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
186 lines
6.0 KiB
Markdown
186 lines
6.0 KiB
Markdown
# 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:
|
|
|
|
1. **Auth disabled?** Inject synthetic dev user (`admin` role) and continue
|
|
2. **Bearer token?** Extract from `Authorization: Bearer silo_...` header, validate via `auth.Service.ValidateToken()`. On success, inject user into context
|
|
3. **Session cookie?** Read `user_id` from `scs` session, look up user via `auth.Service.GetUserByID()`. On success, inject user into context. On stale session (user not found), destroy session
|
|
4. **None of the above?**
|
|
- API requests (`/api/*`): Return `401 Unauthorized` JSON
|
|
- Web requests: Redirect to `/login?next=<current-path>`
|
|
|
|
## RequireRole
|
|
|
|
`internal/api/middleware.go`
|
|
|
|
Applied as per-group middleware on routes that require a minimum role:
|
|
|
|
```go
|
|
r.Use(server.RequireRole(auth.RoleEditor))
|
|
```
|
|
|
|
Checks `auth.RoleSatisfies(user.Role, minimum)` against the hierarchy `admin > editor > viewer`. Returns:
|
|
|
|
- `401 Unauthorized` if no user in context (should not happen after RequireAuth)
|
|
- `403 Forbidden` with 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 Forbidden` with `"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
|
|
```
|