Files
libretechandClaude Opus 4.7 6c2e33a3af 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>
2026-04-28 22:16:25 +02:00

138 lines
4.0 KiB
Go

// Package auth implements magic-link authentication and JWT issuance
// for the librenotes multi-tenant backend.
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"time"
)
// TokenLifetime is how long a magic link is valid after creation.
const TokenLifetime = 15 * time.Minute
// ErrInvalidToken is returned for unknown, expired, or already-used tokens.
var ErrInvalidToken = errors.New("invalid or expired token")
// MagicToken records a single magic-link issuance. The plaintext token
// is never stored — only its SHA-256 hash. The plaintext lives only in
// the email sent to the user.
type MagicToken struct {
UserID string
Email string
CreatedAt time.Time
ExpiresAt time.Time
}
// TokenStore persists magic-link token hashes.
type TokenStore struct {
db *sql.DB
}
// NewTokenStore wraps a database handle.
func NewTokenStore(db *sql.DB) *TokenStore { return &TokenStore{db: db} }
// Issue generates a new random token, stores its hash bound to userID,
// and returns the plaintext token (caller emails it to the user).
func (s *TokenStore) Issue(ctx context.Context, userID, email string) (string, error) {
plaintext, err := randomToken()
if err != nil {
return "", err
}
now := time.Now().UTC()
hash := hashToken(plaintext)
_, err = s.db.ExecContext(ctx,
`INSERT INTO magic_tokens (token_hash, user_id, email, created_at, expires_at) VALUES (?, ?, ?, ?, ?)`,
hash, userID, email, now.Unix(), now.Add(TokenLifetime).Unix())
if err != nil {
return "", fmt.Errorf("insert magic token: %w", err)
}
return plaintext, nil
}
// Consume validates the plaintext token. If valid, it marks the token
// used (single-use) and returns the bound user ID. Comparison goes
// through subtle.ConstantTimeCompare; the hash lookup gives us O(1)
// retrieval without leaking timing for the row scan, and the constant-
// time compare protects against any residual differences.
func (s *TokenStore) Consume(ctx context.Context, plaintext string) (userID string, err error) {
hash := hashToken(plaintext)
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return "", fmt.Errorf("begin: %w", err)
}
defer tx.Rollback()
var (
storedHash string
uid string
expires int64
used sql.NullInt64
)
err = tx.QueryRowContext(ctx,
`SELECT token_hash, user_id, expires_at, used_at FROM magic_tokens WHERE token_hash = ?`,
hash).Scan(&storedHash, &uid, &expires, &used)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrInvalidToken
}
if err != nil {
return "", fmt.Errorf("select magic token: %w", err)
}
if subtle.ConstantTimeCompare([]byte(storedHash), []byte(hash)) != 1 {
return "", ErrInvalidToken
}
if used.Valid {
return "", ErrInvalidToken
}
if time.Now().UTC().Unix() > expires {
return "", ErrInvalidToken
}
res, err := tx.ExecContext(ctx,
`UPDATE magic_tokens SET used_at = ? WHERE token_hash = ? AND used_at IS NULL`,
time.Now().UTC().Unix(), hash)
if err != nil {
return "", fmt.Errorf("mark used: %w", err)
}
n, _ := res.RowsAffected()
if n != 1 {
return "", ErrInvalidToken
}
if err := tx.Commit(); err != nil {
return "", fmt.Errorf("commit: %w", err)
}
return uid, nil
}
// PurgeExpired deletes all expired or used tokens older than retention.
// Callers can run this on a timer to keep the table small.
func (s *TokenStore) PurgeExpired(ctx context.Context, retention time.Duration) error {
cutoff := time.Now().UTC().Add(-retention).Unix()
_, err := s.db.ExecContext(ctx,
`DELETE FROM magic_tokens WHERE expires_at < ? OR (used_at IS NOT NULL AND used_at < ?)`,
cutoff, cutoff)
if err != nil {
return fmt.Errorf("purge: %w", err)
}
return nil
}
func randomToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("rand: %w", err)
}
return hex.EncodeToString(buf), nil
}
func hashToken(plaintext string) string {
sum := sha256.Sum256([]byte(plaintext))
return hex.EncodeToString(sum[:])
}