package server import ( "errors" "net/http" "time" "galaxy/backend/internal/auth" "galaxy/backend/internal/server/clientip" "galaxy/backend/internal/server/handlers" "galaxy/backend/internal/server/httperr" "galaxy/backend/internal/telemetry" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" ) // PublicAuthHandlers groups the public unauthenticated auth handlers // under `/api/v1/public/auth/*`. The current implementation ships the real challenge // issuance and confirmation flows; tests that supply a nil *auth.Service // fall back to the Stage-3 placeholder body so the contract test // continues to validate the OpenAPI envelope without booting a database. type PublicAuthHandlers struct { svc *auth.Service logger *zap.Logger } // NewPublicAuthHandlers constructs the handler set. svc may be nil — in // that case every handler returns 501 not_implemented, matching the // pre-Stage-5.1 placeholder. logger may also be nil; zap.NewNop is used // in that case. func NewPublicAuthHandlers(svc *auth.Service, logger *zap.Logger) *PublicAuthHandlers { if logger == nil { logger = zap.NewNop() } return &PublicAuthHandlers{svc: svc, logger: logger.Named("http.public.auth")} } // SendEmailCode handles POST /api/v1/public/auth/send-email-code. func (h *PublicAuthHandlers) SendEmailCode() gin.HandlerFunc { if h.svc == nil { return handlers.NotImplemented("publicAuthSendEmailCode") } return func(c *gin.Context) { var req struct { Email string `json:"email"` Locale string `json:"locale"` } if err := c.ShouldBindJSON(&req); err != nil { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON") return } email := validateEmail(req.Email) if email == "" { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "email is invalid") return } ctx := c.Request.Context() challengeID, err := h.svc.SendEmailCode(ctx, email, req.Locale, c.GetHeader("Accept-Language"), clientip.ExtractSourceIP(c)) if err != nil { switch { case errors.Is(err, auth.ErrEmailPermanentlyBlocked): httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "email is not allowed") default: h.logger.Error("send-email-code failed", append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))..., ) httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error") } return } c.JSON(http.StatusOK, gin.H{"challenge_id": challengeID.String()}) } } // ConfirmEmailCode handles POST /api/v1/public/auth/confirm-email-code. func (h *PublicAuthHandlers) ConfirmEmailCode() gin.HandlerFunc { if h.svc == nil { return handlers.NotImplemented("publicAuthConfirmEmailCode") } return func(c *gin.Context) { var req struct { ChallengeID string `json:"challenge_id"` Code string `json:"code"` ClientPublicKey string `json:"client_public_key"` TimeZone string `json:"time_zone"` } if err := c.ShouldBindJSON(&req); err != nil { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON") return } challengeID, err := uuid.Parse(req.ChallengeID) if err != nil { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "challenge_id must be a valid UUID") return } if !isDecimalCodeOfLength(req.Code, auth.CodeLength) { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "code must be a 6-digit decimal string") return } clientPubKey, ok := decodeClientPublicKey(req.ClientPublicKey) if !ok { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "client_public_key must be a base64-encoded 32-byte Ed25519 key") return } if _, err := time.LoadLocation(req.TimeZone); err != nil { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "time_zone must be a valid IANA zone") return } ctx := c.Request.Context() session, err := h.svc.ConfirmEmailCode(ctx, auth.ConfirmInputs{ ChallengeID: challengeID, Code: req.Code, ClientPublicKey: clientPubKey, TimeZone: req.TimeZone, SourceIP: clientip.ExtractSourceIP(c), }) if err != nil { switch { case errors.Is(err, auth.ErrChallengeNotFound): httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "challenge is not redeemable") case errors.Is(err, auth.ErrCodeMismatch): httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "code is incorrect") case errors.Is(err, auth.ErrTooManyAttempts): httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "too many attempts") case errors.Is(err, auth.ErrEmailPermanentlyBlocked): httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "email is not allowed") default: h.logger.Error("confirm-email-code failed", append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))..., ) httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error") } return } c.JSON(http.StatusOK, gin.H{"device_session_id": session.DeviceSessionID.String()}) } }