332 lines
9.2 KiB
Go
332 lines
9.2 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"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/pkg/logging"
|
|
"git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/domain"
|
|
"git.threesix.ai/jordan/persona-community-1/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
|
|
}
|