Files
dance-lessons-coach/pkg/user/api/magic_link_handler.go
Gabriel Radureau d863d70f7a feat(bdd): magic-link BDD scenarios + bcrypt overflow fix (ADR-0028 Phase A.5)
Adds 4 BDD scenarios covering the passwordless magic-link flow:
- Happy path (request -> email arrives -> consume -> JWT)
- Token cannot be consumed twice (single-use guarantee)
- Missing token returns 400
- Unknown token returns 401

Implementation:
- features/auth/magic_link.feature with the gherkin spec
- pkg/bdd/steps/magic_link_steps.go: per-scenario unique recipient
  (`<scenario-key>-<8hex>@bdd.local`, ADR-0030), Mailpit-driven token
  extraction, regex parse of the consume URL
- pkg/bdd/steps/scenario_state.go: 2 fields added (MagicLinkEmail,
  MagicLinkToken)
- pkg/bdd/steps/steps.go: register 5 new step regexes

Bug fix exposed by the BDD run:
- pkg/user/api/magic_link_handler.go: passwordless-signup random password
  was 96 hex chars (48 bytes) which overflowed bcrypt's 72-byte input
  limit, breaking first-link signup. Reduced to 64 hex chars (32 bytes,
  256 bits entropy).

Test infra fix:
- pkg/bdd/testserver/server.go: createTestConfig() builds the
  Config literal directly (no Viper defaults), so add explicit Email +
  MagicLink config so the From address is set when the handler sends
  via local Mailpit.

Mistral wrote the feature file, magic_link_steps.go, scenario_state.go
edit, and steps.go edit autonomously in a worktree workspace. Claude
fixed the bcrypt overflow + the test-config gap exposed during verification.

Most authoring by Mistral Vibe (mistral-vibe-cli-latest).
2026-05-05 11:44:20 +02:00

278 lines
9.1 KiB
Go

package api
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"dance-lessons-coach/pkg/config"
"dance-lessons-coach/pkg/email"
"dance-lessons-coach/pkg/user"
"dance-lessons-coach/pkg/validation"
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log"
)
// MagicLinkHandler exposes the passwordless-auth endpoints described
// in ADR-0028 Phase A : `POST /magic-link/request` and
// `GET /magic-link/consume?token=...`.
type MagicLinkHandler struct {
tokens user.MagicLinkRepository
users user.UserService
repo user.UserRepository // for GetUserByUsername (sign-up flow)
sender email.Sender
cfg config.MagicLinkConfig
emailFrom string
validator *validation.Validator
clock func() time.Time
newPassword func() (string, error)
}
// NewMagicLinkHandler wires the handler. emailFrom must be the From
// address (typically cfg.GetEmailConfig().From).
func NewMagicLinkHandler(
tokens user.MagicLinkRepository,
users user.UserService,
repo user.UserRepository,
sender email.Sender,
cfg config.MagicLinkConfig,
emailFrom string,
validator *validation.Validator,
) *MagicLinkHandler {
return &MagicLinkHandler{
tokens: tokens,
users: users,
repo: repo,
sender: sender,
cfg: cfg,
emailFrom: emailFrom,
validator: validator,
clock: time.Now,
newPassword: func() (string, error) {
// 32 bytes = 256 bits of entropy. Encoded as 64 hex chars
// (well under bcrypt's 72-byte input limit; 48 bytes -> 96
// hex chars overflowed and broke first-link signup).
var raw [32]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", err
}
return hex.EncodeToString(raw[:]), nil
},
}
}
// RegisterRoutes mounts the two endpoints on the provided router.
func (h *MagicLinkHandler) RegisterRoutes(router chi.Router) {
router.Post("/magic-link/request", h.handleRequest)
router.Get("/magic-link/consume", h.handleConsume)
}
// MagicLinkRequest is the body of POST /magic-link/request.
type MagicLinkRequest struct {
Email string `json:"email" validate:"required,email,max=255"`
}
// MagicLinkResponse is the response shape for both endpoints.
type MagicLinkResponse struct {
Message string `json:"message"`
Token string `json:"token,omitempty"`
}
// handleRequest godoc
//
// @Summary Request a magic link
// @Description Generates a passwordless-auth one-time token and emails it. Always 200 to prevent email enumeration.
// @Tags API/v1/User
// @Accept json
// @Produce json
// @Param request body MagicLinkRequest true "Email address"
// @Success 200 {object} MagicLinkResponse "Email queued (or silently dropped)"
// @Failure 400 {object} map[string]string "Invalid request body"
// @Router /v1/auth/magic-link/request [post]
func (h *MagicLinkHandler) handleRequest(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req MagicLinkRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid_request","message":"Invalid JSON request body"}`, http.StatusBadRequest)
return
}
if h.validator != nil {
if err := h.validator.Validate(req); err != nil {
h.writeValidationError(w, err)
return
}
}
addr := strings.ToLower(strings.TrimSpace(req.Email))
plain, hashHex, err := user.GenerateMagicLinkToken()
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("magic link request: rand failed")
http.Error(w, `{"error":"server_error","message":"Failed to generate token"}`, http.StatusInternalServerError)
return
}
now := h.clock()
tok := &user.MagicLinkToken{
Email: addr,
TokenHash: hashHex,
ExpiresAt: now.Add(h.cfg.TTL),
}
if err := h.tokens.CreateMagicLinkToken(ctx, tok); err != nil {
log.Error().Ctx(ctx).Err(err).Str("email", addr).Msg("magic link request: persist failed")
writeJSON(w, http.StatusOK, MagicLinkResponse{Message: "If that email is valid, a link has been sent."})
return
}
link := buildMagicLinkURL(h.cfg.BaseURL, plain)
subject := "Your sign-in link"
bodyText := fmt.Sprintf("Sign in by clicking the link below.\n\n%s\n\nThe link is valid for %s and can only be used once.\nIf you did not request this, ignore this email.\n", link, h.cfg.TTL)
bodyHTML := fmt.Sprintf(`<p>Sign in by clicking the link below.</p><p><a href="%s">%s</a></p><p>The link is valid for %s and can only be used once.<br>If you did not request this, ignore this email.</p>`, link, link, h.cfg.TTL)
msg := email.Message{
From: h.emailFrom,
To: addr,
Subject: subject,
BodyText: bodyText,
BodyHTML: bodyHTML,
}
if err := h.sender.Send(ctx, msg); err != nil {
log.Error().Ctx(ctx).Err(err).Str("to", addr).Msg("magic link request: email send failed")
}
writeJSON(w, http.StatusOK, MagicLinkResponse{Message: "If that email is valid, a link has been sent."})
}
// handleConsume validates the token, marks it consumed, ensures a
// matching User row exists (sign-up on first link), and issues a JWT.
//
// All failure modes (missing, expired, already-consumed) collapse to a
// single 401 to prevent attackers distinguishing them.
//
// @Summary Consume a magic link
// @Description Validates the magic-link token, ensures the user exists (signup-on-first-use), issues a JWT.
// @Tags API/v1/User
// @Produce json
// @Param token query string true "The magic-link token"
// @Success 200 {object} MagicLinkResponse "Signed in"
// @Failure 400 {object} map[string]string "Missing token"
// @Failure 401 {object} map[string]string "Invalid or expired token"
// @Router /v1/auth/magic-link/consume [get]
func (h *MagicLinkHandler) handleConsume(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
plain := strings.TrimSpace(r.URL.Query().Get("token"))
if plain == "" {
writeJSONError(w, http.StatusBadRequest, "invalid_request", "missing token")
return
}
tok, err := h.tokens.GetMagicLinkTokenByHash(ctx, user.HashMagicLinkToken(plain))
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("magic link consume: lookup failed")
writeJSONError(w, http.StatusInternalServerError, "server_error", "lookup failed")
return
}
if tok == nil || tok.ConsumedAt != nil || h.clock().After(tok.ExpiresAt) {
writeJSONError(w, http.StatusUnauthorized, "invalid_token", "magic link is invalid or expired")
return
}
if err := h.tokens.MarkMagicLinkTokenConsumed(ctx, tok.ID, h.clock()); err != nil {
log.Error().Ctx(ctx).Err(err).Uint("id", tok.ID).Msg("magic link consume: mark failed")
writeJSONError(w, http.StatusInternalServerError, "server_error", "consume failed")
return
}
u, err := h.ensureUser(ctx, tok.Email)
if err != nil {
log.Error().Ctx(ctx).Err(err).Str("email", tok.Email).Msg("magic link consume: user upsert failed")
writeJSONError(w, http.StatusInternalServerError, "server_error", "user upsert failed")
return
}
jwt, err := h.users.GenerateJWT(ctx, u)
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("magic link consume: JWT generation failed")
writeJSONError(w, http.StatusInternalServerError, "server_error", "jwt generation failed")
return
}
writeJSON(w, http.StatusOK, MagicLinkResponse{Message: "signed in", Token: jwt})
}
// ensureUser returns the user keyed on email (stored as Username),
// creating them if absent. Newly-created users get a random unguessable
// bcrypt-hashed password so the password endpoints stay locked out.
func (h *MagicLinkHandler) ensureUser(ctx context.Context, email string) (*user.User, error) {
if h.repo != nil {
existing, err := h.repo.GetUserByUsername(ctx, email)
if err != nil {
return nil, err
}
if existing != nil {
return existing, nil
}
}
rawPass, err := h.newPassword()
if err != nil {
return nil, fmt.Errorf("magic link signup rand: %w", err)
}
hash, err := h.users.HashPassword(ctx, rawPass)
if err != nil {
return nil, fmt.Errorf("magic link signup hash: %w", err)
}
u := &user.User{
Username: email,
PasswordHash: hash,
IsAdmin: false,
}
if err := h.users.CreateUser(ctx, u); err != nil {
return nil, fmt.Errorf("magic link signup create: %w", err)
}
if h.repo != nil {
return h.repo.GetUserByUsername(ctx, email)
}
return u, nil
}
func (h *MagicLinkHandler) writeValidationError(w http.ResponseWriter, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
var ve *validation.ValidationError
if errors.As(err, &ve) {
_ = json.NewEncoder(w).Encode(map[string]any{
"error": "validation_failed",
"message": "Invalid request data",
"details": ve.Messages,
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"error": "validation_failed",
"message": err.Error(),
})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeJSONError(w http.ResponseWriter, status int, code, msg string) {
writeJSON(w, status, map[string]string{"error": code, "message": msg})
}
func buildMagicLinkURL(baseURL, token string) string {
base := strings.TrimRight(baseURL, "/")
return fmt.Sprintf("%s/api/v1/auth/magic-link/consume?token=%s", base, token)
}