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
|
||||
}
|
||||
Reference in new issue
Block a user