Files
silo-server/internal/api/handlers/auth.go
T
QuickandClaude Opus 4.7 7e4cacd151 feat(auth): make usernames and emails case-insensitive
Login identifiers were compared case-sensitively, so "John" and "john"
were distinct accounts and a user could not log in unless they matched the
exact casing used at registration.

Convert users.username and users.email to the citext type (migration 165).
citext compares case-insensitively while preserving the originally stored
casing for display, so the existing unique constraints become
case-insensitive and `WHERE username = $1` / `email = $1` lookups match
regardless of case with no change to the query code itself.

Also add auth.NormalizeUsername/NormalizeEmail (trim-only; case preserved),
applied at the repository chokepoints (Create, Update, GetByUsername,
GetByEmail) and before validation in the create paths, so surrounding
whitespace no longer defeats matching or creates lookalike accounts.

Verified non-destructively against the dev DB: mixed-case lookups resolve
to the same row, case-variant inserts are rejected by the unique
constraint, and the down migration cleanly reverts to text.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:44:51 -04:00

586 lines
18 KiB
Go

package handlers
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/clientip"
"github.com/Silo-Server/silo-server/internal/models"
)
// AuthHandler handles authentication-related HTTP endpoints.
type AuthHandler struct {
service *auth.Service
jwt *auth.JWTService
device *auth.DeviceLoginService
}
// NewAuthHandler creates a new AuthHandler backed by the given auth, JWT,
// and device login services.
func NewAuthHandler(service *auth.Service, jwt *auth.JWTService, device *auth.DeviceLoginService) *AuthHandler {
return &AuthHandler{
service: service,
jwt: jwt,
device: device,
}
}
// --- Request/Response types ---
// loginRequest represents the JSON body of a POST /auth/login request.
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Provider string `json:"provider,omitempty"`
}
// loginResponse represents the JSON body of a successful login response.
type loginResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
User userResponse `json:"user"`
}
// setupRequest represents the JSON body of a POST /auth/setup request.
type setupRequest struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
CreateDefaultProfile bool `json:"create_default_profile"`
DefaultProfileName string `json:"default_profile_name,omitempty"`
}
// setupStatusResponse represents the JSON body of a GET /auth/setup request.
type setupStatusResponse struct {
NeedsSetup bool `json:"needs_setup"`
}
// signupRequest represents the JSON body of a POST /auth/signup request.
type signupRequest struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
InviteCode string `json:"invite_code"`
CreateDefaultProfile bool `json:"create_default_profile"`
DefaultProfileName string `json:"default_profile_name,omitempty"`
}
// signupStatusResponse represents the JSON body of a GET /auth/signup request.
type signupStatusResponse struct {
Enabled bool `json:"enabled"`
}
// refreshRequest represents the JSON body of a POST /auth/refresh request.
type refreshRequest struct {
RefreshToken string `json:"refresh_token"`
}
// refreshResponse represents the JSON body of a successful refresh response.
type refreshResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
}
type pluginLaunchResponse struct {
ExpiresIn int `json:"expires_in"`
}
type impersonationResponse struct {
Active bool `json:"active"`
ImpersonatorUserID int `json:"impersonator_user_id"`
ImpersonatorUsername string `json:"impersonator_username"`
}
// userResponse represents a user in JSON responses.
type userResponse struct {
ID int `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
Permissions []string `json:"permissions"`
DownloadAllowed bool `json:"download_allowed"`
Impersonation *impersonationResponse `json:"impersonation,omitempty"`
}
// sessionResponse represents a session in JSON responses.
type sessionResponse struct {
ID string `json:"id"`
DeviceName string `json:"device_name"`
IPAddress string `json:"ip_address"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
}
// sessionsListResponse represents the JSON body of a GET /auth/sessions response.
type sessionsListResponse struct {
Sessions []sessionResponse `json:"sessions"`
}
// errorResponse represents an error in JSON responses.
type errorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
}
type authProviderResponse struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
Mode string `json:"mode"`
Default bool `json:"default"`
IconURL string `json:"icon_url,omitempty"`
InstallationID int `json:"installation_id,omitempty"`
}
// --- Handler methods ---
// HandleLogin handles POST /auth/login.
func (h *AuthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if req.Username == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Username and password are required")
return
}
// Extract device name from User-Agent header and IP from request.
deviceName := r.UserAgent()
ip := clientip.FromContext(r.Context())
pair, user, err := h.service.LoginWithProvider(r.Context(), req.Provider, req.Username, req.Password, deviceName, ip)
if err != nil {
if errors.Is(err, auth.ErrInvalidCredentials) {
writeError(w, http.StatusUnauthorized, "invalid_credentials", "Invalid username or password")
return
}
if errors.Is(err, auth.ErrUserDisabled) {
writeError(w, http.StatusForbidden, "user_disabled", "User account is disabled")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "An unexpected error occurred")
return
}
writeJSON(w, http.StatusOK, buildLoginResponse(pair, user, nil))
}
func (h *AuthHandler) HandleProviders(w http.ResponseWriter, r *http.Request) {
providers := h.service.ListProviders()
response := make([]authProviderResponse, 0, len(providers))
for _, provider := range providers {
response = append(response, authProviderResponse{
ID: provider.ID,
DisplayName: provider.DisplayName,
Mode: provider.Mode,
Default: provider.Default,
IconURL: provider.IconURL,
InstallationID: provider.InstallationID,
})
}
writeJSON(w, http.StatusOK, response)
}
// HandleSetupStatus handles GET /auth/setup.
func (h *AuthHandler) HandleSetupStatus(w http.ResponseWriter, r *http.Request) {
needsSetup, err := h.service.NeedsSetup(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "An unexpected error occurred")
return
}
writeJSON(w, http.StatusOK, setupStatusResponse{
NeedsSetup: needsSetup,
})
}
// HandleSetup handles POST /auth/setup.
func (h *AuthHandler) HandleSetup(w http.ResponseWriter, r *http.Request) {
var req setupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
req.Username = auth.NormalizeUsername(req.Username)
req.Email = auth.NormalizeEmail(req.Email)
if req.Username == "" || req.Email == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Username, email, and password are required")
return
}
deviceName := r.UserAgent()
ip := clientip.FromContext(r.Context())
pair, user, err := h.service.SetupInitialUser(
r.Context(),
req.Username,
req.Email,
req.Password,
req.CreateDefaultProfile,
req.DefaultProfileName,
deviceName,
ip,
)
if err != nil {
if errors.Is(err, auth.ErrSetupAlreadyComplete) {
writeError(w, http.StatusUnauthorized, "setup_complete", "Initial setup has already been completed")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "An unexpected error occurred")
return
}
writeJSON(w, http.StatusCreated, buildLoginResponse(pair, user, nil))
}
// HandleLogout handles POST /auth/logout. Requires authentication.
func (h *AuthHandler) HandleLogout(w http.ResponseWriter, r *http.Request) {
claims, err := h.extractClaims(r)
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid or missing authentication token")
return
}
if err := h.service.Logout(r.Context(), claims.SessionID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "An unexpected error occurred")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleEndImpersonation handles POST /auth/impersonation/end. Requires authentication.
func (h *AuthHandler) HandleEndImpersonation(w http.ResponseWriter, r *http.Request) {
claims, err := h.extractClaims(r)
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid or missing authentication token")
return
}
if claims.ImpersonatorUserID == nil {
writeError(w, http.StatusBadRequest, "not_impersonating", "No active impersonation session")
return
}
if err := h.service.EndImpersonation(r.Context(), claims.SessionID, *claims.ImpersonatorUserID); err != nil {
if errors.Is(err, auth.ErrNotImpersonating) {
writeError(w, http.StatusBadRequest, "not_impersonating", "No active impersonation session")
return
}
if errors.Is(err, auth.ErrImpersonationNotAllowed) {
writeError(w, http.StatusForbidden, "impersonation_not_allowed", "Impersonation is not allowed")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "An unexpected error occurred")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleRefresh handles POST /auth/refresh.
func (h *AuthHandler) HandleRefresh(w http.ResponseWriter, r *http.Request) {
var req refreshRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if req.RefreshToken == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Refresh token is required")
return
}
pair, err := h.service.Refresh(r.Context(), req.RefreshToken)
if err != nil {
if errors.Is(err, auth.ErrSessionRevoked) {
writeError(w, http.StatusUnauthorized, "session_revoked", "Session has been revoked")
return
}
if errors.Is(err, auth.ErrInvalidToken) || errors.Is(err, auth.ErrExpiredToken) {
writeError(w, http.StatusUnauthorized, "invalid_token", "Invalid or expired refresh token")
return
}
writeError(w, http.StatusUnauthorized, "invalid_token", "Invalid or expired refresh token")
return
}
writeJSON(w, http.StatusOK, refreshResponse{
AccessToken: pair.AccessToken,
RefreshToken: pair.RefreshToken,
ExpiresIn: pair.ExpiresIn,
})
}
func (h *AuthHandler) HandlePluginLaunch(w http.ResponseWriter, r *http.Request) {
claims, err := h.extractClaims(r)
if err != nil || claims.SessionID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid or missing authentication token")
return
}
const ttl = 5 * time.Minute
token, err := h.jwt.GeneratePluginAccessToken(claims.UserID, claims.Role, claims.SessionID, ttl)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to prepare plugin access")
return
}
http.SetCookie(w, &http.Cookie{
Name: auth.PluginAccessCookieName,
Value: token,
Path: "/api/v1",
MaxAge: int(ttl.Seconds()),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: isSecureRequest(r),
})
writeJSON(w, http.StatusOK, pluginLaunchResponse{ExpiresIn: int(ttl.Seconds())})
}
// HandleMe handles GET /auth/me. Requires authentication.
func (h *AuthHandler) HandleMe(w http.ResponseWriter, r *http.Request) {
claims, err := h.extractClaims(r)
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid or missing authentication token")
return
}
user, err := h.service.GetCurrentUser(r.Context(), claims)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "An unexpected error occurred")
return
}
impersonator, err := h.loadImpersonator(r.Context(), claims)
if err != nil && !auth.IsNotFound(err) {
writeError(w, http.StatusInternalServerError, "internal_error", "An unexpected error occurred")
return
}
writeJSON(w, http.StatusOK, buildUserResponse(user, claims.ImpersonatorUserID, impersonator))
}
// HandleListSessions handles GET /auth/sessions. Requires authentication.
func (h *AuthHandler) HandleListSessions(w http.ResponseWriter, r *http.Request) {
claims, err := h.extractClaims(r)
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid or missing authentication token")
return
}
sessions, err := h.service.GetSessions(r.Context(), claims.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "An unexpected error occurred")
return
}
resp := sessionsListResponse{
Sessions: make([]sessionResponse, 0, len(sessions)),
}
for _, s := range sessions {
resp.Sessions = append(resp.Sessions, sessionResponse{
ID: s.ID,
DeviceName: s.DeviceName,
IPAddress: s.IPAddress,
CreatedAt: s.CreatedAt,
ExpiresAt: s.ExpiresAt,
RevokedAt: s.RevokedAt,
})
}
writeJSON(w, http.StatusOK, resp)
}
// HandleDeleteSession handles DELETE /auth/sessions/{id}. Requires authentication.
func (h *AuthHandler) HandleDeleteSession(w http.ResponseWriter, r *http.Request) {
claims, err := h.extractClaims(r)
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid or missing authentication token")
return
}
sessionID := chi.URLParam(r, "id")
if sessionID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Session ID is required")
return
}
err = h.service.RevokeSession(r.Context(), sessionID, claims.UserID)
if err != nil {
if auth.IsSessionNotFound(err) {
writeError(w, http.StatusNotFound, "not_found", "Session not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "An unexpected error occurred")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleSignupStatus handles GET /auth/signup.
func (h *AuthHandler) HandleSignupStatus(w http.ResponseWriter, r *http.Request) {
enabled, err := h.service.IsSignupEnabled(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "An unexpected error occurred")
return
}
writeJSON(w, http.StatusOK, signupStatusResponse{Enabled: enabled})
}
// HandleSignup handles POST /auth/signup.
func (h *AuthHandler) HandleSignup(w http.ResponseWriter, r *http.Request) {
var req signupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
req.Username = auth.NormalizeUsername(req.Username)
req.Email = auth.NormalizeEmail(req.Email)
if req.Username == "" || req.Email == "" || req.Password == "" || req.InviteCode == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Username, email, password, and invite code are required")
return
}
deviceName := r.UserAgent()
ip := clientip.FromContext(r.Context())
pair, user, err := h.service.Signup(
r.Context(),
req.Username,
req.Email,
req.Password,
req.InviteCode,
req.CreateDefaultProfile,
req.DefaultProfileName,
deviceName,
ip,
)
if err != nil {
if errors.Is(err, auth.ErrSignupDisabled) {
writeError(w, http.StatusForbidden, "signup_disabled", "Public signups are not currently enabled")
return
}
if errors.Is(err, auth.ErrInviteCodeNotFound) {
writeError(w, http.StatusBadRequest, "invalid_code", "Invalid invite code")
return
}
if errors.Is(err, auth.ErrInviteCodeExhausted) {
writeError(w, http.StatusBadRequest, "code_exhausted", "This invite code has reached its maximum uses")
return
}
if errors.Is(err, auth.ErrInviteCodeDisabled) {
writeError(w, http.StatusBadRequest, "code_disabled", "This invite code is no longer active")
return
}
if auth.IsDuplicate(err) {
writeError(w, http.StatusBadRequest, "duplicate", "Username or email already taken")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "An unexpected error occurred")
return
}
writeJSON(w, http.StatusCreated, buildLoginResponse(pair, user, nil))
}
// --- Helper functions ---
func buildLoginResponse(pair *auth.TokenPair, user *models.User, impersonator *models.User) loginResponse {
return loginResponse{
AccessToken: pair.AccessToken,
RefreshToken: pair.RefreshToken,
ExpiresIn: pair.ExpiresIn,
User: buildUserResponse(user, impersonatorUserID(impersonator), impersonator),
}
}
func buildUserResponse(user *models.User, impersonatorUserID *int, impersonator *models.User) userResponse {
resp := userResponse{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Role: user.Role,
Permissions: auth.EffectivePermissions(user),
DownloadAllowed: user.DownloadAllowed,
}
if impersonatorUserID != nil {
resp.Impersonation = &impersonationResponse{
Active: true,
ImpersonatorUserID: *impersonatorUserID,
}
if impersonator != nil {
resp.Impersonation.ImpersonatorUsername = impersonator.Username
}
}
return resp
}
func impersonatorUserID(impersonator *models.User) *int {
if impersonator == nil {
return nil
}
return &impersonator.ID
}
func isSecureRequest(r *http.Request) bool {
if r.TLS != nil {
return true
}
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
func (h *AuthHandler) loadImpersonator(ctx context.Context, claims *auth.Claims) (*models.User, error) {
if claims == nil || claims.ImpersonatorUserID == nil {
return nil, nil
}
return h.service.GetCurrentUser(ctx, &auth.Claims{UserID: *claims.ImpersonatorUserID})
}
// extractClaims extracts JWT claims from the Authorization header.
func (h *AuthHandler) extractClaims(r *http.Request) (*auth.Claims, error) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return nil, auth.ErrInvalidToken
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
return nil, auth.ErrInvalidToken
}
return h.jwt.ValidateToken(parts[1])
}
// writeJSON marshals the given value as JSON and writes it to the response.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// writeError writes a JSON error response with the given status code,
// error code, and message.
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, errorResponse{
Error: code,
Message: message,
})
}