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 }