Files
dance-lessons-coach/pkg/user/api/magic_link_handler.go
Gabriel Radureau f39acf5de5
All checks were successful
CI/CD Pipeline / Build Docker Cache (push) Successful in 8s
CI/CD Pipeline / CI Pipeline (push) Successful in 4m56s
CI/CD Pipeline / Trigger Docker Push (push) Successful in 6s
feat(auth): magic-link request + consume HTTP handlers (ADR-0028 Phase A.4) (#62)
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-05 11:32:12 +02:00

275 lines
8.9 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) {
var raw [48]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)
}