✨ 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>
This commit was merged in pull request #62.
This commit is contained in:
274
pkg/user/api/magic_link_handler.go
Normal file
274
pkg/user/api/magic_link_handler.go
Normal file
@@ -0,0 +1,274 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user