Implement email magic-link authentication
internal/auth/ provides: - TokenStore: 32-byte cryptographically random one-time tokens. Only the SHA-256 hash is persisted (so a DB leak doesn't grant active sessions). Comparison uses subtle.ConstantTimeCompare. Single-use is enforced via UPDATE ... WHERE used_at IS NULL. - Signer: HS256 JWTs with 24h lifetime, jwt.WithValidMethods to reject alg=none and other downgrade attacks. - LogMailer (dev) and SMTPMailer (prod via net/smtp) behind a Mailer interface. - RateLimiter: DB-backed fixed window per email; default 5 per 15 min for the magic-link flow. - Service: orchestrates RequestLogin (auto-creates user on first login, generates token, emails magic link) and Verify (consumes token, updates last_login, issues JWT). - Handlers: POST /auth/login and GET/POST /auth/verify. HandleLogin returns 202 even on validation failure to avoid account enumeration; rate-limit hits surface as 429. Schema additions: magic_tokens (with FK + cascade) and login_attempts. UserStore.SetStoragePath added for completeness. Tests cover: token issue/consume, single-use, expiry, rate limit, JWT round-trip, alg=none rejection, signature tampering, purge, HTTP handlers (login + verify, missing/invalid token paths). Closes #9. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrRateLimited indicates that too many requests have been made for a
|
||||
// given key within the configured window.
|
||||
var ErrRateLimited = errors.New("rate limited")
|
||||
|
||||
// RateLimiter enforces a fixed-window rate limit per email using the
|
||||
// login_attempts table. The DB-backed approach survives restarts and
|
||||
// works across multiple processes sharing the same SQLite file.
|
||||
type RateLimiter struct {
|
||||
db *sql.DB
|
||||
window time.Duration
|
||||
max int
|
||||
}
|
||||
|
||||
// NewRateLimiter creates a limiter with the given window and maximum
|
||||
// attempts. Default for magic-link login: 5 per 15 min.
|
||||
func NewRateLimiter(db *sql.DB, window time.Duration, max int) *RateLimiter {
|
||||
return &RateLimiter{db: db, window: window, max: max}
|
||||
}
|
||||
|
||||
// Check records an attempt for email and returns ErrRateLimited if the
|
||||
// number of attempts within the window exceeds max.
|
||||
func (r *RateLimiter) Check(ctx context.Context, email string) error {
|
||||
now := time.Now().UTC()
|
||||
cutoff := now.Add(-r.window).Unix()
|
||||
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM login_attempts WHERE created_at < ?`, cutoff); err != nil {
|
||||
return fmt.Errorf("prune: %w", err)
|
||||
}
|
||||
var count int
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM login_attempts WHERE email = ? AND created_at >= ?`,
|
||||
email, cutoff).Scan(&count); err != nil {
|
||||
return fmt.Errorf("count: %w", err)
|
||||
}
|
||||
if count >= r.max {
|
||||
return ErrRateLimited
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO login_attempts (email, created_at) VALUES (?, ?)`,
|
||||
email, now.Unix()); err != nil {
|
||||
return fmt.Errorf("insert: %w", err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
Reference in New Issue
Block a user