feat(auth): add authentication, RBAC, API tokens, and default admin

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
This commit is contained in:
Forbes
2026-01-31 11:20:12 -06:00
parent a5fba7bf99
commit 4f0107f1b2
32 changed files with 3538 additions and 138 deletions

View File

@@ -2,9 +2,12 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"github.com/kindredsystems/silo/internal/config"
@@ -35,6 +38,8 @@ func main() {
cmdRevisions(ctx)
case "schemas":
cmdSchemas(ctx)
case "token":
cmdToken(ctx)
default:
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", cmd)
printUsage()
@@ -53,12 +58,25 @@ Commands:
show Show item details
revisions Show item revision history
schemas List available schemas
token Manage API tokens (create, list, revoke)
Token subcommands:
silo token create --name "label" Create a new API token
silo token list List your API tokens
silo token revoke <id> Revoke a token
Environment variables for API access:
SILO_API_URL Base URL of the Silo server (e.g., https://silo.kindred.internal)
SILO_API_TOKEN API token for authentication
Examples:
silo register --schema kindred-rd --project PROTO --type AS
silo list --type assembly
silo show PROTO-AS-0001
silo revisions PROTO-AS-0001`)
silo revisions PROTO-AS-0001
silo token create --name "FreeCAD workstation"
silo token list
silo token revoke 550e8400-e29b-41d4-a716-446655440000`)
}
func loadConfig() *config.Config {
@@ -295,6 +313,163 @@ func getSchemaID(ctx context.Context, database *db.DB, name string) *string {
return &id
}
// apiClient returns the base URL and an *http.Client with the Bearer token set.
func apiRequest(method, path string, body any) (*http.Response, error) {
baseURL := os.Getenv("SILO_API_URL")
if baseURL == "" {
fmt.Fprintln(os.Stderr, "SILO_API_URL environment variable is required for token commands")
os.Exit(1)
}
token := os.Getenv("SILO_API_TOKEN")
if token == "" {
fmt.Fprintln(os.Stderr, "SILO_API_TOKEN environment variable is required for token commands")
os.Exit(1)
}
var reqBody io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshaling request body: %w", err)
}
reqBody = bytes.NewReader(b)
}
req, err := http.NewRequest(method, baseURL+path, reqBody)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
return http.DefaultClient.Do(req)
}
func cmdToken(_ context.Context) {
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "Usage: silo token <create|list|revoke> [options]")
os.Exit(1)
}
subcmd := os.Args[2]
switch subcmd {
case "create":
cmdTokenCreate()
case "list":
cmdTokenList()
case "revoke":
cmdTokenRevoke()
default:
fmt.Fprintf(os.Stderr, "Unknown token subcommand: %s\n", subcmd)
fmt.Fprintln(os.Stderr, "Usage: silo token <create|list|revoke> [options]")
os.Exit(1)
}
}
func cmdTokenCreate() {
var name string
args := os.Args[3:]
for i := 0; i < len(args); i++ {
switch args[i] {
case "--name", "-n":
if i+1 < len(args) {
i++
name = args[i]
}
}
}
if name == "" {
fmt.Fprintln(os.Stderr, "Usage: silo token create --name \"label\"")
os.Exit(1)
}
resp, err := apiRequest("POST", "/api/auth/tokens", map[string]any{"name": name})
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
fmt.Fprintf(os.Stderr, "Error creating token (%d): %s\n", resp.StatusCode, string(body))
os.Exit(1)
}
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
fmt.Fprintf(os.Stderr, "Error decoding response: %v\n", err)
os.Exit(1)
}
fmt.Printf("Token created: %s\n", result["name"])
fmt.Printf("API Token: %s\n", result["token"])
fmt.Println("Save this token — it will not be shown again.")
}
func cmdTokenList() {
resp, err := apiRequest("GET", "/api/auth/tokens", nil)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
fmt.Fprintf(os.Stderr, "Error listing tokens (%d): %s\n", resp.StatusCode, string(body))
os.Exit(1)
}
var tokens []map[string]any
if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil {
fmt.Fprintf(os.Stderr, "Error decoding response: %v\n", err)
os.Exit(1)
}
if len(tokens) == 0 {
fmt.Println("No API tokens.")
return
}
fmt.Printf("%-36s %-20s %-15s %s\n", "ID", "NAME", "PREFIX", "CREATED")
for _, t := range tokens {
id, _ := t["id"].(string)
name, _ := t["name"].(string)
prefix, _ := t["token_prefix"].(string)
created, _ := t["created_at"].(string)
if len(created) > 10 {
created = created[:10]
}
fmt.Printf("%-36s %-20s %-15s %s\n", id, name, prefix+"...", created)
}
}
func cmdTokenRevoke() {
if len(os.Args) < 4 {
fmt.Fprintln(os.Stderr, "Usage: silo token revoke <token-id>")
os.Exit(1)
}
tokenID := os.Args[3]
resp, err := apiRequest("DELETE", "/api/auth/tokens/"+tokenID, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
body, _ := io.ReadAll(resp.Body)
fmt.Fprintf(os.Stderr, "Error revoking token (%d): %s\n", resp.StatusCode, string(body))
os.Exit(1)
}
fmt.Println("Token revoked.")
}
func mapPartType(code string) string {
types := map[string]string{
"AS": "assembly",

View File

@@ -11,7 +11,10 @@ import (
"syscall"
"time"
"github.com/alexedwards/scs/pgxstore"
"github.com/alexedwards/scs/v2"
"github.com/kindredsystems/silo/internal/api"
"github.com/kindredsystems/silo/internal/auth"
"github.com/kindredsystems/silo/internal/config"
"github.com/kindredsystems/silo/internal/db"
"github.com/kindredsystems/silo/internal/schema"
@@ -85,8 +88,99 @@ func main() {
}
logger.Info().Int("count", len(schemas)).Msg("loaded schemas")
// Initialize authentication
userRepo := db.NewUserRepository(database)
tokenRepo := db.NewTokenRepository(database)
// Session manager (PostgreSQL-backed via scs + pgxstore)
sessionManager := scs.New()
sessionManager.Store = pgxstore.New(database.Pool())
sessionManager.Lifetime = 24 * time.Hour
sessionManager.Cookie.Name = "silo_session"
sessionManager.Cookie.HttpOnly = true
sessionManager.Cookie.Secure = cfg.Auth.Enabled // Secure cookies when auth is active
sessionManager.Cookie.SameSite = http.SameSiteLaxMode
// Build auth backends from config
var backends []auth.Backend
if cfg.Auth.Local.Enabled {
backends = append(backends, auth.NewLocalBackend(userRepo))
logger.Info().Msg("auth backend: local")
}
if cfg.Auth.LDAP.Enabled {
backends = append(backends, auth.NewLDAPBackend(auth.LDAPConfig{
URL: cfg.Auth.LDAP.URL,
BaseDN: cfg.Auth.LDAP.BaseDN,
UserSearchDN: cfg.Auth.LDAP.UserSearchDN,
BindDN: cfg.Auth.LDAP.BindDN,
BindPassword: cfg.Auth.LDAP.BindPassword,
UserAttr: cfg.Auth.LDAP.UserAttr,
EmailAttr: cfg.Auth.LDAP.EmailAttr,
DisplayAttr: cfg.Auth.LDAP.DisplayAttr,
GroupAttr: cfg.Auth.LDAP.GroupAttr,
RoleMapping: cfg.Auth.LDAP.RoleMapping,
TLSSkipVerify: cfg.Auth.LDAP.TLSSkipVerify,
}))
logger.Info().Str("url", cfg.Auth.LDAP.URL).Msg("auth backend: ldap")
}
authService := auth.NewService(logger, userRepo, tokenRepo, backends...)
// OIDC backend (separate from the Backend interface since it uses redirect flow)
var oidcBackend *auth.OIDCBackend
if cfg.Auth.OIDC.Enabled {
oidcBackend, err = auth.NewOIDCBackend(ctx, auth.OIDCConfig{
IssuerURL: cfg.Auth.OIDC.IssuerURL,
ClientID: cfg.Auth.OIDC.ClientID,
ClientSecret: cfg.Auth.OIDC.ClientSecret,
RedirectURL: cfg.Auth.OIDC.RedirectURL,
Scopes: cfg.Auth.OIDC.Scopes,
AdminRole: cfg.Auth.OIDC.AdminRole,
EditorRole: cfg.Auth.OIDC.EditorRole,
DefaultRole: cfg.Auth.OIDC.DefaultRole,
})
if err != nil {
logger.Fatal().Err(err).Msg("failed to initialize OIDC backend")
}
logger.Info().Str("issuer", cfg.Auth.OIDC.IssuerURL).Msg("auth backend: oidc")
}
if cfg.Auth.Enabled {
logger.Info().Msg("authentication enabled")
} else {
logger.Warn().Msg("authentication disabled - all routes are open")
}
// Seed default admin account (idempotent — skips if user already exists)
if u := cfg.Auth.Local.DefaultAdminUsername; u != "" {
if p := cfg.Auth.Local.DefaultAdminPassword; p != "" {
existing, err := userRepo.GetByUsername(ctx, u)
if err != nil {
logger.Error().Err(err).Msg("failed to check for default admin user")
} else if existing != nil {
logger.Debug().Str("username", u).Msg("default admin user already exists, skipping")
} else {
hash, err := auth.HashPassword(p)
if err != nil {
logger.Fatal().Err(err).Msg("failed to hash default admin password")
}
adminUser := &db.User{
Username: u,
DisplayName: "Administrator",
Role: auth.RoleAdmin,
AuthSource: "local",
}
if err := userRepo.Create(ctx, adminUser, hash); err != nil {
logger.Fatal().Err(err).Msg("failed to create default admin user")
}
logger.Info().Str("username", u).Msg("default admin user created")
}
}
}
// Create API server
server := api.NewServer(logger, database, schemas, cfg.Schemas.Directory, store)
server := api.NewServer(logger, database, schemas, cfg.Schemas.Directory, store,
authService, sessionManager, oidcBackend, &cfg.Auth)
router := api.NewRouter(server, logger)
// Create HTTP server

View File

@@ -35,8 +35,58 @@ freecad:
# Path to FreeCAD executable (for CLI operations)
executable: "/usr/bin/freecad"
# Future: LDAP authentication
# auth:
# provider: "ldap"
# server: "ldaps://ipa.kindred.internal"
# base_dn: "dc=kindred,dc=internal"
# Authentication
# Set enabled: true to require login. When false, all routes are open
# with a synthetic "dev" user (admin role).
auth:
enabled: false
session_secret: "" # Use SILO_SESSION_SECRET env var in production
# Local accounts (username/password stored in Silo database)
local:
enabled: true
# Default admin account created on first startup (if username and password are set)
default_admin_username: "admin" # Use SILO_ADMIN_USERNAME env var
default_admin_password: "" # Use SILO_ADMIN_PASSWORD env var
# LDAP / FreeIPA
ldap:
enabled: false
url: "ldaps://ipa.kindred.internal"
base_dn: "dc=kindred,dc=internal"
user_search_dn: "cn=users,cn=accounts,dc=kindred,dc=internal"
# Optional service account for user search (omit for direct user bind)
# bind_dn: "uid=silo-service,cn=users,cn=accounts,dc=kindred,dc=internal"
# bind_password: "" # Use SILO_LDAP_BIND_PASSWORD env var
user_attr: "uid"
email_attr: "mail"
display_attr: "displayName"
group_attr: "memberOf"
# Map LDAP groups to Silo roles (checked in order: admin, editor, viewer)
role_mapping:
admin:
- "cn=silo-admins,cn=groups,cn=accounts,dc=kindred,dc=internal"
editor:
- "cn=silo-users,cn=groups,cn=accounts,dc=kindred,dc=internal"
- "cn=engineers,cn=groups,cn=accounts,dc=kindred,dc=internal"
viewer:
- "cn=silo-viewers,cn=groups,cn=accounts,dc=kindred,dc=internal"
tls_skip_verify: false
# OIDC / Keycloak
oidc:
enabled: false
issuer_url: "https://keycloak.kindred.internal/realms/silo"
client_id: "silo"
client_secret: "" # Use SILO_OIDC_CLIENT_SECRET env var
redirect_url: "https://silo.kindred.internal/auth/callback"
scopes: ["openid", "profile", "email"]
# Map Keycloak realm roles to Silo roles
admin_role: "silo-admin"
editor_role: "silo-editor"
default_role: "viewer" # Fallback if no role claim matches
# CORS origins (locked down when auth is enabled)
cors:
allowed_origins:
- "https://silo.kindred.internal"

View File

@@ -35,3 +35,48 @@ schemas:
freecad:
uri_scheme: "silo"
executable: "/usr/bin/freecad"
# Authentication
# Set via SILO_SESSION_SECRET, SILO_OIDC_CLIENT_SECRET, SILO_LDAP_BIND_PASSWORD env vars
auth:
enabled: true
session_secret: "" # Set via SILO_SESSION_SECRET
local:
enabled: true
default_admin_username: "admin"
default_admin_password: "" # Set via SILO_ADMIN_PASSWORD
ldap:
enabled: true
url: "ldaps://ipa.kindred.internal"
base_dn: "dc=kindred,dc=internal"
user_search_dn: "cn=users,cn=accounts,dc=kindred,dc=internal"
user_attr: "uid"
email_attr: "mail"
display_attr: "displayName"
group_attr: "memberOf"
role_mapping:
admin:
- "cn=silo-admins,cn=groups,cn=accounts,dc=kindred,dc=internal"
editor:
- "cn=silo-users,cn=groups,cn=accounts,dc=kindred,dc=internal"
- "cn=engineers,cn=groups,cn=accounts,dc=kindred,dc=internal"
viewer:
- "cn=silo-viewers,cn=groups,cn=accounts,dc=kindred,dc=internal"
tls_skip_verify: false
oidc:
enabled: false
issuer_url: "https://keycloak.kindred.internal/realms/silo"
client_id: "silo"
client_secret: "" # Set via SILO_OIDC_CLIENT_SECRET
redirect_url: "https://silo.kindred.internal/auth/callback"
scopes: ["openid", "profile", "email"]
admin_role: "silo-admin"
editor_role: "silo-editor"
default_role: "viewer"
cors:
allowed_origins:
- "https://silo.kindred.internal"

View File

@@ -60,6 +60,11 @@ services:
SILO_MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-silominiosecret}
SILO_MINIO_BUCKET: silo-files
SILO_MINIO_USE_SSL: "false"
SILO_SESSION_SECRET: ${SILO_SESSION_SECRET:-change-me-in-production}
SILO_OIDC_CLIENT_SECRET: ${SILO_OIDC_CLIENT_SECRET:-}
SILO_LDAP_BIND_PASSWORD: ${SILO_LDAP_BIND_PASSWORD:-}
SILO_ADMIN_USERNAME: ${SILO_ADMIN_USERNAME:-admin}
SILO_ADMIN_PASSWORD: ${SILO_ADMIN_PASSWORD:-}
ports:
- "8080:8080"
volumes:

226
docs/AUTH.md Normal file
View File

@@ -0,0 +1,226 @@
# Silo Authentication Architecture
## Overview
Silo supports three authentication backends that can be enabled independently or in combination:
| Backend | Use Case | Config Key |
|---------|----------|------------|
| **Local** | Username/password stored in Silo's database (bcrypt) | `auth.local` |
| **LDAP** | FreeIPA / Active Directory via LDAP bind | `auth.ldap` |
| **OIDC** | Keycloak or any OpenID Connect provider (redirect flow) | `auth.oidc` |
When authentication is disabled (`auth.enabled: false`), all routes are open and a synthetic developer user with the `admin` role is injected into every request.
## Authentication Flow
### Browser (Session-Based)
```
User -> /login (GET) -> Renders login form
User -> /login (POST) -> Validates credentials via backends
-> Creates server-side session (PostgreSQL)
-> Sets silo_session cookie
-> Redirects to / or ?next= URL
User -> /auth/oidc (GET) -> Generates state, stores in session
-> Redirects to Keycloak authorize endpoint
Keycloak -> /auth/callback -> Verifies state, exchanges code for token
-> Extracts claims, upserts user in DB
-> Creates session, redirects to /
```
### API Client (Token-Based)
```
Client -> Authorization: Bearer silo_<hex> -> SHA-256 hash lookup
-> Validates expiry + user active
-> Injects user into context
```
Both paths converge at the `RequireAuth` middleware, which injects an `auth.User` into the request context. All downstream handlers use `auth.UserFromContext(ctx)` to access the authenticated user.
## Database Schema
### users
The single identity table that all backends resolve to. LDAP and OIDC users are upserted on first login.
| Column | Type | Notes |
|--------|------|-------|
| `id` | UUID | Primary key |
| `username` | TEXT | Unique. FreeIPA `uid` or OIDC `preferred_username` |
| `display_name` | TEXT | Human-readable name |
| `email` | TEXT | Not unique-constrained |
| `password_hash` | TEXT | NULL for LDAP/OIDC-only users (bcrypt cost 12) |
| `auth_source` | TEXT | `local`, `ldap`, or `oidc` |
| `oidc_subject` | TEXT | Stable OIDC `sub` claim (unique partial index) |
| `role` | TEXT | `admin`, `editor`, or `viewer` |
| `is_active` | BOOLEAN | Deactivated users are rejected at middleware |
| `last_login_at` | TIMESTAMPTZ | Updated on each successful login |
| `created_at` | TIMESTAMPTZ | |
| `updated_at` | TIMESTAMPTZ | |
### api_tokens
| Column | Type | Notes |
|--------|------|-------|
| `id` | UUID | Primary key |
| `user_id` | UUID | FK to users, CASCADE on delete |
| `name` | TEXT | Human-readable label |
| `token_hash` | TEXT | SHA-256 of raw token (unique) |
| `token_prefix` | TEXT | `silo_` + 8 hex chars for display |
| `scopes` | TEXT[] | Reserved for future fine-grained permissions |
| `last_used_at` | TIMESTAMPTZ | Updated asynchronously on use |
| `expires_at` | TIMESTAMPTZ | NULL = never expires |
| `created_at` | TIMESTAMPTZ | |
Raw token format: `silo_` + 64 hex characters (32 random bytes). Shown once at creation. Only the SHA-256 hash is stored.
### sessions
Required by `alexedwards/scs` pgxstore:
| Column | Type | Notes |
|--------|------|-------|
| `token` | TEXT | Primary key (session ID) |
| `data` | BYTEA | Serialized session data |
| `expiry` | TIMESTAMPTZ | Indexed for cleanup |
Session data contains `user_id` and `username`. Cookie: `silo_session`, HttpOnly, SameSite=Lax, 24h lifetime. `Secure` flag is set when `auth.enabled` is true.
### audit_log
| Column | Type | Notes |
|--------|------|-------|
| `id` | UUID | Primary key |
| `timestamp` | TIMESTAMPTZ | Indexed DESC |
| `user_id` | UUID | FK to users, SET NULL on delete |
| `username` | TEXT | Preserved after user deletion |
| `action` | TEXT | `create`, `update`, `delete`, `login`, etc. |
| `resource_type` | TEXT | `item`, `revision`, `project`, `relationship` |
| `resource_id` | TEXT | |
| `details` | JSONB | Arbitrary structured data |
| `ip_address` | TEXT | |
### User Tracking Columns
Migration 009 adds `created_by` and `updated_by` TEXT columns to:
- `items` (created_by, updated_by)
- `relationships` (created_by, updated_by)
- `projects` (created_by)
- `sync_log` (triggered_by)
These store the `username` string (not a foreign key) so audit records survive user deletion and dev mode uses `"dev"`.
## Role Model
Three roles with a strict hierarchy:
```
admin > editor > viewer
```
| Permission | viewer | editor | admin |
|-----------|--------|--------|-------|
| Read items, projects, schemas, BOMs | Yes | Yes | Yes |
| Create/update items and revisions | No | Yes | Yes |
| Upload files | No | Yes | Yes |
| Manage BOMs | No | Yes | Yes |
| Create/update projects | No | Yes | Yes |
| Import CSV / BOM CSV | No | Yes | Yes |
| Generate part numbers | No | Yes | Yes |
| Manage own API tokens | Yes | Yes | Yes |
| User management (future) | No | No | Yes |
Role enforcement uses `auth.RoleSatisfies(userRole, minimumRole)` which checks the hierarchy. A user with `admin` satisfies any minimum role.
### Role Mapping from External Sources
**LDAP/FreeIPA**: Mapped from group membership. Checked in priority order (admin > editor > viewer). First match wins.
```yaml
ldap:
role_mapping:
admin:
- "cn=silo-admins,cn=groups,cn=accounts,dc=kindred,dc=internal"
editor:
- "cn=silo-users,cn=groups,cn=accounts,dc=kindred,dc=internal"
- "cn=engineers,cn=groups,cn=accounts,dc=kindred,dc=internal"
viewer:
- "cn=silo-viewers,cn=groups,cn=accounts,dc=kindred,dc=internal"
```
**OIDC/Keycloak**: Mapped from `realm_access.roles` in the ID token.
```yaml
oidc:
admin_role: "silo-admin"
editor_role: "silo-editor"
default_role: "viewer"
```
Roles are re-evaluated on every external login. If an LDAP user's group membership changes in FreeIPA, their Silo role updates on their next login.
## User Lifecycle
| Event | What Happens |
|-------|-------------|
| First LDAP login | User row created with `auth_source=ldap`, role from group mapping |
| First OIDC login | User row created with `auth_source=oidc`, `oidc_subject` set |
| Subsequent external login | `display_name`, `email`, `role` updated; `last_login_at` updated |
| Local account creation | Admin creates user with username, password, role |
| Deactivation | `is_active=false` — sessions and tokens rejected at middleware |
| Password change | Only for `auth_source=local` users |
## Default Admin Account
On startup, if `auth.local.default_admin_username` and `auth.local.default_admin_password` are both set, the daemon checks for an existing user with that username. If none exists, it creates a local admin account. This is idempotent — subsequent startups skip creation.
```yaml
auth:
local:
enabled: true
default_admin_username: "admin"
default_admin_password: "" # Use SILO_ADMIN_PASSWORD env var
```
Set via environment variables for production:
```sh
export SILO_ADMIN_USERNAME=admin
export SILO_ADMIN_PASSWORD=<strong-password>
```
## API Token Security
- Raw token: `silo_` + 64 hex characters (32 bytes of `crypto/rand`)
- Storage: SHA-256 hash only — the raw token cannot be recovered from the database
- Display prefix: `silo_` + first 8 hex characters (for identification in UI)
- Tokens inherit the owning user's role. If the user is deactivated, all their tokens stop working
- Revocation is immediate (row deletion)
- `last_used_at` is updated asynchronously to avoid slowing down API requests
## Dev Mode
When `auth.enabled: false`:
- No login is required
- A synthetic user is injected into every request:
- Username: `dev`
- Role: `admin`
- Auth source: `local`
- `created_by` fields are set to `"dev"`
- CORS allows all origins
- Session cookies are not marked `Secure`
## Security Considerations
- **Password hashing**: bcrypt with cost factor 12
- **Token entropy**: 256 bits (32 bytes from `crypto/rand`)
- **LDAP**: Always use `ldaps://` (TLS). `tls_skip_verify` is available but should only be used for testing
- **OIDC state parameter**: 128 bits, stored server-side in session, verified on callback
- **Session cookies**: HttpOnly, SameSite=Lax, Secure when auth enabled
- **CSRF protection**: nosurf library on all web form routes. API routes exempt (use Bearer tokens instead)
- **CORS**: Locked down to configured origins when auth is enabled. Credentials allowed for session cookies

185
docs/AUTH_MIDDLEWARE.md Normal file
View File

@@ -0,0 +1,185 @@
# 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
```

257
docs/AUTH_USER_GUIDE.md Normal file
View File

@@ -0,0 +1,257 @@
# Silo Authentication User Guide
## Logging In
### Username and Password
Navigate to the Silo web UI. If authentication is enabled, you'll be redirected to the login page.
Enter your username and password. This works for both local accounts and LDAP/FreeIPA accounts — Silo tries local authentication first, then LDAP if configured.
### Keycloak / OIDC
If your deployment has OIDC enabled, the login page will show a "Sign in with Keycloak" button. Click it to be redirected to your identity provider. After authenticating there, you'll be redirected back to Silo with a session.
## Roles
Your role determines what you can do in Silo:
| Role | Permissions |
|------|-------------|
| **viewer** | Read all items, projects, schemas, BOMs. Manage own API tokens. |
| **editor** | Everything a viewer can do, plus: create/update/delete items, upload files, manage BOMs, import CSV, generate part numbers. |
| **admin** | Everything an editor can do, plus: user management (future), configuration changes. |
Your role is shown as a badge next to your name in the header. For LDAP and OIDC users, the role is determined by group membership or token claims and re-evaluated on each login.
## API Tokens
API tokens allow the FreeCAD plugin, scripts, and CI pipelines to authenticate with Silo without a browser session. Tokens inherit your role.
### Creating a Token (Web UI)
1. Click **Settings** in the navigation bar
2. Under **API Tokens**, enter a name (e.g., "FreeCAD workstation") and click **Create Token**
3. The raw token is displayed once — copy it immediately
4. Store the token securely. It cannot be shown again.
### Creating a Token (CLI)
```sh
export SILO_API_URL=https://silo.kindred.internal
export SILO_API_TOKEN=silo_<your-existing-token>
silo token create --name "CI pipeline"
```
Output:
```
Token created: CI pipeline
API Token: silo_a1b2c3d4e5f6...
Save this token — it will not be shown again.
```
### Listing Tokens
```sh
silo token list
```
### Revoking a Token
Via the web UI settings page (click **Revoke** next to the token), or via CLI:
```sh
silo token revoke <token-id>
```
Revocation is immediate. Any in-flight requests using the token will fail.
## FreeCAD Plugin Configuration
The FreeCAD plugin reads the API token from two sources (checked in order):
1. **FreeCAD Preferences**: `Tools > Edit parameters > BaseApp/Preferences/Mod/Silo > ApiToken`
2. **Environment variable**: `SILO_API_TOKEN`
To set the token in FreeCAD preferences:
1. Open FreeCAD
2. Go to `Edit > Preferences > General > Macro` or use the parameter editor
3. Navigate to `BaseApp/Preferences/Mod/Silo`
4. Set `ApiToken` to your token string (e.g., `silo_a1b2c3d4...`)
Or set the environment variable before launching FreeCAD:
```sh
export SILO_API_TOKEN=silo_a1b2c3d4...
freecad
```
The API URL is configured the same way via the `ApiUrl` preference or `SILO_API_URL` environment variable.
## Default Admin Account
On first deployment, configure a default admin account to bootstrap access:
```yaml
# config.yaml
auth:
enabled: true
local:
enabled: true
default_admin_username: "admin"
default_admin_password: "" # Set via SILO_ADMIN_PASSWORD env var
```
```sh
export SILO_ADMIN_PASSWORD=<strong-password>
```
The admin account is created on the first startup if it doesn't already exist. Subsequent startups skip creation. Change the password after first login via the database or a future admin UI.
## Configuration Reference
### Minimal (Local Auth Only)
```yaml
auth:
enabled: true
session_secret: "" # Set via SILO_SESSION_SECRET
local:
enabled: true
default_admin_username: "admin"
default_admin_password: "" # Set via SILO_ADMIN_PASSWORD
```
### LDAP / FreeIPA
```yaml
auth:
enabled: true
session_secret: ""
local:
enabled: true
default_admin_username: "admin"
default_admin_password: ""
ldap:
enabled: true
url: "ldaps://ipa.kindred.internal"
base_dn: "dc=kindred,dc=internal"
user_search_dn: "cn=users,cn=accounts,dc=kindred,dc=internal"
user_attr: "uid"
email_attr: "mail"
display_attr: "displayName"
group_attr: "memberOf"
role_mapping:
admin:
- "cn=silo-admins,cn=groups,cn=accounts,dc=kindred,dc=internal"
editor:
- "cn=silo-users,cn=groups,cn=accounts,dc=kindred,dc=internal"
- "cn=engineers,cn=groups,cn=accounts,dc=kindred,dc=internal"
viewer:
- "cn=silo-viewers,cn=groups,cn=accounts,dc=kindred,dc=internal"
tls_skip_verify: false
```
### OIDC / Keycloak
```yaml
auth:
enabled: true
session_secret: ""
local:
enabled: true
oidc:
enabled: true
issuer_url: "https://keycloak.kindred.internal/realms/silo"
client_id: "silo"
client_secret: "" # Set via SILO_OIDC_CLIENT_SECRET
redirect_url: "https://silo.kindred.internal/auth/callback"
scopes: ["openid", "profile", "email"]
admin_role: "silo-admin"
editor_role: "silo-editor"
default_role: "viewer"
```
### CORS (Production)
```yaml
auth:
cors:
allowed_origins:
- "https://silo.kindred.internal"
```
## Environment Variables
| Variable | Description | Config Path |
|----------|-------------|-------------|
| `SILO_SESSION_SECRET` | Session encryption key | `auth.session_secret` |
| `SILO_ADMIN_USERNAME` | Default admin username | `auth.local.default_admin_username` |
| `SILO_ADMIN_PASSWORD` | Default admin password | `auth.local.default_admin_password` |
| `SILO_OIDC_CLIENT_SECRET` | OIDC client secret | `auth.oidc.client_secret` |
| `SILO_LDAP_BIND_PASSWORD` | LDAP service account password | `auth.ldap.bind_password` |
| `SILO_API_URL` | API base URL (CLI and FreeCAD) | — |
| `SILO_API_TOKEN` | API token (CLI and FreeCAD) | — |
Environment variables override config file values.
## Troubleshooting
### "Authentication required" on every request
- Verify `auth.enabled: true` in your config
- Check that the `sessions` table exists in PostgreSQL (migration 009)
- Ensure `SILO_SESSION_SECRET` is set (empty string is allowed for dev but not recommended)
- Check browser cookies — `silo_session` should be present after login
### API token returns 401
- Tokens are case-sensitive. Ensure no trailing whitespace
- Check token expiry with `silo token list`
- Verify the user account is still active
- Ensure the `Authorization` header format is exactly `Bearer silo_<hex>`
### LDAP login fails
- Check `ldaps://` URL is reachable from the Silo server
- Verify `base_dn` and `user_search_dn` match your FreeIPA tree
- Test with `ldapsearch` from the command line first
- Set `tls_skip_verify: true` temporarily to rule out certificate issues
- Check Silo logs for the specific LDAP error message
### OIDC redirect loops
- Verify `redirect_url` matches the Keycloak client configuration exactly
- Check that `issuer_url` is reachable from the Silo server
- Ensure the Keycloak client has the correct redirect URI registered
- Check for clock skew between Silo and Keycloak servers (JWT validation is time-sensitive)
### Locked out (no admin account)
If you've lost access to all admin accounts:
1. Set `auth.local.default_admin_password` to a new password via `SILO_ADMIN_PASSWORD`
2. Use a different username (e.g., `default_admin_username: "recovery-admin"`)
3. Restart Silo — the new account will be created
4. Log in and fix the original accounts
Or directly update the database:
```sql
-- Reset a local user's password (generate bcrypt hash externally)
UPDATE users SET password_hash = '<bcrypt-hash>', is_active = true WHERE username = 'admin';
```
### FreeCAD plugin gets 401
- Verify the token is set in FreeCAD preferences or `SILO_API_TOKEN`
- Check the API URL points to the correct server
- Test with curl: `curl -H "Authorization: Bearer silo_..." https://silo.kindred.internal/api/items`

29
go.mod
View File

@@ -1,26 +1,37 @@
module github.com/kindredsystems/silo
go 1.23
go 1.24.0
require (
github.com/alexedwards/scs/pgxstore v0.0.0-20251002162104-209de6e426de
github.com/alexedwards/scs/v2 v2.9.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/go-chi/chi/v5 v5.0.12
github.com/go-chi/cors v1.2.1
github.com/jackc/pgx/v5 v5.5.0
github.com/go-ldap/ldap/v3 v3.4.12
github.com/jackc/pgx/v5 v5.5.4
github.com/justinas/nosurf v1.2.0
github.com/minio/minio-go/v7 v7.0.66
github.com/rs/zerolog v1.32.0
github.com/sahilm/fuzzy v0.1.1
golang.org/x/crypto v0.47.0
golang.org/x/oauth2 v0.34.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.5.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.4 // indirect
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
@@ -29,12 +40,10 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/rs/xid v1.5.0 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
golang.org/x/crypto v0.16.0 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
)

108
go.sum
View File

@@ -1,3 +1,13 @@
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/alexedwards/scs/pgxstore v0.0.0-20251002162104-209de6e426de h1:wNJVpr0ag/BL2nRGBIESdLe1qoljXIolF/qPi1gleRA=
github.com/alexedwards/scs/pgxstore v0.0.0-20251002162104-209de6e426de/go.mod h1:hwveArYcjyOK66EViVgVU5Iqj7zyEsWjKXMQhDJrTLI=
github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -5,33 +15,61 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.0 h1:NxstgwndsTRy7eq9/kqYc/BZh5w2hHJV86wjvO+1xPw=
github.com/jackc/pgx/v5 v5.5.0/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
github.com/jackc/pgx/v5 v5.5.4 h1:Xp2aQS8uXButQdnCMWNmvx6UysWQQC+u1EoizjguY+8=
github.com/jackc/pgx/v5 v5.5.4/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/justinas/nosurf v1.2.0 h1:yMs1bSRrNiwXk4AS6n8vL2Ssgpb9CB25T/4xrixaK0s=
github.com/justinas/nosurf v1.2.0/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@@ -51,6 +89,7 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
@@ -62,28 +101,73 @@ github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,373 @@
package api
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/justinas/nosurf"
"github.com/kindredsystems/silo/internal/auth"
)
// loginPageData holds template data for the login page.
type loginPageData struct {
Error string
Username string
Next string
CSRFToken string
OIDCEnabled bool
}
// HandleLoginPage renders the login page.
func (s *Server) HandleLoginPage(w http.ResponseWriter, r *http.Request) {
// If auth is disabled, redirect to home
if s.authConfig == nil || !s.authConfig.Enabled {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
// If already logged in, redirect to home
if s.sessions != nil {
userID := s.sessions.GetString(r.Context(), "user_id")
if userID != "" {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
}
s.renderLogin(w, r, "")
}
// HandleLogin processes the login form submission.
func (s *Server) HandleLogin(w http.ResponseWriter, r *http.Request) {
if s.authConfig == nil || !s.authConfig.Enabled {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
if username == "" || password == "" {
s.renderLogin(w, r, "Username and password are required")
return
}
user, err := s.auth.Authenticate(r.Context(), username, password)
if err != nil {
s.logger.Warn().Str("username", username).Err(err).Msg("login failed")
s.renderLogin(w, r, "Invalid username or password")
return
}
// Create session
if err := s.sessions.RenewToken(r.Context()); err != nil {
s.logger.Error().Err(err).Msg("failed to renew session token")
s.renderLogin(w, r, "Internal error, please try again")
return
}
s.sessions.Put(r.Context(), "user_id", user.ID)
s.sessions.Put(r.Context(), "username", user.Username)
s.logger.Info().Str("username", username).Str("source", user.AuthSource).Msg("user logged in")
// Redirect to original destination or home
next := r.URL.Query().Get("next")
if next == "" || !strings.HasPrefix(next, "/") {
next = "/"
}
http.Redirect(w, r, next, http.StatusSeeOther)
}
// HandleLogout destroys the session and redirects to login.
func (s *Server) HandleLogout(w http.ResponseWriter, r *http.Request) {
if s.sessions != nil {
_ = s.sessions.Destroy(r.Context())
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// HandleOIDCLogin initiates the OIDC redirect to Keycloak.
func (s *Server) HandleOIDCLogin(w http.ResponseWriter, r *http.Request) {
if s.oidc == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
state, err := generateRandomState()
if err != nil {
s.logger.Error().Err(err).Msg("failed to generate OIDC state")
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
s.sessions.Put(r.Context(), "oidc_state", state)
http.Redirect(w, r, s.oidc.AuthCodeURL(state), http.StatusSeeOther)
}
// HandleOIDCCallback processes the OIDC redirect from Keycloak.
func (s *Server) HandleOIDCCallback(w http.ResponseWriter, r *http.Request) {
if s.oidc == nil {
http.Error(w, "OIDC not configured", http.StatusNotFound)
return
}
// Verify state
expectedState := s.sessions.GetString(r.Context(), "oidc_state")
actualState := r.URL.Query().Get("state")
if expectedState == "" || actualState != expectedState {
s.logger.Warn().Msg("OIDC state mismatch")
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
s.sessions.Remove(r.Context(), "oidc_state")
// Check for error from IdP
if errParam := r.URL.Query().Get("error"); errParam != "" {
desc := r.URL.Query().Get("error_description")
s.logger.Warn().Str("error", errParam).Str("description", desc).Msg("OIDC error from IdP")
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// Exchange code for token
code := r.URL.Query().Get("code")
user, err := s.oidc.Exchange(r.Context(), code)
if err != nil {
s.logger.Error().Err(err).Msg("OIDC exchange failed")
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// Upsert user into DB
if err := s.auth.UpsertOIDCUser(r.Context(), user); err != nil {
s.logger.Error().Err(err).Msg("failed to upsert OIDC user")
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// Create session
if err := s.sessions.RenewToken(r.Context()); err != nil {
s.logger.Error().Err(err).Msg("failed to renew session token")
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
s.sessions.Put(r.Context(), "user_id", user.ID)
s.sessions.Put(r.Context(), "username", user.Username)
s.logger.Info().Str("username", user.Username).Msg("OIDC user logged in")
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// HandleGetCurrentUser returns the authenticated user as JSON.
func (s *Server) HandleGetCurrentUser(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
if user == nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "Not authenticated")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"id": user.ID,
"username": user.Username,
"display_name": user.DisplayName,
"email": user.Email,
"role": user.Role,
"auth_source": user.AuthSource,
})
}
// createTokenRequest is the request body for token creation.
type createTokenRequest struct {
Name string `json:"name"`
ExpiresInDays *int `json:"expires_in_days,omitempty"`
}
// HandleCreateToken creates a new API token (JSON API).
func (s *Server) HandleCreateToken(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
if user == nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "Not authenticated")
return
}
var req createTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid JSON body")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "Token name is required")
return
}
var expiresAt *time.Time
if req.ExpiresInDays != nil && *req.ExpiresInDays > 0 {
t := time.Now().AddDate(0, 0, *req.ExpiresInDays)
expiresAt = &t
}
rawToken, info, err := s.auth.GenerateToken(r.Context(), user.ID, req.Name, nil, expiresAt)
if err != nil {
s.logger.Error().Err(err).Msg("failed to generate token")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create token")
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"token": rawToken,
"id": info.ID,
"name": info.Name,
"token_prefix": info.TokenPrefix,
"expires_at": info.ExpiresAt,
"created_at": info.CreatedAt,
})
}
// HandleListTokens lists all tokens for the current user.
func (s *Server) HandleListTokens(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
if user == nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "Not authenticated")
return
}
tokens, err := s.auth.ListTokens(r.Context(), user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list tokens")
return
}
type tokenResponse struct {
ID string `json:"id"`
Name string `json:"name"`
TokenPrefix string `json:"token_prefix"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
result := make([]tokenResponse, 0, len(tokens))
for _, t := range tokens {
result = append(result, tokenResponse{
ID: t.ID,
Name: t.Name,
TokenPrefix: t.TokenPrefix,
LastUsedAt: t.LastUsedAt,
ExpiresAt: t.ExpiresAt,
CreatedAt: t.CreatedAt,
})
}
writeJSON(w, http.StatusOK, result)
}
// HandleRevokeToken deletes an API token (JSON API).
func (s *Server) HandleRevokeToken(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
if user == nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "Not authenticated")
return
}
tokenID := chi.URLParam(r, "id")
if err := s.auth.RevokeToken(r.Context(), user.ID, tokenID); err != nil {
writeError(w, http.StatusNotFound, "not_found", "Token not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleSettingsPage renders the settings page.
func (s *Server) HandleSettingsPage(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
tokens, _ := s.auth.ListTokens(r.Context(), user.ID)
// Retrieve one-time new token from session (if just created)
var newToken string
if s.sessions != nil {
newToken = s.sessions.PopString(r.Context(), "new_token")
}
data := PageData{
Title: "Settings",
Page: "settings",
User: user,
CSRFToken: nosurf.Token(r),
Data: map[string]any{
"tokens": tokens,
"new_token": newToken,
},
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.webHandler.templates.ExecuteTemplate(w, "base.html", data); err != nil {
s.logger.Error().Err(err).Msg("failed to render settings page")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
// HandleCreateTokenWeb creates a token via the web form.
func (s *Server) HandleCreateTokenWeb(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
name = "Unnamed token"
}
var expiresAt *time.Time
if daysStr := r.FormValue("expires_in_days"); daysStr != "" {
if d, err := strconv.Atoi(daysStr); err == nil && d > 0 {
t := time.Now().AddDate(0, 0, d)
expiresAt = &t
}
}
rawToken, _, err := s.auth.GenerateToken(r.Context(), user.ID, name, nil, expiresAt)
if err != nil {
s.logger.Error().Err(err).Msg("failed to generate token")
http.Redirect(w, r, "/settings", http.StatusSeeOther)
return
}
// Store the raw token in session for one-time display
s.sessions.Put(r.Context(), "new_token", rawToken)
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
// HandleRevokeTokenWeb revokes a token via the web form.
func (s *Server) HandleRevokeTokenWeb(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
tokenID := chi.URLParam(r, "id")
_ = s.auth.RevokeToken(r.Context(), user.ID, tokenID)
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
func (s *Server) renderLogin(w http.ResponseWriter, r *http.Request, errMsg string) {
data := loginPageData{
Error: errMsg,
Username: r.FormValue("username"),
Next: r.URL.Query().Get("next"),
CSRFToken: nosurf.Token(r),
OIDCEnabled: s.oidc != nil,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.webHandler.templates.ExecuteTemplate(w, "login.html", data); err != nil {
s.logger.Error().Err(err).Msg("failed to render login page")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
func generateRandomState() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}

View File

@@ -10,6 +10,7 @@ import (
"strings"
"github.com/go-chi/chi/v5"
"github.com/kindredsystems/silo/internal/auth"
"github.com/kindredsystems/silo/internal/db"
)
@@ -240,6 +241,9 @@ func (s *Server) HandleAddBOMEntry(w http.ResponseWriter, r *http.Request) {
ChildRevision: req.ChildRevision,
Metadata: req.Metadata,
}
if user := auth.UserFromContext(ctx); user != nil {
rel.CreatedBy = &user.Username
}
if err := s.relationships.Create(ctx, rel); err != nil {
if strings.Contains(err.Error(), "cycle") {
@@ -333,7 +337,11 @@ func (s *Server) HandleUpdateBOMEntry(w http.ResponseWriter, r *http.Request) {
}
}
if err := s.relationships.Update(ctx, rel.ID, req.RelType, req.Quantity, req.Unit, req.ReferenceDesignators, req.ChildRevision, req.Metadata); err != nil {
var bomUpdatedBy *string
if user := auth.UserFromContext(ctx); user != nil {
bomUpdatedBy = &user.Username
}
if err := s.relationships.Update(ctx, rel.ID, req.RelType, req.Quantity, req.Unit, req.ReferenceDesignators, req.ChildRevision, req.Metadata, bomUpdatedBy); err != nil {
s.logger.Error().Err(err).Msg("failed to update relationship")
writeError(w, http.StatusInternalServerError, "update_failed", err.Error())
return
@@ -776,9 +784,14 @@ func (s *Server) HandleImportBOMCSV(w http.ResponseWriter, r *http.Request) {
continue
}
var importUsername *string
if user := auth.UserFromContext(ctx); user != nil {
importUsername = &user.Username
}
if existing != nil {
// Update existing
if err := s.relationships.Update(ctx, existing.ID, nil, quantity, nil, nil, nil, metadata); err != nil {
if err := s.relationships.Update(ctx, existing.ID, nil, quantity, nil, nil, nil, metadata, importUsername); err != nil {
result.ErrorCount++
result.Errors = append(result.Errors, CSVImportErr{
Row: rowNum,
@@ -794,6 +807,7 @@ func (s *Server) HandleImportBOMCSV(w http.ResponseWriter, r *http.Request) {
RelType: "component",
Quantity: quantity,
Metadata: metadata,
CreatedBy: importUsername,
}
if err := s.relationships.Create(ctx, rel); err != nil {
result.ErrorCount++

View File

@@ -11,6 +11,7 @@ import (
"strings"
"time"
"github.com/kindredsystems/silo/internal/auth"
"github.com/kindredsystems/silo/internal/db"
"github.com/kindredsystems/silo/internal/partnum"
)
@@ -355,6 +356,9 @@ func (s *Server) HandleImportCSV(w http.ResponseWriter, r *http.Request) {
ItemType: itemType,
Description: description,
}
if user := auth.UserFromContext(ctx); user != nil {
item.CreatedBy = &user.Username
}
if err := s.items.Create(ctx, item, properties); err != nil {
result.Errors = append(result.Errors, CSVImportErr{

View File

@@ -10,7 +10,10 @@ import (
"strconv"
"strings"
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
"github.com/kindredsystems/silo/internal/auth"
"github.com/kindredsystems/silo/internal/config"
"github.com/kindredsystems/silo/internal/db"
"github.com/kindredsystems/silo/internal/partnum"
"github.com/kindredsystems/silo/internal/schema"
@@ -30,6 +33,11 @@ type Server struct {
schemasDir string
partgen *partnum.Generator
storage *storage.Storage
auth *auth.Service
sessions *scs.SessionManager
oidc *auth.OIDCBackend
authConfig *config.AuthConfig
webHandler *WebHandler
}
// NewServer creates a new API server.
@@ -39,6 +47,10 @@ func NewServer(
schemas map[string]*schema.Schema,
schemasDir string,
store *storage.Storage,
authService *auth.Service,
sessionManager *scs.SessionManager,
oidcBackend *auth.OIDCBackend,
authCfg *config.AuthConfig,
) *Server {
items := db.NewItemRepository(database)
projects := db.NewProjectRepository(database)
@@ -56,6 +68,10 @@ func NewServer(
schemasDir: schemasDir,
partgen: partgen,
storage: store,
auth: authService,
sessions: sessionManager,
oidc: oidcBackend,
authConfig: authCfg,
}
}
@@ -358,6 +374,9 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) {
ItemType: itemType,
Description: req.Description,
}
if user := auth.UserFromContext(ctx); user != nil {
item.CreatedBy = &user.Username
}
properties := req.Properties
if properties == nil {
@@ -465,7 +484,11 @@ func (s *Server) HandleUpdateItem(w http.ResponseWriter, r *http.Request) {
}
// Update the item record (UUID stays the same)
if err := s.items.Update(ctx, item.ID, newPartNumber, newItemType, newDescription); err != nil {
var updatedBy *string
if user := auth.UserFromContext(ctx); user != nil {
updatedBy = &user.Username
}
if err := s.items.Update(ctx, item.ID, newPartNumber, newItemType, newDescription, updatedBy); err != nil {
s.logger.Error().Err(err).Msg("failed to update item")
writeError(w, http.StatusInternalServerError, "update_failed", err.Error())
return
@@ -478,6 +501,9 @@ func (s *Server) HandleUpdateItem(w http.ResponseWriter, r *http.Request) {
Properties: req.Properties,
Comment: &req.Comment,
}
if user := auth.UserFromContext(ctx); user != nil {
rev.CreatedBy = &user.Username
}
if err := s.items.CreateRevision(ctx, rev); err != nil {
s.logger.Error().Err(err).Msg("failed to create revision")
@@ -782,7 +808,11 @@ func (s *Server) HandleRollbackRevision(w http.ResponseWriter, r *http.Request)
comment = fmt.Sprintf("Rollback to revision %d", revNum)
}
newRev, err := s.items.CreateRevisionFromExisting(ctx, item.ID, revNum, comment, nil)
var createdBy *string
if user := auth.UserFromContext(ctx); user != nil {
createdBy = &user.Username
}
newRev, err := s.items.CreateRevisionFromExisting(ctx, item.ID, revNum, comment, createdBy)
if err != nil {
s.logger.Error().Err(err).Msg("failed to create rollback revision")
writeError(w, http.StatusBadRequest, "rollback_failed", err.Error())
@@ -1102,6 +1132,9 @@ func (s *Server) HandleCreateRevision(w http.ResponseWriter, r *http.Request) {
Properties: req.Properties,
Comment: &req.Comment,
}
if user := auth.UserFromContext(ctx); user != nil {
rev.CreatedBy = &user.Username
}
if err := s.items.CreateRevision(ctx, rev); err != nil {
s.logger.Error().Err(err).Msg("failed to create revision")
@@ -1192,6 +1225,9 @@ func (s *Server) HandleUploadFile(w http.ResponseWriter, r *http.Request) {
FileSize: &result.Size,
Comment: &comment,
}
if user := auth.UserFromContext(ctx); user != nil {
rev.CreatedBy = &user.Username
}
if err := s.items.CreateRevision(ctx, rev); err != nil {
s.logger.Error().Err(err).Msg("failed to create revision")
@@ -1387,6 +1423,9 @@ func (s *Server) HandleCreateProject(w http.ResponseWriter, r *http.Request) {
Name: req.Name,
Description: req.Description,
}
if user := auth.UserFromContext(ctx); user != nil {
project.CreatedBy = &user.Username
}
if err := s.projects.Create(ctx, project); err != nil {
s.logger.Error().Err(err).Msg("failed to create project")

View File

@@ -3,9 +3,12 @@ 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"
)
@@ -54,3 +57,113 @@ func Recoverer(logger zerolog.Logger) func(next http.Handler) http.Handler {
})
}
}
// 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/")
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/kindredsystems/silo/internal/auth"
"github.com/rs/zerolog"
)
@@ -13,120 +14,171 @@ import (
func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
r := chi.NewRouter()
// Middleware stack
// Base middleware stack
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(RequestLogger(logger))
r.Use(Recoverer(logger))
// CORS: configurable origins, locked down when auth is enabled
corsOrigins := []string{"*"}
corsCredentials := false
if server.authConfig != nil && server.authConfig.Enabled {
if len(server.authConfig.CORS.AllowedOrigins) > 0 {
corsOrigins = server.authConfig.CORS.AllowedOrigins
}
corsCredentials = true
}
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Request-ID"},
AllowedOrigins: corsOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "X-Request-ID"},
ExposedHeaders: []string{"Link", "X-Request-ID"},
AllowCredentials: false,
AllowCredentials: corsCredentials,
MaxAge: 300,
}))
// Session middleware (must come before auth middleware)
if server.sessions != nil {
r.Use(server.sessions.LoadAndSave)
}
// Web handler for HTML pages
webHandler, err := NewWebHandler(logger)
webHandler, err := NewWebHandler(logger, server)
if err != nil {
logger.Fatal().Err(err).Msg("failed to create web handler")
}
// Health endpoints
// Health endpoints (no auth)
r.Get("/health", server.HandleHealth)
r.Get("/ready", server.HandleReady)
// Web UI routes
// Auth endpoints (no auth required)
r.Get("/login", server.HandleLoginPage)
r.Post("/login", server.HandleLogin)
r.Post("/logout", server.HandleLogout)
r.Get("/auth/oidc", server.HandleOIDCLogin)
r.Get("/auth/callback", server.HandleOIDCCallback)
// Web UI routes (require auth + CSRF)
r.Group(func(r chi.Router) {
r.Use(server.RequireAuth)
r.Use(server.CSRFProtect)
r.Get("/", webHandler.HandleIndex)
r.Get("/projects", webHandler.HandleProjectsPage)
r.Get("/schemas", webHandler.HandleSchemasPage)
r.Get("/settings", server.HandleSettingsPage)
r.Post("/settings/tokens", server.HandleCreateTokenWeb)
r.Post("/settings/tokens/{id}/revoke", server.HandleRevokeTokenWeb)
})
// API routes
// API routes (require auth, no CSRF — token auth instead)
r.Route("/api", func(r chi.Router) {
// Schemas
r.Use(server.RequireAuth)
// Auth endpoints
r.Get("/auth/me", server.HandleGetCurrentUser)
r.Route("/auth/tokens", func(r chi.Router) {
r.Get("/", server.HandleListTokens)
r.Post("/", server.HandleCreateToken)
r.Delete("/{id}", server.HandleRevokeToken)
})
// Schemas (read: viewer, write: editor)
r.Route("/schemas", func(r chi.Router) {
r.Get("/", server.HandleListSchemas)
r.Get("/{name}", server.HandleGetSchema)
r.Get("/{name}/properties", server.HandleGetPropertySchema)
// Schema segment value management
r.Group(func(r chi.Router) {
r.Use(server.RequireRole(auth.RoleEditor))
r.Route("/{name}/segments/{segment}/values", func(r chi.Router) {
r.Post("/", server.HandleAddSchemaValue)
r.Put("/{code}", server.HandleUpdateSchemaValue)
r.Delete("/{code}", server.HandleDeleteSchemaValue)
})
})
// Projects
r.Route("/projects", func(r chi.Router) {
r.Get("/", server.HandleListProjects)
r.Post("/", server.HandleCreateProject)
r.Get("/{code}", server.HandleGetProject)
r.Put("/{code}", server.HandleUpdateProject)
r.Delete("/{code}", server.HandleDeleteProject)
r.Get("/{code}/items", server.HandleGetProjectItems)
})
// Items
// Projects (read: viewer, write: editor)
r.Route("/projects", func(r chi.Router) {
r.Get("/", server.HandleListProjects)
r.Get("/{code}", server.HandleGetProject)
r.Get("/{code}/items", server.HandleGetProjectItems)
r.Group(func(r chi.Router) {
r.Use(server.RequireRole(auth.RoleEditor))
r.Post("/", server.HandleCreateProject)
r.Put("/{code}", server.HandleUpdateProject)
r.Delete("/{code}", server.HandleDeleteProject)
})
})
// Items (read: viewer, write: editor)
r.Route("/items", func(r chi.Router) {
r.Get("/", server.HandleListItems)
r.Get("/search", server.HandleFuzzySearch)
r.Post("/", server.HandleCreateItem)
// CSV Import/Export
r.Get("/export.csv", server.HandleExportCSV)
r.Post("/import", server.HandleImportCSV)
r.Get("/template.csv", server.HandleCSVTemplate)
r.Group(func(r chi.Router) {
r.Use(server.RequireRole(auth.RoleEditor))
r.Post("/", server.HandleCreateItem)
r.Post("/import", server.HandleImportCSV)
})
r.Route("/{partNumber}", func(r chi.Router) {
r.Get("/", server.HandleGetItem)
r.Put("/", server.HandleUpdateItem)
r.Delete("/", server.HandleDeleteItem)
// Item project tags
r.Get("/projects", server.HandleGetItemProjects)
r.Post("/projects", server.HandleAddItemProjects)
r.Delete("/projects/{code}", server.HandleRemoveItemProject)
// Revisions
r.Get("/revisions", server.HandleListRevisions)
r.Post("/revisions", server.HandleCreateRevision)
r.Get("/revisions/compare", server.HandleCompareRevisions)
r.Get("/revisions/{revision}", server.HandleGetRevision)
r.Patch("/revisions/{revision}", server.HandleUpdateRevision)
r.Post("/revisions/{revision}/rollback", server.HandleRollbackRevision)
// File upload/download
r.Post("/file", server.HandleUploadFile)
r.Get("/file", server.HandleDownloadLatestFile)
r.Get("/file/{revision}", server.HandleDownloadFile)
// BOM / Relationships
r.Get("/bom", server.HandleGetBOM)
r.Post("/bom", server.HandleAddBOMEntry)
r.Get("/bom/expanded", server.HandleGetExpandedBOM)
r.Get("/bom/where-used", server.HandleGetWhereUsed)
r.Get("/bom/export.csv", server.HandleExportBOMCSV)
r.Group(func(r chi.Router) {
r.Use(server.RequireRole(auth.RoleEditor))
r.Put("/", server.HandleUpdateItem)
r.Delete("/", server.HandleDeleteItem)
r.Post("/projects", server.HandleAddItemProjects)
r.Delete("/projects/{code}", server.HandleRemoveItemProject)
r.Post("/revisions", server.HandleCreateRevision)
r.Patch("/revisions/{revision}", server.HandleUpdateRevision)
r.Post("/revisions/{revision}/rollback", server.HandleRollbackRevision)
r.Post("/file", server.HandleUploadFile)
r.Post("/bom", server.HandleAddBOMEntry)
r.Post("/bom/import", server.HandleImportBOMCSV)
r.Put("/bom/{childPartNumber}", server.HandleUpdateBOMEntry)
r.Delete("/bom/{childPartNumber}", server.HandleDeleteBOMEntry)
})
})
})
// Integrations
// Integrations (read: viewer, write: editor)
r.Route("/integrations/odoo", func(r chi.Router) {
r.Get("/config", server.HandleGetOdooConfig)
r.Put("/config", server.HandleUpdateOdooConfig)
r.Get("/sync-log", server.HandleGetOdooSyncLog)
r.Group(func(r chi.Router) {
r.Use(server.RequireRole(auth.RoleEditor))
r.Put("/config", server.HandleUpdateOdooConfig)
r.Post("/test-connection", server.HandleTestOdooConnection)
r.Post("/sync/push/{partNumber}", server.HandleOdooPush)
r.Post("/sync/pull/{odooId}", server.HandleOdooPull)
})
})
// Part number generation
// Part number generation (editor)
r.Group(func(r chi.Router) {
r.Use(server.RequireRole(auth.RoleEditor))
r.Post("/generate-part-number", server.HandleGeneratePartNumber)
})
})
return r
}

View File

@@ -482,7 +482,20 @@
<a href="/" class="{{if eq .Page "items"}}active{{end}}">Items</a>
<a href="/projects" class="{{if eq .Page "projects"}}active{{end}}">Projects</a>
<a href="/schemas" class="{{if eq .Page "schemas"}}active{{end}}">Schemas</a>
<a href="/settings" class="{{if eq .Page "settings"}}active{{end}}">Settings</a>
</nav>
{{if .User}}
<div class="header-user" style="display:flex;align-items:center;gap:0.75rem;">
<span style="color:var(--ctp-subtext1);font-size:0.9rem;">{{.User.DisplayName}}</span>
<span style="display:inline-block;padding:0.15rem 0.5rem;border-radius:1rem;font-size:0.75rem;font-weight:600;
{{if eq .User.Role "admin"}}background:rgba(203,166,247,0.2);color:var(--ctp-mauve);
{{else if eq .User.Role "editor"}}background:rgba(137,180,250,0.2);color:var(--ctp-blue);
{{else}}background:rgba(148,226,213,0.2);color:var(--ctp-teal);{{end}}">{{.User.Role}}</span>
<form method="POST" action="/logout" style="display:inline;margin:0;">
<button type="submit" class="btn-secondary" style="padding:0.35rem 0.75rem;font-size:0.8rem;border-radius:0.4rem;cursor:pointer;border:none;background:var(--ctp-surface1);color:var(--ctp-subtext1);">Logout</button>
</form>
</div>
{{end}}
</header>
<main class="main">
@@ -492,6 +505,8 @@
{{template "projects_content" .}}
{{else if eq .Page "schemas"}}
{{template "schemas_content" .}}
{{else if eq .Page "settings"}}
{{template "settings_content" .}}
{{end}}
</main>
@@ -501,6 +516,8 @@
{{template "projects_scripts" .}}
{{else if eq .Page "schemas"}}
{{template "schemas_scripts" .}}
{{else if eq .Page "settings"}}
{{template "settings_scripts" .}}
{{end}}
</body>
</html>

View File

@@ -0,0 +1,222 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Login - Silo</title>
<style>
:root {
--ctp-rosewater: #f5e0dc;
--ctp-flamingo: #f2cdcd;
--ctp-pink: #f5c2e7;
--ctp-mauve: #cba6f7;
--ctp-red: #f38ba8;
--ctp-maroon: #eba0ac;
--ctp-peach: #fab387;
--ctp-yellow: #f9e2af;
--ctp-green: #a6e3a1;
--ctp-teal: #94e2d5;
--ctp-sky: #89dceb;
--ctp-sapphire: #74c7ec;
--ctp-blue: #89b4fa;
--ctp-lavender: #b4befe;
--ctp-text: #cdd6f4;
--ctp-subtext1: #bac2de;
--ctp-subtext0: #a6adc8;
--ctp-overlay2: #9399b2;
--ctp-overlay1: #7f849c;
--ctp-overlay0: #6c7086;
--ctp-surface2: #585b70;
--ctp-surface1: #45475a;
--ctp-surface0: #313244;
--ctp-base: #1e1e2e;
--ctp-mantle: #181825;
--ctp-crust: #11111b;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
"Inter",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
sans-serif;
background-color: var(--ctp-base);
color: var(--ctp-text);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.login-card {
background-color: var(--ctp-surface0);
border-radius: 1rem;
padding: 2.5rem;
width: 100%;
max-width: 400px;
margin: 1rem;
}
.login-title {
color: var(--ctp-mauve);
text-align: center;
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.login-subtitle {
color: var(--ctp-subtext0);
text-align: center;
font-size: 0.9rem;
margin-bottom: 2rem;
}
.error-msg {
color: var(--ctp-red);
background: rgba(243, 139, 168, 0.1);
border: 1px solid rgba(243, 139, 168, 0.2);
padding: 0.75rem 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--ctp-subtext1);
font-size: 0.9rem;
}
.form-input {
width: 100%;
padding: 0.75rem 1rem;
background-color: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.5rem;
color: var(--ctp-text);
font-size: 1rem;
}
.form-input:focus {
outline: none;
border-color: var(--ctp-mauve);
box-shadow: 0 0 0 3px rgba(203, 166, 247, 0.2);
}
.form-input::placeholder {
color: var(--ctp-overlay0);
}
.btn {
display: block;
width: 100%;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
border: none;
transition: all 0.2s;
text-align: center;
text-decoration: none;
}
.btn-primary {
background-color: var(--ctp-mauve);
color: var(--ctp-crust);
}
.btn-primary:hover {
background-color: var(--ctp-lavender);
}
.btn-oidc {
background-color: var(--ctp-blue);
color: var(--ctp-crust);
}
.btn-oidc:hover {
background-color: var(--ctp-sapphire);
}
.divider {
display: flex;
align-items: center;
margin: 1.5rem 0;
color: var(--ctp-overlay0);
font-size: 0.85rem;
}
.divider::before,
.divider::after {
content: "";
flex: 1;
border-top: 1px solid var(--ctp-surface1);
}
.divider span {
padding: 0 1rem;
}
</style>
</head>
<body>
<div class="login-card">
<h1 class="login-title">Silo</h1>
<p class="login-subtitle">Product Lifecycle Management</p>
{{if .Error}}
<div class="error-msg">{{.Error}}</div>
{{end}}
<form
method="POST"
action="/login{{if .Next}}?next={{.Next}}{{end}}"
>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" />
<div class="form-group">
<label class="form-label">Username</label>
<input
type="text"
name="username"
class="form-input"
placeholder="Username or LDAP uid"
autofocus
required
value="{{.Username}}"
/>
</div>
<div class="form-group">
<label class="form-label">Password</label>
<input
type="password"
name="password"
class="form-input"
placeholder="Password"
required
/>
</div>
<button type="submit" class="btn btn-primary">Sign In</button>
</form>
{{if .OIDCEnabled}}
<div class="divider"><span>or</span></div>
<a href="/auth/oidc" class="btn btn-oidc">Sign in with Keycloak</a>
{{end}}
</div>
</body>
</html>

View File

@@ -0,0 +1,291 @@
{{define "settings_content"}}
<style>
.settings-section {
margin-bottom: 2rem;
}
.settings-info {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1.5rem;
margin-top: 1rem;
}
.settings-info dt {
color: var(--ctp-subtext0);
font-weight: 500;
}
.settings-info dd {
color: var(--ctp-text);
}
.role-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 1rem;
font-size: 0.8rem;
font-weight: 600;
}
.role-admin {
background: rgba(203, 166, 247, 0.2);
color: var(--ctp-mauve);
}
.role-editor {
background: rgba(137, 180, 250, 0.2);
color: var(--ctp-blue);
}
.role-viewer {
background: rgba(148, 226, 213, 0.2);
color: var(--ctp-teal);
}
.token-display {
display: block;
padding: 0.75rem 1rem;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.5rem;
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 0.85rem;
color: var(--ctp-peach);
word-break: break-all;
margin: 0.75rem 0;
}
.new-token-banner {
background: rgba(166, 227, 161, 0.1);
border: 1px solid rgba(166, 227, 161, 0.3);
border-radius: 0.75rem;
padding: 1.25rem;
margin-bottom: 1.5rem;
}
.new-token-banner p {
color: var(--ctp-green);
font-weight: 600;
margin-bottom: 0.5rem;
}
.new-token-banner .hint {
color: var(--ctp-subtext0);
font-size: 0.85rem;
font-weight: 400;
}
.copy-btn {
padding: 0.4rem 0.75rem;
background: var(--ctp-surface1);
border: none;
border-radius: 0.4rem;
color: var(--ctp-text);
cursor: pointer;
font-size: 0.85rem;
}
.copy-btn:hover {
background: var(--ctp-surface2);
}
.token-prefix {
font-family: "JetBrains Mono", "Fira Code", monospace;
color: var(--ctp-peach);
}
.btn-danger {
background: rgba(243, 139, 168, 0.15);
color: var(--ctp-red);
border: none;
padding: 0.4rem 0.75rem;
border-radius: 0.4rem;
cursor: pointer;
font-size: 0.85rem;
}
.btn-danger:hover {
background: rgba(243, 139, 168, 0.25);
}
.create-token-form {
display: flex;
gap: 0.75rem;
align-items: flex-end;
flex-wrap: wrap;
}
.create-token-form .form-group {
margin-bottom: 0;
flex: 1;
min-width: 200px;
}
.no-tokens {
color: var(--ctp-subtext0);
padding: 2rem;
text-align: center;
}
</style>
<div class="settings-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">Account</h2>
</div>
{{if .User}}
<dl class="settings-info">
<dt>Username</dt>
<dd>{{.User.Username}}</dd>
<dt>Display Name</dt>
<dd>{{.User.DisplayName}}</dd>
<dt>Email</dt>
<dd>
{{if .User.Email}}{{.User.Email}}{{else}}<span
style="color: var(--ctp-overlay0)"
>Not set</span
>{{end}}
</dd>
<dt>Auth Source</dt>
<dd>{{.User.AuthSource}}</dd>
<dt>Role</dt>
<dd>
<span class="role-badge role-{{.User.Role}}"
>{{.User.Role}}</span
>
</dd>
</dl>
{{end}}
</div>
</div>
<div class="settings-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">API Tokens</h2>
</div>
<p
style="
color: var(--ctp-subtext0);
margin-bottom: 1.25rem;
font-size: 0.9rem;
"
>
API tokens allow the FreeCAD plugin and scripts to authenticate with
Silo. Tokens inherit your role permissions.
</p>
{{if and .Data (index .Data "new_token")}} {{if ne (index .Data
"new_token") ""}}
<div class="new-token-banner">
<p>Your new API token (copy it now — it won't be shown again):</p>
<code class="token-display" id="new-token-value"
>{{index .Data "new_token"}}</code
>
<button
class="copy-btn"
onclick="
navigator.clipboard
.writeText(
document.getElementById('new-token-value')
.textContent,
)
.then(() => {
this.textContent = 'Copied!';
})
"
>
Copy to clipboard
</button>
<p class="hint">
Store this token securely. You will not be able to see it again.
</p>
</div>
{{end}} {{end}}
<form
method="POST"
action="/settings/tokens"
class="create-token-form"
style="margin-bottom: 1.5rem"
>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" />
<div class="form-group">
<label class="form-label">Token Name</label>
<input
type="text"
name="name"
class="form-input"
placeholder="e.g., FreeCAD workstation"
required
/>
</div>
<button
type="submit"
class="btn btn-primary"
style="padding: 0.75rem 1.25rem; white-space: nowrap"
>
Create Token
</button>
</form>
<div class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Prefix</th>
<th>Created</th>
<th>Last Used</th>
<th>Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="tokens-table"></tbody>
</table>
</div>
</div>
</div>
{{end}} {{define "settings_scripts"}}
<script>
(function () {
async function loadTokens() {
try {
const resp = await fetch("/api/auth/tokens");
if (!resp.ok) return;
const tokens = await resp.json();
const tbody = document.getElementById("tokens-table");
if (!tokens || tokens.length === 0) {
tbody.innerHTML =
'<tr><td colspan="6" class="no-tokens">No API tokens yet. Create one to get started.</td></tr>';
return;
}
tbody.innerHTML = tokens
.map(
(t) => `
<tr>
<td>${escHtml(t.name)}</td>
<td><span class="token-prefix">${escHtml(t.token_prefix)}...</span></td>
<td>${formatDate(t.created_at)}</td>
<td>${t.last_used_at ? formatDate(t.last_used_at) : '<span style="color:var(--ctp-overlay0)">Never</span>'}</td>
<td>${t.expires_at ? formatDate(t.expires_at) : '<span style="color:var(--ctp-overlay0)">Never</span>'}</td>
<td>
<form method="POST" action="/settings/tokens/${t.id}/revoke" style="display:inline">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-danger" onclick="return confirm('Revoke this token?')">Revoke</button>
</form>
</td>
</tr>
`,
)
.join("");
} catch (e) {
console.error("Failed to load tokens:", e);
}
}
function formatDate(s) {
if (!s) return "";
const d = new Date(s);
return (
d.toLocaleDateString() +
" " +
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
);
}
function escHtml(s) {
const div = document.createElement("div");
div.textContent = s;
return div.innerHTML;
}
// Check for newly created token in URL or page state
const params = new URLSearchParams(window.location.search);
// The token is passed via a cookie/session flash, rendered by the server if present
loadTokens();
})();
</script>
{{end}}

View File

@@ -1,11 +1,12 @@
package api
import (
"bytes"
"embed"
"html/template"
"net/http"
"github.com/justinas/nosurf"
"github.com/kindredsystems/silo/internal/auth"
"github.com/rs/zerolog"
)
@@ -16,20 +17,26 @@ var templatesFS embed.FS
type WebHandler struct {
templates *template.Template
logger zerolog.Logger
server *Server
}
// NewWebHandler creates a new web handler.
func NewWebHandler(logger zerolog.Logger) (*WebHandler, error) {
// Parse templates from embedded filesystem
func NewWebHandler(logger zerolog.Logger, server *Server) (*WebHandler, error) {
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
if err != nil {
return nil, err
}
return &WebHandler{
wh := &WebHandler{
templates: tmpl,
logger: logger,
}, nil
server: server,
}
// Store reference on server for auth handlers to use templates
server.webHandler = wh
return wh, nil
}
// PageData holds data for page rendering.
@@ -37,29 +44,12 @@ type PageData struct {
Title string
Page string
Data any
}
// render executes a page template within the base layout.
func (h *WebHandler) render(w http.ResponseWriter, page string, data PageData) {
// First, render the page-specific content
var contentBuf bytes.Buffer
if err := h.templates.ExecuteTemplate(&contentBuf, page+".html", data); err != nil {
h.logger.Error().Err(err).Str("page", page).Msg("failed to render page template")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Now render the base template with the content
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := h.templates.ExecuteTemplate(w, "base.html", data); err != nil {
h.logger.Error().Err(err).Msg("failed to render base template")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
User *auth.User
CSRFToken string
}
// HandleIndex serves the main items page.
func (h *WebHandler) HandleIndex(w http.ResponseWriter, r *http.Request) {
// Check if this is the root path
if r.URL.Path != "/" {
http.NotFound(w, r)
return
@@ -68,6 +58,8 @@ func (h *WebHandler) HandleIndex(w http.ResponseWriter, r *http.Request) {
data := PageData{
Title: "Items",
Page: "items",
User: auth.UserFromContext(r.Context()),
CSRFToken: nosurf.Token(r),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -82,6 +74,8 @@ func (h *WebHandler) HandleProjectsPage(w http.ResponseWriter, r *http.Request)
data := PageData{
Title: "Projects",
Page: "projects",
User: auth.UserFromContext(r.Context()),
CSRFToken: nosurf.Token(r),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -96,6 +90,8 @@ func (h *WebHandler) HandleSchemasPage(w http.ResponseWriter, r *http.Request) {
data := PageData{
Title: "Schemas",
Page: "schemas",
User: auth.UserFromContext(r.Context()),
CSRFToken: nosurf.Token(r),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")

188
internal/auth/auth.go Normal file
View File

@@ -0,0 +1,188 @@
// Package auth provides authentication and authorization for Silo.
package auth
import (
"context"
"fmt"
"github.com/rs/zerolog"
"github.com/kindredsystems/silo/internal/db"
)
// Role constants.
const (
RoleAdmin = "admin"
RoleEditor = "editor"
RoleViewer = "viewer"
)
// roleRank maps roles to their privilege level for comparison.
var roleRank = map[string]int{
RoleViewer: 1,
RoleEditor: 2,
RoleAdmin: 3,
}
// RoleSatisfies returns true if the user's role meets or exceeds the minimum required role.
func RoleSatisfies(userRole, minimumRole string) bool {
return roleRank[userRole] >= roleRank[minimumRole]
}
// User represents an authenticated user in the system.
type User struct {
ID string
Username string
DisplayName string
Email string
Role string // "admin", "editor", "viewer"
AuthSource string // "local", "ldap", "oidc"
}
// contextKey is a private type for context keys in this package.
type contextKey int
const userContextKey contextKey = iota
// UserFromContext extracts the authenticated user from the request context.
// Returns nil if no user is present (unauthenticated request).
func UserFromContext(ctx context.Context) *User {
u, _ := ctx.Value(userContextKey).(*User)
return u
}
// ContextWithUser returns a new context carrying the given user.
func ContextWithUser(ctx context.Context, u *User) context.Context {
return context.WithValue(ctx, userContextKey, u)
}
// Backend is the interface every auth provider must implement.
type Backend interface {
// Name returns the backend identifier ("local", "ldap").
Name() string
// Authenticate validates credentials and returns the authenticated user.
Authenticate(ctx context.Context, username, password string) (*User, error)
}
// Service orchestrates authentication across all configured backends.
type Service struct {
users *db.UserRepository
tokens *db.TokenRepository
backends []Backend
logger zerolog.Logger
}
// NewService creates the auth service with the given backends.
func NewService(logger zerolog.Logger, users *db.UserRepository, tokens *db.TokenRepository, backends ...Backend) *Service {
return &Service{
users: users,
tokens: tokens,
backends: backends,
logger: logger,
}
}
// Authenticate tries each backend in order until one succeeds.
// On success, upserts the user into the local database and updates last_login_at.
func (s *Service) Authenticate(ctx context.Context, username, password string) (*User, error) {
for _, b := range s.backends {
user, err := b.Authenticate(ctx, username, password)
if err != nil {
s.logger.Debug().Str("backend", b.Name()).Str("username", username).Err(err).Msg("auth attempt failed")
continue
}
// Upsert user into local database
dbUser := &db.User{
Username: user.Username,
DisplayName: user.DisplayName,
Email: user.Email,
AuthSource: user.AuthSource,
Role: user.Role,
}
if err := s.users.Upsert(ctx, dbUser); err != nil {
return nil, fmt.Errorf("upserting user: %w", err)
}
user.ID = dbUser.ID
s.logger.Info().Str("backend", b.Name()).Str("username", username).Msg("user authenticated")
return user, nil
}
return nil, fmt.Errorf("invalid credentials")
}
// UpsertOIDCUser upserts a user from OIDC claims into the local database.
func (s *Service) UpsertOIDCUser(ctx context.Context, user *User) error {
dbUser := &db.User{
Username: user.Username,
DisplayName: user.DisplayName,
Email: user.Email,
AuthSource: "oidc",
OIDCSubject: &user.ID, // ID carries the OIDC subject before DB upsert
Role: user.Role,
}
if err := s.users.Upsert(ctx, dbUser); err != nil {
return fmt.Errorf("upserting oidc user: %w", err)
}
user.ID = dbUser.ID
return nil
}
// ValidateToken checks a raw API token and returns the owning user.
func (s *Service) ValidateToken(ctx context.Context, rawToken string) (*User, error) {
tokenInfo, err := s.tokens.ValidateToken(ctx, rawToken)
if err != nil {
return nil, err
}
dbUser, err := s.users.GetByID(ctx, tokenInfo.UserID)
if err != nil {
return nil, fmt.Errorf("looking up token user: %w", err)
}
if dbUser == nil || !dbUser.IsActive {
return nil, fmt.Errorf("token user not found or inactive")
}
// Update last_used_at asynchronously
go func() {
_ = s.tokens.TouchLastUsed(context.Background(), tokenInfo.ID)
}()
return &User{
ID: dbUser.ID,
Username: dbUser.Username,
DisplayName: dbUser.DisplayName,
Email: dbUser.Email,
Role: dbUser.Role,
AuthSource: dbUser.AuthSource,
}, nil
}
// GetUserByID retrieves a user by their database ID.
func (s *Service) GetUserByID(ctx context.Context, id string) (*User, error) {
dbUser, err := s.users.GetByID(ctx, id)
if err != nil {
return nil, err
}
if dbUser == nil {
return nil, nil
}
return &User{
ID: dbUser.ID,
Username: dbUser.Username,
DisplayName: dbUser.DisplayName,
Email: dbUser.Email,
Role: dbUser.Role,
AuthSource: dbUser.AuthSource,
}, nil
}
// Users returns the underlying user repository for direct access.
func (s *Service) Users() *db.UserRepository {
return s.users
}
// Tokens returns the underlying token repository for direct access.
func (s *Service) Tokens() *db.TokenRepository {
return s.tokens
}

107
internal/auth/ldap.go Normal file
View File

@@ -0,0 +1,107 @@
package auth
import (
"context"
"crypto/tls"
"fmt"
ldapv3 "github.com/go-ldap/ldap/v3"
)
// LDAPConfig holds settings for the LDAP backend.
type LDAPConfig struct {
URL string
BaseDN string
UserSearchDN string
BindDN string
BindPassword string
UserAttr string
EmailAttr string
DisplayAttr string
GroupAttr string
RoleMapping map[string][]string // role -> list of group DNs
TLSSkipVerify bool
}
// LDAPBackend authenticates via LDAP simple bind against FreeIPA.
type LDAPBackend struct {
cfg LDAPConfig
}
// NewLDAPBackend creates an LDAP authentication backend.
func NewLDAPBackend(cfg LDAPConfig) *LDAPBackend {
return &LDAPBackend{cfg: cfg}
}
// Name returns "ldap".
func (b *LDAPBackend) Name() string { return "ldap" }
// Authenticate verifies credentials against the LDAP server.
func (b *LDAPBackend) Authenticate(_ context.Context, username, password string) (*User, error) {
if username == "" || password == "" {
return nil, fmt.Errorf("username and password required")
}
conn, err := ldapv3.DialURL(b.cfg.URL, ldapv3.DialWithTLSConfig(&tls.Config{
InsecureSkipVerify: b.cfg.TLSSkipVerify,
}))
if err != nil {
return nil, fmt.Errorf("ldap dial: %w", err)
}
defer conn.Close()
// Build user DN and bind with user credentials
userDN := fmt.Sprintf("%s=%s,%s", b.cfg.UserAttr, ldapv3.EscapeFilter(username), b.cfg.UserSearchDN)
if err := conn.Bind(userDN, password); err != nil {
return nil, fmt.Errorf("ldap bind failed: %w", err)
}
// Search for user attributes
searchReq := ldapv3.NewSearchRequest(
userDN,
ldapv3.ScopeBaseObject, ldapv3.NeverDerefAliases, 1, 10, false,
"(objectClass=*)",
[]string{b.cfg.EmailAttr, b.cfg.DisplayAttr, b.cfg.GroupAttr},
nil,
)
sr, err := conn.Search(searchReq)
if err != nil || len(sr.Entries) == 0 {
return nil, fmt.Errorf("ldap user search failed: %w", err)
}
entry := sr.Entries[0]
email := entry.GetAttributeValue(b.cfg.EmailAttr)
displayName := entry.GetAttributeValue(b.cfg.DisplayAttr)
if displayName == "" {
displayName = username
}
groups := entry.GetAttributeValues(b.cfg.GroupAttr)
role := b.resolveRole(groups)
return &User{
Username: username,
DisplayName: displayName,
Email: email,
Role: role,
AuthSource: "ldap",
}, nil
}
// resolveRole maps LDAP group memberships to a Silo role.
// Checks in priority order: admin > editor > viewer.
func (b *LDAPBackend) resolveRole(groups []string) string {
groupSet := make(map[string]struct{}, len(groups))
for _, g := range groups {
groupSet[g] = struct{}{}
}
for _, role := range []string{RoleAdmin, RoleEditor, RoleViewer} {
for _, requiredGroup := range b.cfg.RoleMapping[role] {
if _, ok := groupSet[requiredGroup]; ok {
return role
}
}
}
return RoleViewer
}

68
internal/auth/local.go Normal file
View File

@@ -0,0 +1,68 @@
package auth
import (
"context"
"fmt"
"golang.org/x/crypto/bcrypt"
"github.com/kindredsystems/silo/internal/db"
)
// BcryptCost is the cost parameter for bcrypt hashing.
const BcryptCost = 12
// LocalBackend authenticates against bcrypt password hashes in the users table.
type LocalBackend struct {
users *db.UserRepository
}
// NewLocalBackend creates a local authentication backend.
func NewLocalBackend(users *db.UserRepository) *LocalBackend {
return &LocalBackend{users: users}
}
// Name returns "local".
func (b *LocalBackend) Name() string { return "local" }
// Authenticate verifies a username and password against the local database.
func (b *LocalBackend) Authenticate(ctx context.Context, username, password string) (*User, error) {
dbUser, hash, err := b.users.GetWithPasswordHash(ctx, username)
if err != nil {
return nil, fmt.Errorf("looking up user: %w", err)
}
if dbUser == nil {
return nil, fmt.Errorf("user not found")
}
if hash == "" {
return nil, fmt.Errorf("no local password set")
}
if !dbUser.IsActive {
return nil, fmt.Errorf("account is disabled")
}
if dbUser.AuthSource != "local" {
return nil, fmt.Errorf("user authenticates via %s", dbUser.AuthSource)
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
return nil, fmt.Errorf("invalid password")
}
return &User{
ID: dbUser.ID,
Username: dbUser.Username,
DisplayName: dbUser.DisplayName,
Email: dbUser.Email,
Role: dbUser.Role,
AuthSource: "local",
}, nil
}
// HashPassword creates a bcrypt hash of the given password.
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
if err != nil {
return "", fmt.Errorf("hashing password: %w", err)
}
return string(hash), nil
}

148
internal/auth/oidc.go Normal file
View File

@@ -0,0 +1,148 @@
package auth
import (
"context"
"fmt"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
// OIDCConfig holds settings for the OIDC backend.
type OIDCConfig struct {
IssuerURL string
ClientID string
ClientSecret string
RedirectURL string
Scopes []string
AdminRole string
EditorRole string
DefaultRole string
}
// OIDCBackend handles OIDC redirect-based authentication (e.g., Keycloak).
type OIDCBackend struct {
cfg OIDCConfig
provider *oidc.Provider
verifier *oidc.IDTokenVerifier
oauth oauth2.Config
}
// NewOIDCBackend creates and initializes an OIDC backend.
// Contacts the issuer URL to discover endpoints, so requires network access.
func NewOIDCBackend(ctx context.Context, cfg OIDCConfig) (*OIDCBackend, error) {
provider, err := oidc.NewProvider(ctx, cfg.IssuerURL)
if err != nil {
return nil, fmt.Errorf("oidc provider discovery: %w", err)
}
scopes := cfg.Scopes
if len(scopes) == 0 {
scopes = []string{oidc.ScopeOpenID, "profile", "email"}
}
oauthConfig := oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: cfg.RedirectURL,
Endpoint: provider.Endpoint(),
Scopes: scopes,
}
return &OIDCBackend{
cfg: cfg,
provider: provider,
verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
oauth: oauthConfig,
}, nil
}
// Name returns "oidc".
func (b *OIDCBackend) Name() string { return "oidc" }
// Authenticate is not used for OIDC — the redirect flow is handled by
// AuthCodeURL and Exchange instead.
func (b *OIDCBackend) Authenticate(_ context.Context, _, _ string) (*User, error) {
return nil, fmt.Errorf("OIDC requires redirect flow, not direct authentication")
}
// AuthCodeURL generates the OIDC authorization URL with the given state parameter.
func (b *OIDCBackend) AuthCodeURL(state string) string {
return b.oauth.AuthCodeURL(state)
}
// Exchange handles the OIDC callback: exchanges the authorization code for tokens,
// verifies the ID token, and extracts user claims.
func (b *OIDCBackend) Exchange(ctx context.Context, code string) (*User, error) {
token, err := b.oauth.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("oidc code exchange: %w", err)
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return nil, fmt.Errorf("no id_token in oidc response")
}
idToken, err := b.verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, fmt.Errorf("oidc token verification: %w", err)
}
var claims struct {
Subject string `json:"sub"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
Name string `json:"name"`
RealmAccess struct {
Roles []string `json:"roles"`
} `json:"realm_access"`
}
if err := idToken.Claims(&claims); err != nil {
return nil, fmt.Errorf("parsing oidc claims: %w", err)
}
username := claims.PreferredUsername
if username == "" {
username = claims.Subject
}
displayName := claims.Name
if displayName == "" {
displayName = username
}
role := b.resolveRole(claims.RealmAccess.Roles)
return &User{
ID: claims.Subject, // Temporarily holds OIDC subject; replaced by DB ID after upsert
Username: username,
DisplayName: displayName,
Email: claims.Email,
Role: role,
AuthSource: "oidc",
}, nil
}
// resolveRole maps Keycloak realm roles to a Silo role.
func (b *OIDCBackend) resolveRole(roles []string) string {
roleSet := make(map[string]struct{}, len(roles))
for _, r := range roles {
roleSet[r] = struct{}{}
}
if b.cfg.AdminRole != "" {
if _, ok := roleSet[b.cfg.AdminRole]; ok {
return RoleAdmin
}
}
if b.cfg.EditorRole != "" {
if _, ok := roleSet[b.cfg.EditorRole]; ok {
return RoleEditor
}
}
if b.cfg.DefaultRole != "" {
return b.cfg.DefaultRole
}
return RoleViewer
}

51
internal/auth/token.go Normal file
View File

@@ -0,0 +1,51 @@
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
"github.com/kindredsystems/silo/internal/db"
)
const (
tokenPrefixStr = "silo_"
tokenRawBytes = 32 // 32 random bytes = 64 hex chars
)
// GenerateToken creates a new API token. Returns the raw token string
// (shown once to the user) and the persisted token info.
func (s *Service) GenerateToken(ctx context.Context, userID, name string, scopes []string, expiresAt *time.Time) (string, *db.TokenInfo, error) {
rawBytes := make([]byte, tokenRawBytes)
if _, err := rand.Read(rawBytes); err != nil {
return "", nil, fmt.Errorf("generating random bytes: %w", err)
}
rawToken := tokenPrefixStr + hex.EncodeToString(rawBytes)
hash := sha256.Sum256([]byte(rawToken))
tokenHash := hex.EncodeToString(hash[:])
// Prefix for display: "silo_" + first 8 hex chars
displayPrefix := rawToken[:len(tokenPrefixStr)+8]
info, err := s.tokens.Create(ctx, userID, name, tokenHash, displayPrefix, scopes, expiresAt)
if err != nil {
return "", nil, err
}
return rawToken, info, nil
}
// ListTokens returns all tokens for a user.
func (s *Service) ListTokens(ctx context.Context, userID string) ([]*db.TokenInfo, error) {
return s.tokens.ListByUser(ctx, userID)
}
// RevokeToken deletes a token by ID, ensuring it belongs to the given user.
func (s *Service) RevokeToken(ctx context.Context, userID, tokenID string) error {
return s.tokens.Delete(ctx, userID, tokenID)
}

View File

@@ -16,6 +16,58 @@ type Config struct {
Schemas SchemasConfig `yaml:"schemas"`
FreeCAD FreeCADConfig `yaml:"freecad"`
Odoo OdooConfig `yaml:"odoo"`
Auth AuthConfig `yaml:"auth"`
}
// AuthConfig holds authentication and authorization settings.
type AuthConfig struct {
Enabled bool `yaml:"enabled"`
SessionSecret string `yaml:"session_secret"`
Local LocalAuth `yaml:"local"`
LDAP LDAPAuth `yaml:"ldap"`
OIDC OIDCAuth `yaml:"oidc"`
CORS CORSConfig `yaml:"cors"`
}
// LocalAuth holds settings for local account authentication.
type LocalAuth struct {
Enabled bool `yaml:"enabled"`
DefaultAdminUsername string `yaml:"default_admin_username"`
DefaultAdminPassword string `yaml:"default_admin_password"`
}
// LDAPAuth holds settings for LDAP/FreeIPA authentication.
type LDAPAuth struct {
Enabled bool `yaml:"enabled"`
URL string `yaml:"url"`
BaseDN string `yaml:"base_dn"`
UserSearchDN string `yaml:"user_search_dn"`
BindDN string `yaml:"bind_dn"`
BindPassword string `yaml:"bind_password"`
UserAttr string `yaml:"user_attr"`
EmailAttr string `yaml:"email_attr"`
DisplayAttr string `yaml:"display_attr"`
GroupAttr string `yaml:"group_attr"`
RoleMapping map[string][]string `yaml:"role_mapping"`
TLSSkipVerify bool `yaml:"tls_skip_verify"`
}
// OIDCAuth holds settings for OIDC/Keycloak authentication.
type OIDCAuth struct {
Enabled bool `yaml:"enabled"`
IssuerURL string `yaml:"issuer_url"`
ClientID string `yaml:"client_id"`
ClientSecret string `yaml:"client_secret"`
RedirectURL string `yaml:"redirect_url"`
Scopes []string `yaml:"scopes"`
AdminRole string `yaml:"admin_role"`
EditorRole string `yaml:"editor_role"`
DefaultRole string `yaml:"default_role"`
}
// CORSConfig holds CORS settings.
type CORSConfig struct {
AllowedOrigins []string `yaml:"allowed_origins"`
}
// ServerConfig holds HTTP server settings.
@@ -128,5 +180,39 @@ func Load(path string) (*Config, error) {
cfg.Storage.SecretKey = v
}
// Auth defaults
if cfg.Auth.LDAP.UserAttr == "" {
cfg.Auth.LDAP.UserAttr = "uid"
}
if cfg.Auth.LDAP.EmailAttr == "" {
cfg.Auth.LDAP.EmailAttr = "mail"
}
if cfg.Auth.LDAP.DisplayAttr == "" {
cfg.Auth.LDAP.DisplayAttr = "displayName"
}
if cfg.Auth.LDAP.GroupAttr == "" {
cfg.Auth.LDAP.GroupAttr = "memberOf"
}
if cfg.Auth.OIDC.DefaultRole == "" {
cfg.Auth.OIDC.DefaultRole = "viewer"
}
// Auth environment variable overrides
if v := os.Getenv("SILO_SESSION_SECRET"); v != "" {
cfg.Auth.SessionSecret = v
}
if v := os.Getenv("SILO_OIDC_CLIENT_SECRET"); v != "" {
cfg.Auth.OIDC.ClientSecret = v
}
if v := os.Getenv("SILO_LDAP_BIND_PASSWORD"); v != "" {
cfg.Auth.LDAP.BindPassword = v
}
if v := os.Getenv("SILO_ADMIN_USERNAME"); v != "" {
cfg.Auth.Local.DefaultAdminUsername = v
}
if v := os.Getenv("SILO_ADMIN_PASSWORD"); v != "" {
cfg.Auth.Local.DefaultAdminPassword = v
}
return &cfg, nil
}

View File

@@ -22,6 +22,8 @@ type Item struct {
CurrentRevision int
CADSyncedAt *time.Time
CADFilePath *string
CreatedBy *string
UpdatedBy *string
}
// Revision represents a revision record.
@@ -84,10 +86,10 @@ func (r *ItemRepository) Create(ctx context.Context, item *Item, properties map[
return r.db.Tx(ctx, func(tx pgx.Tx) error {
// Insert item
err := tx.QueryRow(ctx, `
INSERT INTO items (part_number, schema_id, item_type, description)
VALUES ($1, $2, $3, $4)
INSERT INTO items (part_number, schema_id, item_type, description, created_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at, updated_at, current_revision
`, item.PartNumber, item.SchemaID, item.ItemType, item.Description).Scan(
`, item.PartNumber, item.SchemaID, item.ItemType, item.Description, item.CreatedBy).Scan(
&item.ID, &item.CreatedAt, &item.UpdatedAt, &item.CurrentRevision,
)
if err != nil {
@@ -101,9 +103,9 @@ func (r *ItemRepository) Create(ctx context.Context, item *Item, properties map[
}
_, err = tx.Exec(ctx, `
INSERT INTO revisions (item_id, revision_number, properties)
VALUES ($1, 1, $2)
`, item.ID, propsJSON)
INSERT INTO revisions (item_id, revision_number, properties, created_by)
VALUES ($1, 1, $2, $3)
`, item.ID, propsJSON, item.CreatedBy)
if err != nil {
return fmt.Errorf("inserting revision: %w", err)
}
@@ -626,12 +628,12 @@ func (r *ItemRepository) Archive(ctx context.Context, id string) error {
// Update modifies an item's part number, type, and description.
// The UUID remains stable.
func (r *ItemRepository) Update(ctx context.Context, id string, partNumber string, itemType string, description string) error {
func (r *ItemRepository) Update(ctx context.Context, id string, partNumber string, itemType string, description string, updatedBy *string) error {
_, err := r.db.pool.Exec(ctx, `
UPDATE items
SET part_number = $2, item_type = $3, description = $4, updated_at = now()
SET part_number = $2, item_type = $3, description = $4, updated_by = $5, updated_at = now()
WHERE id = $1 AND archived_at IS NULL
`, id, partNumber, itemType, description)
`, id, partNumber, itemType, description, updatedBy)
if err != nil {
return fmt.Errorf("updating item: %w", err)
}

View File

@@ -14,6 +14,7 @@ type Project struct {
Name string
Description string
CreatedAt time.Time
CreatedBy *string
}
// ProjectRepository provides project database operations.
@@ -114,10 +115,10 @@ func (r *ProjectRepository) GetByID(ctx context.Context, id string) (*Project, e
// Create inserts a new project.
func (r *ProjectRepository) Create(ctx context.Context, p *Project) error {
return r.db.pool.QueryRow(ctx, `
INSERT INTO projects (code, name, description)
VALUES ($1, $2, $3)
INSERT INTO projects (code, name, description, created_by)
VALUES ($1, $2, $3, $4)
RETURNING id, created_at
`, p.Code, nullIfEmpty(p.Name), nullIfEmpty(p.Description)).Scan(&p.ID, &p.CreatedAt)
`, p.Code, nullIfEmpty(p.Name), nullIfEmpty(p.Description), p.CreatedBy).Scan(&p.ID, &p.CreatedAt)
}
// Update updates a project's name and description.

View File

@@ -23,6 +23,8 @@ type Relationship struct {
ParentRevisionID *string
CreatedAt time.Time
UpdatedAt time.Time
CreatedBy *string
UpdatedBy *string
}
// BOMEntry is a denormalized row for BOM display, combining relationship
@@ -83,12 +85,13 @@ func (r *RelationshipRepository) Create(ctx context.Context, rel *Relationship)
err = r.db.pool.QueryRow(ctx, `
INSERT INTO relationships (
parent_item_id, child_item_id, rel_type, quantity, unit,
reference_designators, child_revision, metadata, parent_revision_id
reference_designators, child_revision, metadata, parent_revision_id, created_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, created_at, updated_at
`, rel.ParentItemID, rel.ChildItemID, rel.RelType, rel.Quantity, rel.Unit,
rel.ReferenceDesignators, rel.ChildRevision, metadataJSON, rel.ParentRevisionID,
rel.CreatedBy,
).Scan(&rel.ID, &rel.CreatedAt, &rel.UpdatedAt)
if err != nil {
return fmt.Errorf("inserting relationship: %w", err)
@@ -98,12 +101,18 @@ func (r *RelationshipRepository) Create(ctx context.Context, rel *Relationship)
}
// Update modifies an existing relationship's mutable fields.
func (r *RelationshipRepository) Update(ctx context.Context, id string, relType *string, quantity *float64, unit *string, refDes []string, childRevision *int, metadata map[string]any) error {
func (r *RelationshipRepository) Update(ctx context.Context, id string, relType *string, quantity *float64, unit *string, refDes []string, childRevision *int, metadata map[string]any, updatedBy *string) error {
// Build dynamic update query
query := "UPDATE relationships SET updated_at = now()"
args := []any{}
argNum := 1
if updatedBy != nil {
query += fmt.Sprintf(", updated_by = $%d", argNum)
args = append(args, *updatedBy)
argNum++
}
if relType != nil {
query += fmt.Sprintf(", rel_type = $%d", argNum)
args = append(args, *relType)

371
internal/db/users.go Normal file
View File

@@ -0,0 +1,371 @@
package db
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// User represents a user in the database.
type User struct {
ID string
Username string
DisplayName string
Email string
AuthSource string
OIDCSubject *string
Role string
IsActive bool
LastLoginAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// TokenInfo holds metadata about an API token (never the raw token or hash).
type TokenInfo struct {
ID string
UserID string
Name string
TokenPrefix string
Scopes []string
LastUsedAt *time.Time
ExpiresAt *time.Time
CreatedAt time.Time
}
// UserRepository provides user database operations.
type UserRepository struct {
db *DB
}
// NewUserRepository creates a new user repository.
func NewUserRepository(db *DB) *UserRepository {
return &UserRepository{db: db}
}
// GetByID returns a user by ID.
func (r *UserRepository) GetByID(ctx context.Context, id string) (*User, error) {
u := &User{}
var email, oidcSubject *string
err := r.db.pool.QueryRow(ctx, `
SELECT id, username, display_name, email, auth_source, oidc_subject,
role, is_active, last_login_at, created_at, updated_at
FROM users WHERE id = $1
`, id).Scan(
&u.ID, &u.Username, &u.DisplayName, &email, &u.AuthSource, &oidcSubject,
&u.Role, &u.IsActive, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("getting user by id: %w", err)
}
if email != nil {
u.Email = *email
}
u.OIDCSubject = oidcSubject
return u, nil
}
// GetByUsername returns a user by username.
func (r *UserRepository) GetByUsername(ctx context.Context, username string) (*User, error) {
u := &User{}
var email, oidcSubject *string
err := r.db.pool.QueryRow(ctx, `
SELECT id, username, display_name, email, auth_source, oidc_subject,
role, is_active, last_login_at, created_at, updated_at
FROM users WHERE username = $1
`, username).Scan(
&u.ID, &u.Username, &u.DisplayName, &email, &u.AuthSource, &oidcSubject,
&u.Role, &u.IsActive, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("getting user by username: %w", err)
}
if email != nil {
u.Email = *email
}
u.OIDCSubject = oidcSubject
return u, nil
}
// GetWithPasswordHash returns a user and their password hash for local authentication.
func (r *UserRepository) GetWithPasswordHash(ctx context.Context, username string) (*User, string, error) {
u := &User{}
var email, oidcSubject, passwordHash *string
err := r.db.pool.QueryRow(ctx, `
SELECT id, username, display_name, email, password_hash, auth_source,
oidc_subject, role, is_active, last_login_at, created_at, updated_at
FROM users WHERE username = $1
`, username).Scan(
&u.ID, &u.Username, &u.DisplayName, &email, &passwordHash, &u.AuthSource,
&oidcSubject, &u.Role, &u.IsActive, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, "", nil
}
if err != nil {
return nil, "", fmt.Errorf("getting user with password: %w", err)
}
if email != nil {
u.Email = *email
}
u.OIDCSubject = oidcSubject
hash := ""
if passwordHash != nil {
hash = *passwordHash
}
return u, hash, nil
}
// GetByOIDCSubject returns a user by their OIDC subject claim.
func (r *UserRepository) GetByOIDCSubject(ctx context.Context, subject string) (*User, error) {
u := &User{}
var email, oidcSubject *string
err := r.db.pool.QueryRow(ctx, `
SELECT id, username, display_name, email, auth_source, oidc_subject,
role, is_active, last_login_at, created_at, updated_at
FROM users WHERE oidc_subject = $1
`, subject).Scan(
&u.ID, &u.Username, &u.DisplayName, &email, &u.AuthSource, &oidcSubject,
&u.Role, &u.IsActive, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("getting user by oidc subject: %w", err)
}
if email != nil {
u.Email = *email
}
u.OIDCSubject = oidcSubject
return u, nil
}
// Upsert inserts a new user or updates an existing one by username.
// Used when LDAP/OIDC users log in to sync their external attributes.
func (r *UserRepository) Upsert(ctx context.Context, u *User) error {
var email *string
if u.Email != "" {
email = &u.Email
}
err := r.db.pool.QueryRow(ctx, `
INSERT INTO users (username, display_name, email, auth_source, oidc_subject, role, last_login_at)
VALUES ($1, $2, $3, $4, $5, $6, now())
ON CONFLICT (username) DO UPDATE SET
display_name = EXCLUDED.display_name,
email = EXCLUDED.email,
auth_source = EXCLUDED.auth_source,
oidc_subject = COALESCE(EXCLUDED.oidc_subject, users.oidc_subject),
role = EXCLUDED.role,
last_login_at = now(),
updated_at = now()
RETURNING id
`, u.Username, u.DisplayName, email, u.AuthSource, u.OIDCSubject, u.Role).Scan(&u.ID)
if err != nil {
return fmt.Errorf("upserting user: %w", err)
}
return nil
}
// Create inserts a new local user with a password hash.
func (r *UserRepository) Create(ctx context.Context, u *User, passwordHash string) error {
var email *string
if u.Email != "" {
email = &u.Email
}
err := r.db.pool.QueryRow(ctx, `
INSERT INTO users (username, display_name, email, password_hash, auth_source, role)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
`, u.Username, u.DisplayName, email, passwordHash, u.AuthSource, u.Role).Scan(&u.ID)
if err != nil {
return fmt.Errorf("creating user: %w", err)
}
return nil
}
// UpdateLastLogin sets last_login_at to now.
func (r *UserRepository) UpdateLastLogin(ctx context.Context, id string) error {
_, err := r.db.pool.Exec(ctx, `
UPDATE users SET last_login_at = now(), updated_at = now() WHERE id = $1
`, id)
return err
}
// SetPassword updates the password hash for a local user.
func (r *UserRepository) SetPassword(ctx context.Context, id string, hash string) error {
_, err := r.db.pool.Exec(ctx, `
UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1
`, id, hash)
return err
}
// SetActive enables or disables a user.
func (r *UserRepository) SetActive(ctx context.Context, id string, active bool) error {
_, err := r.db.pool.Exec(ctx, `
UPDATE users SET is_active = $2, updated_at = now() WHERE id = $1
`, id, active)
return err
}
// SetRole updates the role for a user.
func (r *UserRepository) SetRole(ctx context.Context, id string, role string) error {
_, err := r.db.pool.Exec(ctx, `
UPDATE users SET role = $2, updated_at = now() WHERE id = $1
`, id, role)
return err
}
// List returns all users ordered by username.
func (r *UserRepository) List(ctx context.Context) ([]*User, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT id, username, display_name, email, auth_source, oidc_subject,
role, is_active, last_login_at, created_at, updated_at
FROM users
ORDER BY username
`)
if err != nil {
return nil, fmt.Errorf("listing users: %w", err)
}
defer rows.Close()
var users []*User
for rows.Next() {
u := &User{}
var email, oidcSubject *string
if err := rows.Scan(
&u.ID, &u.Username, &u.DisplayName, &email, &u.AuthSource, &oidcSubject,
&u.Role, &u.IsActive, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt,
); err != nil {
return nil, err
}
if email != nil {
u.Email = *email
}
u.OIDCSubject = oidcSubject
users = append(users, u)
}
return users, rows.Err()
}
// TokenRepository provides API token database operations.
type TokenRepository struct {
db *DB
}
// NewTokenRepository creates a new token repository.
func NewTokenRepository(db *DB) *TokenRepository {
return &TokenRepository{db: db}
}
// Create inserts a new API token record.
func (r *TokenRepository) Create(ctx context.Context, userID, name, tokenHash, tokenPrefix string, scopes []string, expiresAt *time.Time) (*TokenInfo, error) {
t := &TokenInfo{}
if scopes == nil {
scopes = []string{}
}
err := r.db.pool.QueryRow(ctx, `
INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, user_id, name, token_prefix, scopes, last_used_at, expires_at, created_at
`, userID, name, tokenHash, tokenPrefix, scopes, expiresAt).Scan(
&t.ID, &t.UserID, &t.Name, &t.TokenPrefix, &t.Scopes,
&t.LastUsedAt, &t.ExpiresAt, &t.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("creating api token: %w", err)
}
return t, nil
}
// ValidateToken hashes the raw token, looks it up, and checks expiry and user active status.
// Returns the token info and user ID on success.
func (r *TokenRepository) ValidateToken(ctx context.Context, rawToken string) (*TokenInfo, error) {
hash := sha256.Sum256([]byte(rawToken))
tokenHash := hex.EncodeToString(hash[:])
t := &TokenInfo{}
var isActive bool
err := r.db.pool.QueryRow(ctx, `
SELECT t.id, t.user_id, t.name, t.token_prefix, t.scopes,
t.last_used_at, t.expires_at, t.created_at, u.is_active
FROM api_tokens t
JOIN users u ON u.id = t.user_id
WHERE t.token_hash = $1
`, tokenHash).Scan(
&t.ID, &t.UserID, &t.Name, &t.TokenPrefix, &t.Scopes,
&t.LastUsedAt, &t.ExpiresAt, &t.CreatedAt, &isActive,
)
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("invalid token")
}
if err != nil {
return nil, fmt.Errorf("validating token: %w", err)
}
if !isActive {
return nil, fmt.Errorf("user account is disabled")
}
if t.ExpiresAt != nil && t.ExpiresAt.Before(time.Now()) {
return nil, fmt.Errorf("token has expired")
}
return t, nil
}
// ListByUser returns all tokens for a user (without hashes).
func (r *TokenRepository) ListByUser(ctx context.Context, userID string) ([]*TokenInfo, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT id, user_id, name, token_prefix, scopes, last_used_at, expires_at, created_at
FROM api_tokens
WHERE user_id = $1
ORDER BY created_at DESC
`, userID)
if err != nil {
return nil, fmt.Errorf("listing tokens: %w", err)
}
defer rows.Close()
var tokens []*TokenInfo
for rows.Next() {
t := &TokenInfo{}
if err := rows.Scan(
&t.ID, &t.UserID, &t.Name, &t.TokenPrefix, &t.Scopes,
&t.LastUsedAt, &t.ExpiresAt, &t.CreatedAt,
); err != nil {
return nil, err
}
tokens = append(tokens, t)
}
return tokens, rows.Err()
}
// Delete removes a token, ensuring it belongs to the given user.
func (r *TokenRepository) Delete(ctx context.Context, userID, tokenID string) error {
tag, err := r.db.pool.Exec(ctx, `
DELETE FROM api_tokens WHERE id = $1 AND user_id = $2
`, tokenID, userID)
if err != nil {
return fmt.Errorf("deleting token: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("token not found")
}
return nil
}
// TouchLastUsed updates the last_used_at timestamp for a token.
func (r *TokenRepository) TouchLastUsed(ctx context.Context, tokenID string) error {
_, err := r.db.pool.Exec(ctx, `
UPDATE api_tokens SET last_used_at = now() WHERE id = $1
`, tokenID)
return err
}

100
migrations/009_auth.sql Normal file
View File

@@ -0,0 +1,100 @@
-- Authentication: users, API tokens, sessions, audit log, and user tracking columns
-- Migration: 009_auth
-- Date: 2026-01
BEGIN;
--------------------------------------------------------------------------------
-- Users
--------------------------------------------------------------------------------
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
username TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
email TEXT,
password_hash TEXT, -- NULL for LDAP/OIDC-only users
auth_source TEXT NOT NULL DEFAULT 'local', -- 'local', 'ldap', 'oidc'
oidc_subject TEXT, -- Stable OIDC sub claim
role TEXT NOT NULL DEFAULT 'viewer', -- 'admin', 'editor', 'viewer'
is_active BOOLEAN NOT NULL DEFAULT true,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX idx_users_oidc_subject ON users(oidc_subject) WHERE oidc_subject IS NOT NULL;
CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_auth_source ON users(auth_source);
CREATE INDEX idx_users_role ON users(role);
--------------------------------------------------------------------------------
-- API Tokens
--------------------------------------------------------------------------------
CREATE TABLE api_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL, -- Human-readable label
token_hash TEXT UNIQUE NOT NULL, -- SHA-256 of raw token
token_prefix TEXT NOT NULL, -- First 13 chars for display (silo_ + 8 hex)
scopes TEXT[] NOT NULL DEFAULT '{}', -- Reserved for future fine-grained permissions
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ, -- NULL = never expires
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_api_tokens_user ON api_tokens(user_id);
CREATE INDEX idx_api_tokens_hash ON api_tokens(token_hash);
--------------------------------------------------------------------------------
-- Sessions (schema required by alexedwards/scs pgxstore)
--------------------------------------------------------------------------------
CREATE TABLE sessions (
token TEXT PRIMARY KEY,
data BYTEA NOT NULL,
expiry TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_sessions_expiry ON sessions(expiry);
--------------------------------------------------------------------------------
-- Audit Log
--------------------------------------------------------------------------------
CREATE TABLE audit_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
username TEXT NOT NULL,
action TEXT NOT NULL, -- 'create', 'update', 'delete', 'login', etc.
resource_type TEXT NOT NULL, -- 'item', 'revision', 'project', 'relationship'
resource_id TEXT NOT NULL,
details JSONB,
ip_address TEXT
);
CREATE INDEX idx_audit_log_timestamp ON audit_log(timestamp DESC);
CREATE INDEX idx_audit_log_user ON audit_log(username);
CREATE INDEX idx_audit_log_resource ON audit_log(resource_type, resource_id);
--------------------------------------------------------------------------------
-- Add user tracking columns to existing tables
--------------------------------------------------------------------------------
-- Items: track who created and last updated
ALTER TABLE items ADD COLUMN created_by TEXT;
ALTER TABLE items ADD COLUMN updated_by TEXT;
-- Relationships/BOM: track who created and last updated
ALTER TABLE relationships ADD COLUMN created_by TEXT;
ALTER TABLE relationships ADD COLUMN updated_by TEXT;
-- Projects: track who created
ALTER TABLE projects ADD COLUMN created_by TEXT;
-- Sync log: track who triggered the sync
ALTER TABLE sync_log ADD COLUMN triggered_by TEXT;
COMMIT;

View File

@@ -40,6 +40,15 @@ def _get_api_url() -> str:
return url
def _get_api_token() -> str:
"""Get Silo API token from preferences, falling back to env var."""
param = FreeCAD.ParamGet(_PREF_GROUP)
token = param.GetString("ApiToken", "")
if not token:
token = os.environ.get("SILO_API_TOKEN", "")
return token
def _get_ssl_verify() -> bool:
"""Get SSL verification setting from preferences."""
param = FreeCAD.ParamGet(_PREF_GROUP)
@@ -306,6 +315,9 @@ class SiloClient:
"""Make HTTP request to Silo API."""
url = f"{self.base_url}{path}"
headers = {"Content-Type": "application/json"}
token = _get_api_token()
if token:
headers["Authorization"] = f"Bearer {token}"
body = json.dumps(data).encode() if data else None
req = urllib.request.Request(url, data=body, headers=headers, method=method)
@@ -322,6 +334,9 @@ class SiloClient:
"""Download a file from MinIO storage."""
url = f"{self.base_url}/items/{part_number}/file/{revision}"
req = urllib.request.Request(url, method="GET")
token = _get_api_token()
if token:
req.add_header("Authorization", f"Bearer {token}")
try:
with urllib.request.urlopen(req, context=_get_ssl_context()) as resp:
@@ -383,6 +398,9 @@ class SiloClient:
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Content-Length": str(len(body)),
}
token = _get_api_token()
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
try: