package handlers import ( "errors" "net/http" "github.com/go-chi/chi/v5" "git.threesix.ai/jordan/persona-community-1/pkg/app" "git.threesix.ai/jordan/persona-community-1/pkg/auth" "git.threesix.ai/jordan/persona-community-1/pkg/httperror" "git.threesix.ai/jordan/persona-community-1/pkg/httpresponse" "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/domain" ) // --- Request types for auth flows --- // EmailRequest is used by OTP send, magic link, and forgot password. type EmailRequest struct { Email string `json:"email" validate:"required,email"` } // OTPVerifyRequest verifies a one-time password. type OTPVerifyRequest struct { Email string `json:"email" validate:"required,email"` Code string `json:"code" validate:"required,len=6"` } // MagicLinkVerifyRequest verifies a magic link token. type MagicLinkVerifyRequest struct { Email string `json:"email" validate:"required,email"` Token string `json:"token" validate:"required"` } // ResetPasswordRequest sets a new password using a reset token. type ResetPasswordRequest struct { Email string `json:"email" validate:"required,email"` Token string `json:"token" validate:"required"` NewPassword string `json:"newPassword" validate:"required,min=8"` } // VerifyEmailRequest verifies an email with a code. type VerifyEmailRequest struct { Code string `json:"code" validate:"required,len=6"` } // SessionResponse is a single session in the list. type SessionResponse struct { ID string `json:"id"` IPAddress string `json:"ipAddress"` DeviceLabel string `json:"deviceLabel"` LastActiveAt string `json:"lastActiveAt"` CreatedAt string `json:"createdAt"` IsCurrent bool `json:"isCurrent"` } // --- OTP handlers --- // SendOTP sends a one-time password to the user's email. // // POST /api/{service}/auth/otp/send func (h *Auth) SendOTP(w http.ResponseWriter, r *http.Request) error { var req EmailRequest if err := app.BindAndValidate(r, &req); err != nil { return err } if err := h.svc.SendOTP(r.Context(), req.Email, clientIP(r)); err != nil { return mapAuthError(err) } httpresponse.OK(w, r, map[string]string{"message": "If an account exists, a code has been sent"}) return nil } // VerifyOTP verifies a one-time password and returns a login token. // // POST /api/{service}/auth/otp/verify func (h *Auth) VerifyOTP(w http.ResponseWriter, r *http.Request) error { var req OTPVerifyRequest if err := app.BindAndValidate(r, &req); err != nil { return err } output, err := h.svc.VerifyOTP(r.Context(), req.Email, req.Code, clientIP(r), r.UserAgent()) if err != nil { return mapAuthError(err) } httpresponse.OK(w, r, toLoginResponse(output)) return nil } // --- Magic Link handlers --- // SendMagicLink sends a magic link to the user's email. // // POST /api/{service}/auth/magic-link func (h *Auth) SendMagicLink(w http.ResponseWriter, r *http.Request) error { var req EmailRequest if err := app.BindAndValidate(r, &req); err != nil { return err } if err := h.svc.SendMagicLink(r.Context(), req.Email, clientIP(r)); err != nil { return mapAuthError(err) } httpresponse.OK(w, r, map[string]string{"message": "If an account exists, a link has been sent"}) return nil } // VerifyMagicLink verifies a magic link token and returns a login token. // // POST /api/{service}/auth/magic-link/verify func (h *Auth) VerifyMagicLink(w http.ResponseWriter, r *http.Request) error { var req MagicLinkVerifyRequest if err := app.BindAndValidate(r, &req); err != nil { return err } output, err := h.svc.VerifyMagicLink(r.Context(), req.Email, req.Token, clientIP(r), r.UserAgent()) if err != nil { return mapAuthError(err) } httpresponse.OK(w, r, toLoginResponse(output)) return nil } // --- Forgot / Reset Password handlers --- // ForgotPassword sends a password reset token. // // POST /api/{service}/auth/forgot-password func (h *Auth) ForgotPassword(w http.ResponseWriter, r *http.Request) error { var req EmailRequest if err := app.BindAndValidate(r, &req); err != nil { return err } if err := h.svc.ForgotPassword(r.Context(), req.Email, clientIP(r)); err != nil { return mapAuthError(err) } httpresponse.OK(w, r, map[string]string{"message": "If an account exists, a reset link has been sent"}) return nil } // ResetPassword sets a new password using a reset token. // // POST /api/{service}/auth/reset-password func (h *Auth) ResetPassword(w http.ResponseWriter, r *http.Request) error { var req ResetPasswordRequest if err := app.BindAndValidate(r, &req); err != nil { return err } if err := h.svc.ResetPassword(r.Context(), req.Email, req.Token, req.NewPassword); err != nil { return mapAuthError(err) } httpresponse.OK(w, r, map[string]string{"message": "Password has been reset. Please sign in."}) return nil } // --- Email Verification handlers --- // SendVerifyEmail sends a verification code to the current user's email. // // POST /api/{service}/auth/verify-email/send func (h *Auth) SendVerifyEmail(w http.ResponseWriter, r *http.Request) error { user, err := auth.GetUserOrError(r.Context()) if err != nil { return httperror.Unauthorized("not authenticated") } if err := h.svc.SendVerifyEmail(r.Context(), user.ID); err != nil { return mapAuthError(err) } httpresponse.OK(w, r, map[string]string{"message": "Verification code sent"}) return nil } // VerifyEmail verifies the current user's email with a code. // // POST /api/{service}/auth/verify-email func (h *Auth) VerifyEmail(w http.ResponseWriter, r *http.Request) error { user, err := auth.GetUserOrError(r.Context()) if err != nil { return httperror.Unauthorized("not authenticated") } var req VerifyEmailRequest if err := app.BindAndValidate(r, &req); err != nil { return err } if err := h.svc.VerifyEmail(r.Context(), user.ID, req.Code); err != nil { return mapAuthError(err) } httpresponse.OK(w, r, map[string]string{"message": "Email verified"}) return nil } // --- Session Management handlers --- // ListSessions returns all active sessions for the current user. // // GET /api/{service}/auth/sessions func (h *Auth) ListSessions(w http.ResponseWriter, r *http.Request) error { user, err := auth.GetUserOrError(r.Context()) if err != nil { return httperror.Unauthorized("not authenticated") } currentSID := sessionID(user) sessions, err := h.svc.ListSessions(r.Context(), user.ID) if err != nil { return err } result := make([]SessionResponse, 0, len(sessions)) for _, s := range sessions { result = append(result, SessionResponse{ ID: string(s.ID), IPAddress: s.IPAddress, DeviceLabel: s.DeviceLabel, LastActiveAt: s.LastActiveAt.Format("2006-01-02T15:04:05Z07:00"), CreatedAt: s.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), IsCurrent: string(s.ID) == currentSID, }) } httpresponse.OK(w, r, result) return nil } // RevokeSession revokes a specific session. // // DELETE /api/{service}/auth/sessions/{id} func (h *Auth) RevokeSession(w http.ResponseWriter, r *http.Request) error { user, err := auth.GetUserOrError(r.Context()) if err != nil { return httperror.Unauthorized("not authenticated") } sid := chi.URLParam(r, "id") if sid == "" { return httperror.BadRequest("session id required") } if err := h.svc.RevokeSession(r.Context(), user.ID, sid); err != nil { if errors.Is(err, domain.ErrSessionNotFound) { return httperror.NotFound("session not found") } return err } httpresponse.NoContent(w) return nil } // RevokeAllSessions revokes all sessions except the current one. // // DELETE /api/{service}/auth/sessions func (h *Auth) RevokeAllSessions(w http.ResponseWriter, r *http.Request) error { user, err := auth.GetUserOrError(r.Context()) if err != nil { return httperror.Unauthorized("not authenticated") } currentSID := sessionID(user) var except *string if currentSID != "" { except = ¤tSID } if err := h.svc.LogoutAll(r.Context(), user.ID, except); err != nil { return err } httpresponse.NoContent(w) return nil }