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>
148 lines
4.4 KiB
Go
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")
|
|
}
|