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

148 lines
4.4 KiB
Go

package storage
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
// User represents a tenant of the librenotes service.
type User struct {
ID string
Email string
CreatedAt time.Time
LastLoginAt *time.Time
StoragePath string
}
// ErrNotFound indicates that a user lookup did not match any row.
var ErrNotFound = errors.New("user not found")
// ErrEmailTaken indicates that the given email already maps to a user.
var ErrEmailTaken = errors.New("email already registered")
// UserStore is the persistence interface for users. Implementations are
// expected to be safe for concurrent use.
type UserStore struct {
db *sql.DB
}
// NewUserStore wraps a database handle.
func NewUserStore(db *sql.DB) *UserStore { return &UserStore{db: db} }
// Create inserts a new user. The ID is generated as a UUIDv4 if empty.
// Email is normalised (trimmed + lowercased) before insertion. The
// StoragePath is preserved as given so the caller can decide on the
// filesystem layout.
func (s *UserStore) Create(ctx context.Context, u User) (User, error) {
if u.ID == "" {
u.ID = uuid.NewString()
}
u.Email = normaliseEmail(u.Email)
if u.Email == "" {
return User{}, fmt.Errorf("email required")
}
if u.StoragePath == "" {
return User{}, fmt.Errorf("storage_path required")
}
if u.CreatedAt.IsZero() {
u.CreatedAt = time.Now().UTC()
}
const q = `INSERT INTO users (id, email, created_at, last_login_at, storage_path)
VALUES (?, ?, ?, ?, ?)`
var lastLogin sql.NullInt64
if u.LastLoginAt != nil {
lastLogin = sql.NullInt64{Int64: u.LastLoginAt.Unix(), Valid: true}
}
_, err := s.db.ExecContext(ctx, q, u.ID, u.Email, u.CreatedAt.Unix(), lastLogin, u.StoragePath)
if err != nil {
if isUniqueErr(err) {
return User{}, ErrEmailTaken
}
return User{}, fmt.Errorf("insert user: %w", err)
}
return u, nil
}
// GetByID fetches a user by primary key.
func (s *UserStore) GetByID(ctx context.Context, id string) (User, error) {
return s.scanOne(ctx, `SELECT id, email, created_at, last_login_at, storage_path FROM users WHERE id = ?`, id)
}
// GetByEmail fetches a user by their (normalised) email.
func (s *UserStore) GetByEmail(ctx context.Context, email string) (User, error) {
return s.scanOne(ctx, `SELECT id, email, created_at, last_login_at, storage_path FROM users WHERE email = ?`, normaliseEmail(email))
}
// SetStoragePath updates the per-user storage path. Used during the
// first-login auto-create flow once the UUID is known.
func (s *UserStore) SetStoragePath(ctx context.Context, id, path string) error {
res, err := s.db.ExecContext(ctx, `UPDATE users SET storage_path = ? WHERE id = ?`, path, id)
if err != nil {
return fmt.Errorf("update storage_path: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
// UpdateLastLogin records a successful login at the given instant.
func (s *UserStore) UpdateLastLogin(ctx context.Context, id string, at time.Time) error {
res, err := s.db.ExecContext(ctx, `UPDATE users SET last_login_at = ? WHERE id = ?`, at.Unix(), id)
if err != nil {
return fmt.Errorf("update last_login: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
// Delete removes a user row. Returns ErrNotFound if no row matched.
func (s *UserStore) Delete(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete user: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
func (s *UserStore) scanOne(ctx context.Context, q string, args ...any) (User, error) {
var u User
var created int64
var lastLogin sql.NullInt64
err := s.db.QueryRowContext(ctx, q, args...).Scan(&u.ID, &u.Email, &created, &lastLogin, &u.StoragePath)
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrNotFound
}
if err != nil {
return User{}, fmt.Errorf("scan user: %w", err)
}
u.CreatedAt = time.Unix(created, 0).UTC()
if lastLogin.Valid {
t := time.Unix(lastLogin.Int64, 0).UTC()
u.LastLoginAt = &t
}
return u, nil
}
func normaliseEmail(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
func isUniqueErr(err error) bool {
// modernc.org/sqlite returns errors whose Error() text contains
// "UNIQUE constraint failed". This is stable across versions.
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
}