Files
Alphaeus Mote e0b002975f feat(api-docs): require auth for docs and add filterable route discovery
The OpenAPI spec and Swagger UI were public. Put them behind the same
authentication as the rest of the API, and add a compact route list so a
client can ask "what can I call?" without opening dev tools.

Access:
- /api/openapi.json and /api/routes require a bearer token or API key.
- /api/docs additionally accepts a session cookie set at login, so a
  signed-in operator can open the docs in a new tab; an anonymous browser
  is redirected to /login?redirect=... and returned afterwards.
- The cookie is HttpOnly and path-scoped to /api/docs, so it is never sent
  to /api/v1/* and cannot authenticate an API call (no CSRF surface).
  Verified: cookie-only request to /api/v1/rules returns 401.

Discovery: both the spec and GET /api/routes accept ?method=get,post and
?path=<substring> (comma-separated, case-insensitive). The route list
returns method, path, summary, tag, public, and `allowed` — false when a
read-scoped API key cannot invoke that route. /api/docs passes the same
query through to the spec it loads.

UI: a </> icon in the header (both layouts) and an Administration → API
Docs menu entry, opened in a new tab via a new `external` nav-item flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 10:44:54 -04:00

216 lines
7.1 KiB
Go

// Package api - Authentication handlers
package api
import (
"net/http"
"time"
"github.com/Grace-Solutions/OrchestrAD/internal/audit"
"github.com/Grace-Solutions/OrchestrAD/internal/auth"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
)
// AuthHandler handles authentication endpoints
type AuthHandler struct {
authService *auth.Service
auditService *audit.Service
logger *logging.Logger
}
// NewAuthHandler creates a new AuthHandler
func NewAuthHandler(authService *auth.Service, auditService *audit.Service, logger *logging.Logger) *AuthHandler {
return &AuthHandler{
authService: authService,
auditService: auditService,
logger: logger,
}
}
// LoginRequest represents a login request
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// LoginResponse represents a login response
type LoginResponse struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expiresAt"`
User UserInfo `json:"user"`
}
// UserInfo represents user information in responses
type UserInfo struct {
ID string `json:"id"`
Username string `json:"username"`
Email *string `json:"email,omitempty"`
DisplayName *string `json:"displayName,omitempty"`
Roles []string `json:"roles"`
PasswordResetRequired bool `json:"passwordResetRequired"`
}
// ChangePasswordRequest represents a password change request
type ChangePasswordRequest struct {
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
}
// Login handles POST /api/v1/auth/login
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
if err := DecodeJSON(r, &req); err != nil {
WriteError(w, http.StatusBadRequest, ErrCodeBadRequest, "Invalid request body")
return
}
if req.Username == "" || req.Password == "" {
WriteError(w, http.StatusBadRequest, ErrCodeValidation, "Username and password are required")
return
}
result, err := h.authService.Login(req.Username, req.Password)
if err != nil {
h.logger.Warn("Auth", "Login failed for user '%s': %v", req.Username, err)
emitAudit(h.auditService, r, audit.EventLogin, "User", "", "Login", false,
map[string]any{"username": req.Username}, err.Error())
if err == auth.ErrInvalidCredentials || err == auth.ErrUserDisabled {
WriteError(w, http.StatusUnauthorized, ErrCodeUnauthorized, "Invalid credentials")
} else {
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Login failed")
}
return
}
h.logger.Info("Auth", "User '%s' logged in successfully", req.Username)
emitAudit(h.auditService, r, audit.EventLogin, "User", result.User.ID, "Login", true,
map[string]any{"username": result.User.Username}, "")
// Build role names
roles := make([]string, len(result.User.Roles))
for i, role := range result.User.Roles {
roles[i] = role.Name
}
// Let the signed-in browser open /api/docs directly (cookie is scoped to
// the docs path only; see docs_cookie.go).
SetDocsSessionCookie(w, r, result.SessionToken, result.ExpiresAt)
WriteJSON(w, http.StatusOK, LoginResponse{
Token: result.SessionToken,
ExpiresAt: result.ExpiresAt,
User: UserInfo{
ID: result.User.ID,
Username: result.User.Username,
Email: result.User.Email,
DisplayName: result.User.DisplayName,
Roles: roles,
PasswordResetRequired: result.User.PasswordResetRequired,
},
})
}
// Logout handles POST /api/v1/auth/logout
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
token := extractBearerToken(r)
if token == "" {
WriteError(w, http.StatusUnauthorized, ErrCodeUnauthorized, "No token provided")
return
}
if err := h.authService.Logout(token); err != nil {
h.logger.Error("Auth", "Logout failed: %v", err)
}
ClearDocsSessionCookie(w, r)
if user := GetUserFromContext(r.Context()); user != nil {
emitAudit(h.auditService, r, audit.EventLogout, "User", user.ID, "Logout", true, nil, "")
} else {
emitAudit(h.auditService, r, audit.EventLogout, "User", "", "Logout", true, nil, "")
}
WriteJSON(w, http.StatusOK, map[string]bool{"success": true})
}
// Me handles GET /api/v1/auth/me
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
user := GetUserFromContext(r.Context())
if user == nil {
WriteError(w, http.StatusUnauthorized, ErrCodeUnauthorized, "Not authenticated")
return
}
roles := make([]string, len(user.Roles))
for i, role := range user.Roles {
roles[i] = role.Name
}
WriteJSON(w, http.StatusOK, UserInfo{
ID: user.ID,
Username: user.Username,
Email: user.Email,
DisplayName: user.DisplayName,
Roles: roles,
PasswordResetRequired: user.PasswordResetRequired,
})
}
// ChangePassword handles POST /api/v1/auth/change-password
func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
user := GetUserFromContext(r.Context())
if user == nil {
WriteError(w, http.StatusUnauthorized, ErrCodeUnauthorized, "Not authenticated")
return
}
var req ChangePasswordRequest
if err := DecodeJSON(r, &req); err != nil {
WriteError(w, http.StatusBadRequest, ErrCodeBadRequest, "Invalid request body")
return
}
if req.CurrentPassword == "" || req.NewPassword == "" {
WriteError(w, http.StatusBadRequest, ErrCodeValidation, "Current and new password are required")
return
}
if err := h.authService.ChangePassword(user.ID, req.CurrentPassword, req.NewPassword); err != nil {
h.logger.Warn("Auth", "Password change failed for user '%s': %v", user.Username, err)
emitAudit(h.auditService, r, audit.EventPasswordChange, "User", user.ID, "ChangePassword", false,
map[string]any{"username": user.Username}, err.Error())
switch err {
case auth.ErrInvalidCredentials:
WriteError(w, http.StatusUnauthorized, ErrCodeUnauthorized, "Current password is incorrect")
case auth.ErrPasswordTooShort:
WriteError(w, http.StatusBadRequest, ErrCodeValidation, "New password is too short")
case auth.ErrPasswordUnchanged:
WriteError(w, http.StatusBadRequest, ErrCodeValidation, "New password must differ from the current password")
case auth.ErrUserNotFound:
WriteError(w, http.StatusNotFound, ErrCodeNotFound, "User not found")
default:
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Password change failed")
}
return
}
h.logger.Info("Auth", "User '%s' changed password", user.Username)
emitAudit(h.auditService, r, audit.EventPasswordChange, "User", user.ID, "ChangePassword", true,
map[string]any{"username": user.Username}, "")
WriteJSON(w, http.StatusOK, map[string]bool{"success": true})
}
// CSRF handles GET /api/v1/auth/csrf
func (h *AuthHandler) CSRF(w http.ResponseWriter, r *http.Request) {
// TODO: Generate and return CSRF token
WriteJSON(w, http.StatusOK, map[string]string{
"token": "csrf-token-placeholder",
})
}
func extractBearerToken(r *http.Request) string {
auth := r.Header.Get("Authorization")
if len(auth) > 7 && auth[:7] == "Bearer " {
return auth[7:]
}
return ""
}