e0b002975f
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>
216 lines
7.1 KiB
Go
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 ""
|
|
}
|