feat(auth): magic-link request + consume HTTP handlers (ADR-0028 Phase A.4)

Adds the two passwordless-auth endpoints behind /api/v1/auth/:
  POST /magic-link/request   — body {email}; always 200 (no enumeration leak)
  GET  /magic-link/consume   — ?token=...; signs in (signup-on-first-link)

Sign-up flow: first consume for an unknown email creates the user with a
random unguessable bcrypt-hashed password — keeps the schema NOT NULL
constraint while permanently locking the password endpoints out.

Failure modes (missing/expired/already-consumed) collapse to a single
401 to prevent attackers distinguishing them. DB persist failures on
request silently degrade to the generic 200 to avoid leaking internal
state.

Config:
  auth.magic_link.ttl       (default 15m, env DLC_AUTH_MAGIC_LINK_TTL)
  auth.magic_link.base_url  (default http://localhost:8080)

Tests: 11 unit tests against fakes (repo, user service, sender) cover
happy path (new + existing user), normalization, bad JSON, persist
failure, missing/unknown/expired/consumed token, URL builder.
This commit is contained in:
2026-05-05 11:31:48 +02:00
parent c9ab876dfe
commit cbd2ae7c0e
4 changed files with 701 additions and 4 deletions

View File

@@ -20,6 +20,7 @@ import (
"dance-lessons-coach/pkg/cache"
"dance-lessons-coach/pkg/config"
"dance-lessons-coach/pkg/email"
"dance-lessons-coach/pkg/greet"
"dance-lessons-coach/pkg/middleware"
"dance-lessons-coach/pkg/telemetry"
@@ -252,6 +253,29 @@ func (s *Server) registerApiV1Routes(r chi.Router) {
handler := userapi.NewAuthHandler(s.userService, s.userService, s.validator)
r.Route("/auth", func(r chi.Router) {
handler.RegisterRoutes(r)
// Magic-link routes (ADR-0028 Phase A). Mounted only when the
// userRepo also implements MagicLinkRepository (PostgresRepository does).
if mlRepo, ok := s.userRepo.(user.MagicLinkRepository); ok {
emailCfg := s.config.GetEmailConfig()
sender := email.NewSMTPSender(email.SMTPConfig{
Host: emailCfg.SMTPHost,
Port: emailCfg.SMTPPort,
Username: emailCfg.SMTPUsername,
Password: emailCfg.SMTPPassword,
UseTLS: emailCfg.SMTPUseTLS,
Timeout: emailCfg.Timeout,
})
mlHandler := userapi.NewMagicLinkHandler(
mlRepo,
s.userService,
s.userRepo,
sender,
s.config.GetMagicLinkConfig(),
emailCfg.From,
s.validator,
)
mlHandler.RegisterRoutes(r)
}
})
// Register admin routes