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,147 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user