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(`

Sign in by clicking the link below.

%s

The link is valid for %s and can only be used once.
If you did not request this, ignore this email.

`, 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) }