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
@@ -0,0 +1,208 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lixenwraith/chess/internal/server/mail"
|
||||
"github.com/lixenwraith/chess/internal/server/storage"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
// PasswordResetTTL is how long a reset link works.
|
||||
PasswordResetTTL = time.Hour
|
||||
resetTokenBytes = 32
|
||||
mailSendTimeout = 20 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrMailDisabled reports that no mail transport is configured, so
|
||||
// password recovery is off.
|
||||
ErrMailDisabled = errors.New("password recovery is not configured")
|
||||
// ErrMailFailed reports a delivery failure to the relay.
|
||||
ErrMailFailed = errors.New("could not send the email")
|
||||
// ErrResetInvalid reports an unknown, used, or expired reset token.
|
||||
ErrResetInvalid = errors.New("invalid or expired reset link")
|
||||
)
|
||||
|
||||
// mailer is the configured transport and the client URL reset links open.
|
||||
type mailer struct {
|
||||
sender mail.Sender
|
||||
publicURL *url.URL
|
||||
}
|
||||
|
||||
// SetMailer configures password recovery: sender delivers the email and
|
||||
// publicURL is the web client's address, which the link opens with
|
||||
// ?reset=<token>. A nil sender turns recovery off.
|
||||
func (s *Service) SetMailer(sender mail.Sender, publicURL string) error {
|
||||
if sender == nil {
|
||||
s.mail = nil
|
||||
return nil
|
||||
}
|
||||
u, err := url.Parse(publicURL)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return fmt.Errorf("public URL %q must be absolute (https://host/chess/web-client/)", publicURL)
|
||||
}
|
||||
s.mail = &mailer{sender: sender, publicURL: u}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MailHealth reports "ok" when password recovery can send mail, otherwise
|
||||
// "disabled".
|
||||
func (s *Service) MailHealth() string {
|
||||
if s.mail == nil || s.store == nil {
|
||||
return "disabled"
|
||||
}
|
||||
return "ok"
|
||||
}
|
||||
|
||||
// NewResetToken returns a fresh reset token and the hash stored for it.
|
||||
func NewResetToken() (token string, hash []byte, err error) {
|
||||
raw := make([]byte, resetTokenBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
token = base64.RawURLEncoding.EncodeToString(raw)
|
||||
return token, hashResetToken(token), nil
|
||||
}
|
||||
|
||||
func hashResetToken(token string) []byte {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// ResetLink is the web client URL that opens the password form for token.
|
||||
func ResetLink(publicURL *url.URL, token string) string {
|
||||
u := *publicURL
|
||||
q := u.Query()
|
||||
q.Set("reset", token)
|
||||
u.RawQuery = q.Encode()
|
||||
u.Fragment = ""
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// RequestPasswordReset emails a reset link to the account named by
|
||||
// identifier (username or email) when it has an email address. It reports
|
||||
// success for unknown accounts and accounts without an email, so the request
|
||||
// reveals nothing about who is registered; the caller sees the same answer.
|
||||
func (s *Service) RequestPasswordReset(identifier string) error {
|
||||
if s.store == nil {
|
||||
return ErrStorageDisabled
|
||||
}
|
||||
if s.mail == nil {
|
||||
return ErrMailDisabled
|
||||
}
|
||||
identifier = strings.ToLower(strings.TrimSpace(identifier))
|
||||
var record *storage.UserRecord
|
||||
var err error
|
||||
if strings.Contains(identifier, "@") {
|
||||
record, err = s.store.GetUserByEmail(identifier)
|
||||
} else {
|
||||
record, err = s.store.GetUserByUsername(identifier)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
slog.Info("password reset requested for an unknown account")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: look up user: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
if record.Email == "" {
|
||||
slog.Info("password reset requested for an account without email", "user_id", record.UserID)
|
||||
return nil
|
||||
}
|
||||
|
||||
token, hash, err := NewResetToken()
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate reset token: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if err := s.store.CreatePasswordReset(storage.PasswordResetRecord{
|
||||
UserID: record.UserID, TokenHash: hash, CreatedAt: now, ExpiresAt: now.Add(PasswordResetTTL),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("%w: store reset: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
|
||||
link := ResetLink(s.mail.publicURL, token)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), mailSendTimeout)
|
||||
defer cancel()
|
||||
err = s.mail.sender.Send(ctx, mail.Message{
|
||||
To: record.Email,
|
||||
Subject: "Reset your chess password",
|
||||
Body: fmt.Sprintf(`Hello %s,
|
||||
|
||||
Someone asked to reset the password of your chess account at %s.
|
||||
If it was you, open this link within the next hour:
|
||||
|
||||
%s
|
||||
|
||||
If you did not ask for this, ignore this message: your password stays as
|
||||
it is and nobody else can use the link without this email.
|
||||
`, record.Username, s.mail.publicURL.Host, link),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("password reset email failed", "user_id", record.UserID, "error", err)
|
||||
return ErrMailFailed
|
||||
}
|
||||
slog.Info("password reset email sent", "user_id", record.UserID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetPassword sets a new password for the account whose pending reset
|
||||
// token this is, ends its other sessions, and signs the user in. The token
|
||||
// works once and only until its expiry.
|
||||
func (s *Service) ResetPassword(token, password string) (*User, string, error) {
|
||||
if s.store == nil {
|
||||
return nil, "", ErrStorageDisabled
|
||||
}
|
||||
if raw, err := base64.RawURLEncoding.DecodeString(token); err != nil || len(raw) != resetTokenBytes {
|
||||
return nil, "", ErrResetInvalid
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
userID, err := s.store.ConsumePasswordReset(hashResetToken(token), now)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, "", ErrResetInvalid
|
||||
}
|
||||
return nil, "", fmt.Errorf("%w: consume reset: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
|
||||
passwordHash, err := s.hashPassword(password)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrAuthBusy) {
|
||||
return nil, "", err
|
||||
}
|
||||
return nil, "", fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
if err := s.store.ResetUserPassword(userID, passwordHash); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, "", ErrResetInvalid
|
||||
}
|
||||
return nil, "", fmt.Errorf("%w: reset password: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
record, err := s.store.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("%w: look up user: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
sessionID := uuid.NewString()
|
||||
if err := s.store.CreateSession(storage.SessionRecord{
|
||||
SessionID: sessionID, UserID: userID, CreatedAt: now, ExpiresAt: now.Add(SessionTTL),
|
||||
}); err != nil {
|
||||
return nil, "", fmt.Errorf("%w: create session: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
slog.Info("password reset completed", "user_id", userID)
|
||||
return &User{
|
||||
UserID: record.UserID, Username: record.Username, Email: record.Email, CreatedAt: record.CreatedAt,
|
||||
}, sessionID, nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lixenwraith/chess/internal/server/mail"
|
||||
"github.com/lixenwraith/chess/internal/server/storage"
|
||||
)
|
||||
|
||||
var linkPattern = regexp.MustCompile(`https://example\.test/chess/web-client/\?reset=([A-Za-z0-9_-]{43})`)
|
||||
|
||||
func TestPasswordResetFlow(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
if svc.MailHealth() != "disabled" {
|
||||
t.Fatal("mail reported before configuration")
|
||||
}
|
||||
if err := svc.RequestPasswordReset("alice"); !errors.Is(err, ErrMailDisabled) {
|
||||
t.Fatalf("recovery without mail: %v", err)
|
||||
}
|
||||
dir := filepath.Join(t.TempDir(), "mail")
|
||||
if err := svc.SetMailer(&mail.Dir{Path: dir, From: "chess@example.test"}, "/chess/"); err == nil {
|
||||
t.Fatal("relative public URL accepted")
|
||||
}
|
||||
if err := svc.SetMailer(&mail.Dir{Path: dir, From: "chess@example.test"}, "https://example.test/chess/web-client/"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if svc.MailHealth() != "ok" {
|
||||
t.Fatal("mail not reported ok")
|
||||
}
|
||||
|
||||
user, sessionID, err := svc.RegisterUser("alice", "Alice@Example.test", "Password1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.RegisterUser("bob", "", "Password1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Unknown accounts and accounts without email get the same answer and no mail.
|
||||
for _, id := range []string{"nobody", "nobody@example.test", "bob"} {
|
||||
if err := svc.RequestPasswordReset(id); err != nil {
|
||||
t.Errorf("recover %q: %v", id, err)
|
||||
}
|
||||
}
|
||||
if entries, _ := os.ReadDir(dir); len(entries) != 0 {
|
||||
t.Fatalf("mail sent for unknown accounts: %d files", len(entries))
|
||||
}
|
||||
|
||||
// By email, case-insensitively; then by username, which replaces the link.
|
||||
if err := svc.RequestPasswordReset("ALICE@example.test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := lastMailToken(t, dir)
|
||||
if err := svc.RequestPasswordReset("alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := lastMailToken(t, dir)
|
||||
if token == first {
|
||||
t.Fatal("second request reused the token")
|
||||
}
|
||||
if _, _, err := svc.ResetPassword(first, "Password2"); !errors.Is(err, ErrResetInvalid) {
|
||||
t.Errorf("replaced token: %v", err)
|
||||
}
|
||||
for _, bad := range []string{"", "x", strings.Repeat("A", 43), token + "A"} {
|
||||
if _, _, err := svc.ResetPassword(bad, "Password2"); !errors.Is(err, ErrResetInvalid) {
|
||||
t.Errorf("token %q: %v", bad, err)
|
||||
}
|
||||
}
|
||||
|
||||
reset, newSession, err := svc.ResetPassword(token, "Password2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reset.UserID != user.UserID || reset.Username != "alice" || newSession == "" || newSession == sessionID {
|
||||
t.Fatalf("reset returned %+v, session %q", reset, newSession)
|
||||
}
|
||||
if _, _, err := svc.ResetPassword(token, "Password3"); !errors.Is(err, ErrResetInvalid) {
|
||||
t.Errorf("token reused: %v", err)
|
||||
}
|
||||
// The old session is gone, the new one and the new password work.
|
||||
if _, _, err := svc.ValidateToken(mustToken(t, svc, user.UserID, sessionID)); err == nil {
|
||||
t.Error("old session still valid")
|
||||
}
|
||||
if _, _, err := svc.ValidateToken(mustToken(t, svc, user.UserID, newSession)); err != nil {
|
||||
t.Errorf("new session invalid: %v", err)
|
||||
}
|
||||
if _, _, err := svc.AuthenticateUser("alice", "Password1"); !errors.Is(err, ErrInvalidCredentials) {
|
||||
t.Errorf("old password: %v", err)
|
||||
}
|
||||
if _, _, err := svc.AuthenticateUser("alice", "Password2"); err != nil {
|
||||
t.Errorf("new password: %v", err)
|
||||
}
|
||||
|
||||
// An expired link is refused and the cleanup removes it.
|
||||
expired, hash, err := NewResetToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
past := time.Now().UTC().Add(-time.Minute)
|
||||
if err := svc.store.CreatePasswordReset(storage.PasswordResetRecord{
|
||||
UserID: user.UserID, TokenHash: hash, CreatedAt: past.Add(-time.Hour), ExpiresAt: past,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.ResetPassword(expired, "Password4"); !errors.Is(err, ErrResetInvalid) {
|
||||
t.Errorf("expired token: %v", err)
|
||||
}
|
||||
if deleted, err := svc.store.DeleteExpiredPasswordResets(); err != nil || deleted != 1 {
|
||||
t.Errorf("expired resets deleted = %d, %v", deleted, err)
|
||||
}
|
||||
}
|
||||
|
||||
func lastMailToken(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil || len(entries) == 0 {
|
||||
t.Fatalf("no mail in %s: %v", dir, err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, entries[len(entries)-1].Name()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(data)
|
||||
if !strings.Contains(text, "To: alice@example.test\r\n") || !strings.Contains(text, "Subject: Reset your chess password\r\n") {
|
||||
t.Fatalf("mail = %q", text)
|
||||
}
|
||||
m := linkPattern.FindStringSubmatch(text)
|
||||
if m == nil {
|
||||
t.Fatalf("no reset link in %q", text)
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
func mustToken(t *testing.T, svc *Service, userID, sessionID string) string {
|
||||
t.Helper()
|
||||
token, err := svc.GenerateUserToken(userID, sessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
@@ -41,6 +41,8 @@ type Service struct {
|
||||
|
||||
integrityMode IntegrityMode
|
||||
integrityCursor string // last game ID the integrity sweep verified; cleanup goroutine only
|
||||
|
||||
mail *mailer // password recovery; nil when no transport is configured
|
||||
}
|
||||
|
||||
// New creates a service with optional storage. jwtSecret signs session tokens
|
||||
@@ -193,6 +195,11 @@ func (s *Service) cleanupExpired() {
|
||||
} else if deleted > 0 {
|
||||
slog.Info("cleanup deleted expired sessions", "count", deleted)
|
||||
}
|
||||
if deleted, err := s.store.DeleteExpiredPasswordResets(); err != nil {
|
||||
slog.Error("cleanup failed to delete expired password resets", "error", err)
|
||||
} else if deleted > 0 {
|
||||
slog.Info("cleanup deleted expired password resets", "count", deleted)
|
||||
}
|
||||
}
|
||||
s.cleanupGames(now)
|
||||
s.checkIntegrity(now)
|
||||
|
||||
Reference in new issue
Block a user