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
6.0 KiB
Silo Auth Middleware
Middleware Chain
Every request passes through this middleware stack in order:
RequestID Assigns X-Request-ID header
|
RealIP Extracts client IP from X-Forwarded-For
|
RequestLogger Logs method, path, status, duration (zerolog)
|
Recoverer Catches panics, logs stack trace, returns 500
|
CORS Validates origin, sets Access-Control headers
|
SessionLoadAndSave Loads session from PostgreSQL, saves on response
|
[route group middleware applied per-group below]
Route Groups
Public (no auth)
GET /health
GET /ready
GET /login
POST /login
POST /logout
GET /auth/oidc
GET /auth/callback
No authentication or CSRF middleware. The login form includes a CSRF hidden field but nosurf is not enforced on these routes (they are outside the CSRF-protected group).
Web UI (auth + CSRF)
RequireAuth -> CSRFProtect -> Handler
GET / Items page
GET /projects Projects page
GET /schemas Schemas page
GET /settings Settings page (account info, tokens)
POST /settings/tokens Create API token (form)
POST /settings/tokens/{id}/revoke Revoke token (form)
Both RequireAuth and CSRFProtect are applied. Form submissions must include a csrf_token hidden field with the value from nosurf.Token(r).
API (auth, no CSRF)
RequireAuth -> [RequireRole where needed] -> Handler
GET /api/auth/me Current user info (viewer)
GET /api/auth/tokens List tokens (viewer)
POST /api/auth/tokens Create token (viewer)
DELETE /api/auth/tokens/{id} Revoke token (viewer)
GET /api/schemas/* Read schemas (viewer)
POST /api/schemas/*/segments/* Modify schema values (editor)
GET /api/projects/* Read projects (viewer)
POST /api/projects Create project (editor)
PUT /api/projects/{code} Update project (editor)
DELETE /api/projects/{code} Delete project (editor)
GET /api/items/* Read items (viewer)
POST /api/items Create item (editor)
PUT /api/items/{partNumber} Update item (editor)
DELETE /api/items/{partNumber} Delete item (editor)
POST /api/items/{partNumber}/file Upload file (editor)
POST /api/items/import CSV import (editor)
GET /api/items/{partNumber}/bom/* Read BOM (viewer)
POST /api/items/{partNumber}/bom Add BOM entry (editor)
PUT /api/items/{partNumber}/bom/* Update BOM entry (editor)
DELETE /api/items/{partNumber}/bom/* Delete BOM entry (editor)
POST /api/generate-part-number Generate PN (editor)
GET /api/integrations/odoo/* Read Odoo config (viewer)
PUT /api/integrations/odoo/* Modify Odoo config (editor)
POST /api/integrations/odoo/* Odoo sync operations (editor)
API routes are exempt from CSRF (they use Bearer token auth). CORS credentials are allowed so browser-based API clients with session cookies work.
RequireAuth
internal/api/middleware.go
Authentication check order:
- Auth disabled? Inject synthetic dev user (
adminrole) and continue - Bearer token? Extract from
Authorization: Bearer silo_...header, validate viaauth.Service.ValidateToken(). On success, inject user into context - Session cookie? Read
user_idfromscssession, look up user viaauth.Service.GetUserByID(). On success, inject user into context. On stale session (user not found), destroy session - None of the above?
- API requests (
/api/*): Return401 UnauthorizedJSON - Web requests: Redirect to
/login?next=<current-path>
- API requests (
RequireRole
internal/api/middleware.go
Applied as per-group middleware on routes that require a minimum role:
r.Use(server.RequireRole(auth.RoleEditor))
Checks auth.RoleSatisfies(user.Role, minimum) against the hierarchy admin > editor > viewer. Returns:
401 Unauthorizedif no user in context (should not happen after RequireAuth)403 Forbiddenwith message"Insufficient permissions: requires <role> role"if role is too low
CSRFProtect
internal/api/middleware.go
Wraps the justinas/nosurf library:
- Cookie:
csrf_token, HttpOnly, SameSite=Lax, Secure when auth enabled - Exempt paths:
/api/*,/health,/ready - Form field name:
csrf_token - Failure: Returns
403 Forbiddenwith"CSRF token validation failed"
Templates inject the token via {{.CSRFToken}} which is populated from nosurf.Token(r).
CORS Configuration
Configured in internal/api/routes.go:
| Setting | Auth Disabled | Auth Enabled |
|---|---|---|
| Allowed Origins | * |
From auth.cors.allowed_origins config |
| Allow Credentials | false |
true (needed for session cookies) |
| Allowed Methods | GET, POST, PUT, PATCH, DELETE, OPTIONS | Same |
| Allowed Headers | Accept, Authorization, Content-Type, X-CSRF-Token, X-Request-ID | Same |
| Max Age | 300 seconds | 300 seconds |
FreeCAD uses direct HTTP (not browser), so CORS does not affect it. Browser-based tools on other origins need their origin in the allowed list.
Request Flow Examples
Browser Login
GET /projects
-> RequestLogger
-> CORS (pass)
-> Session (loads empty session)
-> RequireAuth: no token, no session user_id
-> Redirect 303 /login?next=/projects
POST /login (username=alice, password=...)
-> RequestLogger
-> CORS (pass)
-> Session (loads)
-> HandleLogin: auth.Authenticate(ctx, "alice", password)
-> Session: put user_id, renew token
-> Redirect 303 /projects
GET /projects
-> Session (loads, has user_id)
-> RequireAuth: session -> GetUserByID -> inject alice
-> CSRFProtect: GET request, passes
-> HandleProjectsPage: renders with alice's info
API Token Request
GET /api/items
Authorization: Bearer silo_a1b2c3d4...
-> RequireAuth: Bearer token found
-> ValidateToken: SHA-256 hash lookup, check expiry, check user active
-> Inject user into context
-> HandleListItems: returns JSON