Add a complete authentication and authorization system to Silo with three pluggable backends (local bcrypt, LDAP/FreeIPA, OIDC/Keycloak), session management, API token support, and role-based access control. Authentication backends: - Local: bcrypt (cost 12) password verification against users table - LDAP: FreeIPA simple bind with group-to-role mapping - OIDC: Keycloak redirect flow with realm role mapping - Backends are tried in order; users upserted to DB on first login Session and token management: - PostgreSQL-backed sessions via alexedwards/scs + pgxstore - Opaque API tokens (silo_ prefix, SHA-256 hashed, shown once) - 24h session lifetime, HttpOnly/SameSite=Lax/Secure cookies Role-based access control (admin > editor > viewer): - RequireAuth middleware: Bearer token -> session -> redirect/401 - RequireRole middleware: per-route-group minimum role enforcement - CSRF protection via justinas/nosurf on web forms, API exempt - CORS locked to configured origins when auth enabled Route restructuring: - Public: /health, /ready, /login, /auth/oidc, /auth/callback - Web (auth + CSRF): /, /projects, /schemas, /settings - API read (viewer): GET /api/** - API write (editor): POST/PUT/PATCH/DELETE /api/** User context wiring: - created_by/updated_by columns on items, projects, relationships - All create/update handlers populate tracking fields from context - CSV and BOM import handlers pass authenticated username - Revision creation tracks user across all code paths Default admin account: - Configurable via auth.local.default_admin_username/password - Env var overrides: SILO_ADMIN_USERNAME, SILO_ADMIN_PASSWORD - Idempotent: created on first startup, skipped if exists CLI and FreeCAD plugin: - silo token create/list/revoke subcommands (HTTP API client) - FreeCAD SiloClient sends Bearer token on all requests - Token read from ApiToken preference or SILO_API_TOKEN env var Web UI: - Login page (Catppuccin Mocha themed, OIDC button conditional) - Settings page with account info and API token management - User display name, role badge, and logout button in header - One-time token display banner with copy-to-clipboard Database (migration 009): - users table with role, auth_source, oidc_subject, password_hash - api_tokens table with SHA-256 hash, prefix, expiry, scopes - sessions table (scs pgxstore schema) - audit_log table (schema ready for future use) - created_by/updated_by ALTER on items, relationships, projects New dependencies: scs/v2, scs/pgxstore, go-oidc/v3, go-ldap/v3, justinas/nosurf, golang.org/x/oauth2
170 lines
5.0 KiB
Go
170 lines
5.0 KiB
Go
// Package api provides HTTP handlers and middleware for the Silo API.
|
|
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
"github.com/justinas/nosurf"
|
|
"github.com/kindredsystems/silo/internal/auth"
|
|
"github.com/rs/zerolog"
|
|
)
|
|
|
|
// RequestLogger returns a middleware that logs HTTP requests using zerolog.
|
|
func RequestLogger(logger zerolog.Logger) func(next http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
|
|
|
defer func() {
|
|
logger.Info().
|
|
Str("method", r.Method).
|
|
Str("path", r.URL.Path).
|
|
Str("query", r.URL.RawQuery).
|
|
Int("status", ww.Status()).
|
|
Int("bytes", ww.BytesWritten()).
|
|
Dur("duration", time.Since(start)).
|
|
Str("remote", r.RemoteAddr).
|
|
Str("user_agent", r.UserAgent()).
|
|
Msg("request")
|
|
}()
|
|
|
|
next.ServeHTTP(ww, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Recoverer returns a middleware that recovers from panics and logs them.
|
|
func Recoverer(logger zerolog.Logger) func(next http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
if rec := recover(); rec != nil {
|
|
logger.Error().
|
|
Interface("panic", rec).
|
|
Str("method", r.Method).
|
|
Str("path", r.URL.Path).
|
|
Msg("panic recovered")
|
|
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
}
|
|
}()
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// RequireAuth extracts the user from a session cookie or API token and injects
|
|
// it into the request context. If auth is disabled, injects a synthetic dev user.
|
|
func (s *Server) RequireAuth(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Dev mode: inject synthetic admin user
|
|
if s.authConfig == nil || !s.authConfig.Enabled {
|
|
devUser := &auth.User{
|
|
ID: "00000000-0000-0000-0000-000000000000",
|
|
Username: "dev",
|
|
DisplayName: "Developer",
|
|
Email: "dev@localhost",
|
|
Role: auth.RoleAdmin,
|
|
AuthSource: "local",
|
|
}
|
|
ctx := auth.ContextWithUser(r.Context(), devUser)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
return
|
|
}
|
|
|
|
// 1. Check for API token (Authorization: Bearer silo_...)
|
|
if token := extractBearerToken(r); token != "" {
|
|
user, err := s.auth.ValidateToken(r.Context(), token)
|
|
if err != nil {
|
|
writeError(w, http.StatusUnauthorized, "invalid_token", "Invalid or expired API token")
|
|
return
|
|
}
|
|
ctx := auth.ContextWithUser(r.Context(), user)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
return
|
|
}
|
|
|
|
// 2. Check session
|
|
if s.sessions != nil {
|
|
userID := s.sessions.GetString(r.Context(), "user_id")
|
|
if userID != "" {
|
|
user, err := s.auth.GetUserByID(r.Context(), userID)
|
|
if err == nil && user != nil {
|
|
ctx := auth.ContextWithUser(r.Context(), user)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
return
|
|
}
|
|
// Invalid session — destroy it
|
|
_ = s.sessions.Destroy(r.Context())
|
|
}
|
|
}
|
|
|
|
// 3. Not authenticated
|
|
if isAPIRequest(r) {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
|
} else {
|
|
next := r.URL.Path
|
|
if r.URL.RawQuery != "" {
|
|
next += "?" + r.URL.RawQuery
|
|
}
|
|
http.Redirect(w, r, "/login?next="+next, http.StatusSeeOther)
|
|
}
|
|
})
|
|
}
|
|
|
|
// RequireRole returns middleware that rejects users below the given role.
|
|
// Role hierarchy: admin > editor > viewer.
|
|
func (s *Server) RequireRole(minimum string) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
user := auth.UserFromContext(r.Context())
|
|
if user == nil {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
|
return
|
|
}
|
|
if !auth.RoleSatisfies(user.Role, minimum) {
|
|
writeError(w, http.StatusForbidden, "forbidden",
|
|
"Insufficient permissions: requires "+minimum+" role")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// CSRFProtect wraps nosurf for browser-based form submissions.
|
|
// API routes (using Bearer token auth) are exempt.
|
|
func (s *Server) CSRFProtect(next http.Handler) http.Handler {
|
|
csrfHandler := nosurf.New(next)
|
|
csrfHandler.SetBaseCookie(http.Cookie{
|
|
HttpOnly: true,
|
|
Secure: s.authConfig != nil && s.authConfig.Enabled,
|
|
SameSite: http.SameSiteLaxMode,
|
|
Path: "/",
|
|
})
|
|
csrfHandler.ExemptGlob("/api/*")
|
|
csrfHandler.ExemptPath("/health")
|
|
csrfHandler.ExemptPath("/ready")
|
|
csrfHandler.SetFailureHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
writeError(w, http.StatusForbidden, "csrf_failed", "CSRF token validation failed")
|
|
}))
|
|
return csrfHandler
|
|
}
|
|
|
|
func extractBearerToken(r *http.Request) string {
|
|
h := r.Header.Get("Authorization")
|
|
if strings.HasPrefix(h, "Bearer ") {
|
|
return strings.TrimPrefix(h, "Bearer ")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func isAPIRequest(r *http.Request) bool {
|
|
return strings.HasPrefix(r.URL.Path, "/api/")
|
|
}
|