package auth import ( "encoding/json" "net" "net/http" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "github.com/wangjia/pangolin/server/internal/apierr" ) // Handler adapts the auth Service to HTTP. Route shapes follow the OpenAPI // contract (POST /v1/auth/{code,register,login,refresh}). type Handler struct { svc *Service } // NewHandler builds a Handler. func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } // RegisterRoutes mounts the four public auth endpoints onto r. The caller is // expected to mount this group WITHOUT the bearer-auth middleware. func (h *Handler) RegisterRoutes(r chi.Router) { r.Post("/auth/code", h.SendCode) r.Post("/auth/register", h.Register) r.Post("/auth/login", h.Login) r.Post("/auth/refresh", h.Refresh) r.Post("/auth/logout", h.Logout) } // Logout handles POST /v1/auth/logout. The refresh token to revoke is taken from // the X-Refresh-Token header (the access token in Authorization is not enough — // revocation keys on the refresh JTI). Always 204; the client clears its session // regardless of the server-side outcome. func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { h.svc.Logout(r.Context(), r.Header.Get("X-Refresh-Token")) w.WriteHeader(http.StatusNoContent) } // ---- request/response bodies (mirror openapi.yaml) ---- type sendCodeRequest struct { Email string `json:"email"` } type registerRequest struct { Email string `json:"email"` Code string `json:"code"` Password string `json:"password"` } type loginRequest struct { Email string `json:"email"` Password string `json:"password"` } type refreshRequest struct { RefreshToken string `json:"refresh_token"` } type tokenPairResponse struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int `json:"expires_in"` } // SendCode handles POST /v1/auth/code. func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) { var req sendCodeRequest if !decodeJSON(w, r, &req) { return } retryAfter, apiErr := h.svc.SendCode(r.Context(), req.Email, clientIP(r)) if apiErr != nil { writeAPIErr(w, apiErr, retryAfter) return } w.WriteHeader(http.StatusNoContent) } // Register handles POST /v1/auth/register. func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { var req registerRequest if !decodeJSON(w, r, &req) { return } pair, apiErr := h.svc.Register(r.Context(), req.Email, req.Code, req.Password) if apiErr != nil { writeAPIErr(w, apiErr, 0) return } writeTokenPair(w, pair) } // Login handles POST /v1/auth/login. func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { var req loginRequest if !decodeJSON(w, r, &req) { return } out, retryAfter, apiErr := h.svc.Login(r.Context(), req.Email, req.Password, clientIP(r)) if apiErr != nil { writeAPIErr(w, apiErr, retryAfter) return } // TOTP-enabled accounts get a pending token instead of tokens; the web // user-center then completes login at /auth/login/totp. The app (no TOTP) // always receives the flat token pair below. if out.PendingToken != "" { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(map[string]any{ "totp_required": true, "pending_token": out.PendingToken, }) return } writeTokenPair(w, out.Tokens) } // Refresh handles POST /v1/auth/refresh. func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) { var req refreshRequest if !decodeJSON(w, r, &req) { return } pair, apiErr := h.svc.Refresh(r.Context(), req.RefreshToken) if apiErr != nil { writeAPIErr(w, apiErr, 0) return } writeTokenPair(w, pair) } // ---- helpers ---- // decodeJSON decodes the request body, writing a 400 on malformed input. // Returns false if the caller should stop (error already written). func decodeJSON(w http.ResponseWriter, r *http.Request, dst interface{}) bool { dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)) dec.DisallowUnknownFields() if err := dec.Decode(dst); err != nil { writeAPIErr(w, ErrInvalidRequest, 0) return false } return true } // writeTokenPair writes a 200 TokenPair body. func writeTokenPair(w http.ResponseWriter, pair *TokenPair) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(tokenPairResponse{ AccessToken: pair.AccessToken, RefreshToken: pair.RefreshToken, ExpiresIn: pair.ExpiresIn, }) } // writeAPIErr maps an apierr.Error to the right HTTP status, attaching a // Retry-After header when provided. func writeAPIErr(w http.ResponseWriter, e *apierr.Error, retryAfter time.Duration) { status := statusFor(e) if retryAfter > 0 { secs := int(retryAfter.Seconds()) if secs < 1 { secs = 1 } w.Header().Set("Retry-After", strconv.Itoa(secs)) } apierr.WriteJSON(w, status, e) } // statusFor maps auth error codes to HTTP status codes. func statusFor(e *apierr.Error) int { switch e.Code { case ErrInvalidRequest.Code, ErrEmailDisposable.Code, ErrCodeInvalid.Code: return http.StatusBadRequest case ErrEmailExists.Code: return http.StatusConflict case ErrRateLimited.Code, ErrAccountLocked.Code: return http.StatusTooManyRequests case ErrInvalidCredentials.Code, ErrInvalidToken.Code, ErrUnauthorized.Code: return http.StatusUnauthorized case ErrAccountBanned.Code: return http.StatusForbidden default: return http.StatusInternalServerError } } // clientIP extracts the best-effort client IP, honouring X-Forwarded-For and // X-Real-IP set by the edge proxy. Used only as a rate-limit key — never logged. func clientIP(r *http.Request) string { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { // First entry is the original client. if i := strings.IndexByte(xff, ','); i >= 0 { return strings.TrimSpace(xff[:i]) } return strings.TrimSpace(xff) } if xr := r.Header.Get("X-Real-IP"); xr != "" { return strings.TrimSpace(xr) } host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host }