persona-community-2/services/persona-api/internal/api/handlers/auth_flows.go
2026-02-23 10:54:06 +00:00

289 lines
7.9 KiB
Go

package handlers
import (
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"git.threesix.ai/jordan/persona-community-2/pkg/app"
"git.threesix.ai/jordan/persona-community-2/pkg/auth"
"git.threesix.ai/jordan/persona-community-2/pkg/httperror"
"git.threesix.ai/jordan/persona-community-2/pkg/httpresponse"
"git.threesix.ai/jordan/persona-community-2/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 = &currentSID
}
if err := h.svc.LogoutAll(r.Context(), user.ID, except); err != nil {
return err
}
httpresponse.NoContent(w)
return nil
}