v0.15.0 password recovery, phone layout, CLI pager, embed policy fixes
This commit is contained in:
1 parent
a999177f0c
commit
e1fa5242e3
48 files changed
+2101
-135
No files matched your search
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/lixenwraith/chess/internal/server/replay"
|
||||
"github.com/lixenwraith/chess/internal/server/service"
|
||||
"github.com/lixenwraith/chess/internal/server/storage"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -40,7 +42,7 @@ func Run(args []string) error {
|
||||
return runVerify(args[1:])
|
||||
case "user":
|
||||
if len(args) < 2 {
|
||||
return fmt.Errorf("user subcommand required: add, delete, set-password, set-hash, set-email, set-username, list")
|
||||
return fmt.Errorf("user subcommand required: add, delete, set-password, set-hash, set-email, set-username, reset-link, list")
|
||||
}
|
||||
return runUser(args[1], args[2:])
|
||||
default:
|
||||
@@ -303,6 +305,8 @@ func runUser(subcommand string, args []string) error {
|
||||
return runUserSetEmail(args)
|
||||
case "set-username":
|
||||
return runUserSetUsername(args)
|
||||
case "reset-link":
|
||||
return runUserResetLink(args)
|
||||
case "list":
|
||||
return runUserList(args)
|
||||
default:
|
||||
@@ -310,6 +314,48 @@ func runUser(subcommand string, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// runUserResetLink prints a one-time password-reset link for an account, the
|
||||
// same link the recovery email carries, for an administrator to pass on when
|
||||
// the server sends no mail or the account has no email. It works for an hour
|
||||
// and replaces any pending link.
|
||||
func runUserResetLink(args []string) error {
|
||||
fs, dsn := newFlagSet("user reset-link")
|
||||
username := fs.String("username", "", "Username")
|
||||
userID := fs.String("id", "", "User ID")
|
||||
publicURL := fs.String("url", "", "Web client address the link opens, e.g. https://example.com/chess/web-client/ (required)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
base, err := url.Parse(*publicURL)
|
||||
if *publicURL == "" || err != nil || base.Scheme == "" || base.Host == "" {
|
||||
return fmt.Errorf("-url must be the web client's absolute address")
|
||||
}
|
||||
|
||||
store, err := openStore(*dsn, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
targetID, err := lookupUser(store, *username, *userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token, hash, err := service.NewResetToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if err := store.CreatePasswordReset(storage.PasswordResetRecord{
|
||||
UserID: targetID, TokenHash: hash, CreatedAt: now, ExpiresAt: now.Add(service.PasswordResetTTL),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to create reset: %w", err)
|
||||
}
|
||||
fmt.Printf("Reset link for %s, valid until %s (one use):\n%s\n",
|
||||
abbreviateID(targetID), now.Add(service.PasswordResetTTL).Format(time.RFC3339), service.ResetLink(base, token))
|
||||
return nil
|
||||
}
|
||||
|
||||
// readPassword prompts on the terminal without echo.
|
||||
func readPassword(prompt string) (string, error) {
|
||||
fmt.Print(prompt)
|
||||
|
||||
@@ -5,6 +5,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
|
||||
"github.com/lixenwraith/chess/cmd/chess-server/cli"
|
||||
"github.com/lixenwraith/chess/internal/server/http"
|
||||
"github.com/lixenwraith/chess/internal/server/mail"
|
||||
"github.com/lixenwraith/chess/internal/server/processor"
|
||||
"github.com/lixenwraith/chess/internal/server/service"
|
||||
"github.com/lixenwraith/chess/internal/server/storage"
|
||||
@@ -58,6 +60,17 @@ func main() {
|
||||
jwtFile = flag.String("jwt-secret-file", "", "File holding a stable JWT signing key, mode 0600, at least 32 bytes (default $CHESS_JWT_SECRET_FILE)")
|
||||
dbCleanup = flag.String("db-cleanup", "off", "Hourly integrity sweep of stored games and accounts: off, report (log only), or delete")
|
||||
|
||||
// Password recovery by email: a transport (SMTP relay, or a directory
|
||||
// of .eml files for development) and the web client's address, which
|
||||
// the emailed link opens. Without a transport, recovery is off.
|
||||
smtpHost = flag.String("smtp-host", "", "SMTP relay for password-reset email (recovery is off when empty)")
|
||||
smtpPort = flag.Int("smtp-port", 25, "SMTP relay port")
|
||||
smtpUser = flag.String("smtp-user", "", "SMTP user name (optional; the password comes from -smtp-password-file)")
|
||||
smtpPassFile = flag.String("smtp-password-file", "", "File holding the SMTP password, mode 0600")
|
||||
mailFrom = flag.String("mail-from", "", "Sender address of server email, e.g. \"Chess <chess@example.com>\"")
|
||||
mailDir = flag.String("mail-dir", "", "Write email to .eml files in this directory instead of sending (development)")
|
||||
publicURL = flag.String("public-url", "", "Web client address that emailed links open, e.g. https://example.com/chess/web-client/")
|
||||
|
||||
// Web UI server flags
|
||||
serve = flag.Bool("serve", false, "Enable web UI server")
|
||||
webHost = flag.String("web-host", "localhost", "Web UI server host")
|
||||
@@ -165,6 +178,27 @@ func main() {
|
||||
}
|
||||
svc.SetIntegrityMode(integrity)
|
||||
|
||||
// Password recovery needs mail and the client's public address.
|
||||
sender, err := mail.Config{
|
||||
SMTPHost: *smtpHost, SMTPPort: *smtpPort, SMTPUser: *smtpUser, SMTPPasswordFile: *smtpPassFile,
|
||||
Dir: *mailDir, From: *mailFrom,
|
||||
}.Build()
|
||||
switch {
|
||||
case errors.Is(err, mail.ErrNoSender):
|
||||
log.Printf("Password recovery disabled (no -smtp-host or -mail-dir)")
|
||||
case err != nil:
|
||||
log.Fatalf("Invalid mail configuration: %v", err)
|
||||
case store == nil:
|
||||
log.Fatalf("Password recovery (-smtp-host or -mail-dir) requires a database (-dsn)")
|
||||
case *publicURL == "":
|
||||
log.Fatalf("Password recovery requires -public-url, the web client address that reset links open")
|
||||
default:
|
||||
if err := svc.SetMailer(sender, *publicURL); err != nil {
|
||||
log.Fatalf("Invalid -public-url: %v", err)
|
||||
}
|
||||
log.Printf("Password recovery enabled: %s; links open %s", sender.Describe(), *publicURL)
|
||||
}
|
||||
|
||||
// Start cleanup job for expired users/sessions
|
||||
cleanupCtx, cleanupCancel := context.WithCancel(context.Background())
|
||||
cleanupDone := make(chan struct{})
|
||||
|
||||
Reference in new issue
Block a user