Co-authored-by: Gabriel Radureau <arcodange@gmail.com> Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
278 lines
9.1 KiB
Go
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)
|
|
}
|