Files
librenotes/internal/auth/service.go
T
libretechandClaude Opus 4.7 d9f3574913 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
3.8 KiB
Go

package auth
import (
"context"
"errors"
"fmt"
"net/url"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"git.librete.ch/public/librenotes/internal/storage"
)
// nowFn is overridable in tests.
var nowFn = func() time.Time { return time.Now().UTC() }
// Service orchestrates the magic-link login flow: request a link by
// email and verify a returned link to issue a JWT session.
type Service struct {
users *storage.UserStore
tokens *TokenStore
limiter *RateLimiter
mailer Mailer
signer *Signer
baseURL string
dataDir string
}
// Config bundles dependencies for NewService.
type Config struct {
Users *storage.UserStore
Tokens *TokenStore
Limiter *RateLimiter
Mailer Mailer
Signer *Signer
// BaseURL is the public origin used to build verification links,
// e.g. "https://librenot.es".
BaseURL string
// DataDir is the parent directory under which per-user note
// directories are created on first login.
DataDir string
}
// NewService validates and assembles a Service.
func NewService(c Config) (*Service, error) {
if c.Users == nil || c.Tokens == nil || c.Limiter == nil || c.Mailer == nil || c.Signer == nil {
return nil, fmt.Errorf("auth: missing dependency")
}
if c.BaseURL == "" {
return nil, fmt.Errorf("auth: BaseURL required")
}
if c.DataDir == "" {
return nil, fmt.Errorf("auth: DataDir required")
}
return &Service{
users: c.Users,
tokens: c.Tokens,
limiter: c.Limiter,
mailer: c.Mailer,
signer: c.Signer,
baseURL: strings.TrimRight(c.BaseURL, "/"),
dataDir: c.DataDir,
}, nil
}
// RequestLogin generates a magic link and emails it. The user is
// auto-created on first login. Returns ErrRateLimited if the email has
// exceeded the limiter window.
func (s *Service) RequestLogin(ctx context.Context, email string) error {
email = strings.ToLower(strings.TrimSpace(email))
if !looksLikeEmail(email) {
return fmt.Errorf("invalid email")
}
if err := s.limiter.Check(ctx, email); err != nil {
return err
}
user, err := s.users.GetByEmail(ctx, email)
if errors.Is(err, storage.ErrNotFound) {
id := uuid.NewString()
path := filepath.Join(s.dataDir, id)
created, cerr := s.users.Create(ctx, storage.User{
ID: id,
Email: email,
StoragePath: path,
})
if cerr != nil {
return fmt.Errorf("create user: %w", cerr)
}
user = created
} else if err != nil {
return fmt.Errorf("lookup user: %w", err)
}
plaintext, err := s.tokens.Issue(ctx, user.ID, email)
if err != nil {
return err
}
link := s.baseURL + "/auth/verify?token=" + url.QueryEscape(plaintext)
if err := s.mailer.SendMagicLink(ctx, email, link); err != nil {
return fmt.Errorf("send mail: %w", err)
}
return nil
}
// VerifyResult holds the outcome of a successful magic-link verification.
type VerifyResult struct {
JWT string
User storage.User
}
// Verify consumes a magic-link token and returns a signed session JWT.
func (s *Service) Verify(ctx context.Context, token string) (VerifyResult, error) {
userID, err := s.tokens.Consume(ctx, token)
if err != nil {
return VerifyResult{}, err
}
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return VerifyResult{}, fmt.Errorf("load user: %w", err)
}
now := nowFn()
if err := s.users.UpdateLastLogin(ctx, user.ID, now); err != nil {
return VerifyResult{}, fmt.Errorf("update last login: %w", err)
}
jwtStr, err := s.signer.Issue(user.ID, user.Email)
if err != nil {
return VerifyResult{}, err
}
return VerifyResult{JWT: jwtStr, User: user}, nil
}
// looksLikeEmail does a minimal sanity check; full validation is left
// to the SMTP server. We just want to reject obvious garbage.
func looksLikeEmail(s string) bool {
at := strings.IndexByte(s, '@')
if at <= 0 || at == len(s)-1 {
return false
}
if strings.ContainsAny(s, " \t\r\n") {
return false
}
return strings.IndexByte(s[at+1:], '.') >= 0
}