289 lines
7.9 KiB
Go
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 = ¤tSID
|
|
}
|
|
|
|
if err := h.svc.LogoutAll(r.Context(), user.ID, except); err != nil {
|
|
return err
|
|
}
|
|
|
|
httpresponse.NoContent(w)
|
|
return nil
|
|
}
|