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

332 lines
9.2 KiB
Go

package handlers
import (
"errors"
"net"
"net/http"
"strings"
"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/pkg/logging"
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/service"
)
// Auth handles authentication HTTP requests.
type Auth struct {
svc *service.AuthService
logger *logging.Logger
}
// NewAuth creates a new Auth handler.
func NewAuth(svc *service.AuthService, logger *logging.Logger) *Auth {
return &Auth{
svc: svc,
logger: logger.WithComponent("AuthHandler"),
}
}
// --- Request / Response types ---
// LoginRequest is the request body for password login.
type LoginRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=1"`
}
// RegisterRequest is the request body for registration.
type RegisterRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=8"`
Name string `json:"name"`
}
// LoginResponse is the response for successful login or registration.
type LoginResponse struct {
Token string `json:"token"`
User UserResponse `json:"user"`
}
// UserResponse is the user data returned in auth responses.
type UserResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name,omitempty"`
AvatarURL string `json:"avatarUrl,omitempty"`
EmailVerified bool `json:"emailVerified"`
Roles []string `json:"roles,omitempty"`
}
// UpdateProfileRequest is the request body for updating the user profile.
type UpdateProfileRequest struct {
Name string `json:"name"`
AvatarURL string `json:"avatarUrl"`
}
// ChangePasswordRequest is the request body for changing password.
type ChangePasswordRequest struct {
CurrentPassword string `json:"currentPassword" validate:"required"`
NewPassword string `json:"newPassword" validate:"required,min=8"`
}
// RefreshRequest is the request body for refreshing an access token.
type RefreshRequest struct {
Token string `json:"token" validate:"required"`
}
// toUserResponse converts a domain.User to UserResponse.
func toUserResponse(u *domain.User) UserResponse {
return UserResponse{
ID: string(u.ID),
Email: u.Email,
Name: u.Name,
AvatarURL: u.AvatarURL,
EmailVerified: u.EmailVerified,
Roles: u.Roles,
}
}
// toLoginResponse creates a LoginResponse from service output.
func toLoginResponse(out *service.LoginOutput) LoginResponse {
return LoginResponse{
Token: out.Token,
User: toUserResponse(out.User),
}
}
// --- Handlers ---
// Login authenticates a user with email and password.
//
// POST /api/{service}/auth/login
func (h *Auth) Login(w http.ResponseWriter, r *http.Request) error {
var req LoginRequest
if err := app.BindAndValidate(r, &req); err != nil {
return err
}
output, err := h.svc.LoginWithPassword(r.Context(), req.Email, req.Password, clientIP(r), r.UserAgent())
if err != nil {
return mapAuthError(err)
}
httpresponse.OK(w, r, toLoginResponse(output))
return nil
}
// Register creates a new user account.
//
// POST /api/{service}/auth/register
func (h *Auth) Register(w http.ResponseWriter, r *http.Request) error {
var req RegisterRequest
if err := app.BindAndValidate(r, &req); err != nil {
return err
}
output, err := h.svc.Register(r.Context(), req.Email, req.Password, req.Name, clientIP(r), r.UserAgent())
if err != nil {
return mapAuthError(err)
}
httpresponse.Created(w, r, toLoginResponse(output))
return nil
}
// Me returns the current authenticated user.
//
// GET /api/{service}/auth/me
func (h *Auth) Me(w http.ResponseWriter, r *http.Request) error {
user, err := auth.GetUserOrError(r.Context())
if err != nil {
return httperror.Unauthorized("not authenticated")
}
freshUser, err := h.svc.GetCurrentUser(r.Context(), user.ID)
if err != nil {
return mapAuthError(err)
}
httpresponse.OK(w, r, toUserResponse(freshUser))
return nil
}
// UpdateMe updates the current user's profile.
//
// PUT /api/{service}/auth/me
func (h *Auth) UpdateMe(w http.ResponseWriter, r *http.Request) error {
user, err := auth.GetUserOrError(r.Context())
if err != nil {
return httperror.Unauthorized("not authenticated")
}
var req UpdateProfileRequest
if err := app.BindAndValidate(r, &req); err != nil {
return err
}
updated, err := h.svc.UpdateProfile(r.Context(), user.ID, req.Name, req.AvatarURL)
if err != nil {
return mapAuthError(err)
}
httpresponse.OK(w, r, toUserResponse(updated))
return nil
}
// ChangePassword changes the current user's password.
//
// POST /api/{service}/auth/change-password
func (h *Auth) ChangePassword(w http.ResponseWriter, r *http.Request) error {
user, err := auth.GetUserOrError(r.Context())
if err != nil {
return httperror.Unauthorized("not authenticated")
}
var req ChangePasswordRequest
if err := app.BindAndValidate(r, &req); err != nil {
return err
}
if err := h.svc.ChangePassword(r.Context(), user.ID, req.CurrentPassword, req.NewPassword); err != nil {
return mapAuthError(err)
}
httpresponse.NoContent(w)
return nil
}
// Logout revokes the current session.
//
// POST /api/{service}/auth/logout
func (h *Auth) Logout(w http.ResponseWriter, r *http.Request) error {
user := auth.GetUser(r.Context())
if user == nil {
httpresponse.NoContent(w)
return nil
}
sessionID := ""
if user.Metadata != nil {
if sid, ok := user.Metadata["sid"].(string); ok {
sessionID = sid
}
}
if err := h.svc.Logout(r.Context(), sessionID); err != nil {
h.logger.Warn("logout session revoke failed", "error", err)
}
httpresponse.NoContent(w)
return nil
}
// RefreshToken issues a new access token for an active session.
//
// POST /api/{service}/auth/refresh
func (h *Auth) RefreshToken(w http.ResponseWriter, r *http.Request) error {
// The caller sends their current (possibly near-expiry) token.
// We parse it to get user ID and session ID, then issue a new one.
user := auth.GetUser(r.Context())
if user == nil {
return httperror.Unauthorized("not authenticated")
}
sessionID := ""
if user.Metadata != nil {
if sid, ok := user.Metadata["sid"].(string); ok {
sessionID = sid
}
}
if sessionID == "" {
return httperror.Unauthorized("no session")
}
output, err := h.svc.RefreshToken(r.Context(), sessionID, user.ID)
if err != nil {
return mapAuthError(err)
}
httpresponse.OK(w, r, toLoginResponse(output))
return nil
}
// --- Helpers ---
// mapAuthError translates domain errors to HTTP errors.
func mapAuthError(err error) error {
switch {
case errors.Is(err, domain.ErrInvalidCredentials):
return httperror.Unauthorized("invalid email or password")
case errors.Is(err, domain.ErrUserNotFound):
return httperror.Unauthorized("invalid email or password")
case errors.Is(err, domain.ErrUserSuspended):
return httperror.Forbidden("account is suspended")
case errors.Is(err, domain.ErrDuplicateEmail):
return httperror.Conflict("email already registered")
case errors.Is(err, domain.ErrWeakPassword):
return httperror.BadRequest(err.Error())
case errors.Is(err, domain.ErrRegistrationDisabled):
return httperror.Forbidden("registration is currently disabled")
case errors.Is(err, domain.ErrNameTooLong), errors.Is(err, domain.ErrEmailTooLong):
return httperror.BadRequest(err.Error())
case errors.Is(err, domain.ErrInvalidAvatarURL):
return httperror.BadRequest("avatar URL must use http or https")
case errors.Is(err, domain.ErrSessionNotFound):
return httperror.NotFound("session not found")
case errors.Is(err, domain.ErrSessionRevoked):
return httperror.Unauthorized("session has been revoked")
case errors.Is(err, domain.ErrInvalidAuthCode):
return httperror.Unauthorized("invalid or expired code")
default:
return err
}
}
// clientIP extracts the client IP from the request.
// It prefers RemoteAddr (set by the Go HTTP server from the TCP connection) and
// only uses X-Forwarded-For/X-Real-Ip when the direct connection is from a
// private/loopback address, indicating a trusted reverse proxy.
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
// Only trust proxy headers when the connection is from a private network.
if isPrivateIP(host) {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.SplitN(xff, ",", 2)
ip := strings.TrimSpace(parts[0])
if ip != "" {
return ip
}
}
if xri := r.Header.Get("X-Real-Ip"); xri != "" {
return xri
}
}
return host
}
// isPrivateIP returns true if the address is loopback or RFC 1918 private.
func isPrivateIP(addr string) bool {
ip := net.ParseIP(addr)
if ip == nil {
return false
}
return ip.IsLoopback() || ip.IsPrivate()
}
// sessionID extracts the session ID from the authenticated user's metadata.
func sessionID(user *auth.User) string {
if user == nil || user.Metadata == nil {
return ""
}
sid, _ := user.Metadata["sid"].(string)
return sid
}