diff --git a/cmd/silo/main.go b/cmd/silo/main.go index 7567cb2..546ceb3 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -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 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 [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 [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 ") + 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", diff --git a/cmd/silod/main.go b/cmd/silod/main.go index 6c1d0fc..2eb20e9 100644 --- a/cmd/silod/main.go +++ b/cmd/silod/main.go @@ -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 diff --git a/config.example.yaml b/config.example.yaml index cd16777..be667ba 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -11,14 +11,14 @@ database: port: 5432 name: "silo" user: "silo" - password: "" # Use SILO_DB_PASSWORD env var + password: "" # Use SILO_DB_PASSWORD env var sslmode: "require" max_connections: 10 storage: endpoint: "minio.kindred.internal:9000" - access_key: "" # Use SILO_MINIO_ACCESS_KEY env var - secret_key: "" # Use SILO_MINIO_SECRET_KEY env var + access_key: "" # Use SILO_MINIO_ACCESS_KEY env var + secret_key: "" # Use SILO_MINIO_SECRET_KEY env var bucket: "silo-files" use_ssl: true region: "us-east-1" @@ -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" diff --git a/deployments/config.prod.yaml b/deployments/config.prod.yaml index 56d0ced..469f1a9 100644 --- a/deployments/config.prod.yaml +++ b/deployments/config.prod.yaml @@ -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" diff --git a/deployments/docker-compose.yaml b/deployments/docker-compose.yaml index cffe1ed..3a6167e 100644 --- a/deployments/docker-compose.yaml +++ b/deployments/docker-compose.yaml @@ -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: diff --git a/docs/AUTH.md b/docs/AUTH.md new file mode 100644 index 0000000..a5f6317 --- /dev/null +++ b/docs/AUTH.md @@ -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_ -> 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= +``` + +## 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 diff --git a/docs/AUTH_MIDDLEWARE.md b/docs/AUTH_MIDDLEWARE.md new file mode 100644 index 0000000..f1515c4 --- /dev/null +++ b/docs/AUTH_MIDDLEWARE.md @@ -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=` + +## 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"` 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 +``` diff --git a/docs/AUTH_USER_GUIDE.md b/docs/AUTH_USER_GUIDE.md new file mode 100644 index 0000000..6968700 --- /dev/null +++ b/docs/AUTH_USER_GUIDE.md @@ -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_ + +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 +``` + +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= +``` + +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_` + +### 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 = '', 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` diff --git a/go.mod b/go.mod index 2e94f73..45aad72 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index ab59b7b..a3f51f7 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/api/auth_handlers.go b/internal/api/auth_handlers.go new file mode 100644 index 0000000..99f7ab1 --- /dev/null +++ b/internal/api/auth_handlers.go @@ -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 +} diff --git a/internal/api/bom_handlers.go b/internal/api/bom_handlers.go index a9fdae6..e9d7315 100644 --- a/internal/api/bom_handlers.go +++ b/internal/api/bom_handlers.go @@ -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++ diff --git a/internal/api/csv.go b/internal/api/csv.go index ffd1299..8ce63a8 100644 --- a/internal/api/csv.go +++ b/internal/api/csv.go @@ -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{ diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 8f5910e..bc8103a 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -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") diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 28e0dc1..d6e8012 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -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/") +} diff --git a/internal/api/routes.go b/internal/api/routes.go index 8285f74..9bd5e58 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -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,119 +14,170 @@ 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 - r.Get("/", webHandler.HandleIndex) - r.Get("/projects", webHandler.HandleProjectsPage) - r.Get("/schemas", webHandler.HandleSchemasPage) + // 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) - // API routes + // 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 (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.Route("/{name}/segments/{segment}/values", func(r chi.Router) { - r.Post("/", server.HandleAddSchemaValue) - r.Put("/{code}", server.HandleUpdateSchemaValue) - r.Delete("/{code}", server.HandleDeleteSchemaValue) + 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 + // Projects (read: viewer, write: editor) 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) + + 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 + // 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.Post("/bom/import", server.HandleImportBOMCSV) - r.Put("/bom/{childPartNumber}", server.HandleUpdateBOMEntry) - r.Delete("/bom/{childPartNumber}", server.HandleDeleteBOMEntry) + + 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.Post("/test-connection", server.HandleTestOdooConnection) - r.Post("/sync/push/{partNumber}", server.HandleOdooPush) - r.Post("/sync/pull/{odooId}", server.HandleOdooPull) + + 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 - r.Post("/generate-part-number", server.HandleGeneratePartNumber) + // 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 diff --git a/internal/api/templates/base.html b/internal/api/templates/base.html index c363f05..5e9b65c 100644 --- a/internal/api/templates/base.html +++ b/internal/api/templates/base.html @@ -482,7 +482,20 @@ Items Projects Schemas + Settings + {{if .User}} +
+ {{.User.DisplayName}} + {{.User.Role}} +
+ +
+
+ {{end}}
@@ -492,6 +505,8 @@ {{template "projects_content" .}} {{else if eq .Page "schemas"}} {{template "schemas_content" .}} + {{else if eq .Page "settings"}} + {{template "settings_content" .}} {{end}}
@@ -501,6 +516,8 @@ {{template "projects_scripts" .}} {{else if eq .Page "schemas"}} {{template "schemas_scripts" .}} + {{else if eq .Page "settings"}} + {{template "settings_scripts" .}} {{end}} diff --git a/internal/api/templates/login.html b/internal/api/templates/login.html new file mode 100644 index 0000000..6c20fd3 --- /dev/null +++ b/internal/api/templates/login.html @@ -0,0 +1,222 @@ + + + + + + Login - Silo + + + + + + diff --git a/internal/api/templates/settings.html b/internal/api/templates/settings.html new file mode 100644 index 0000000..e495731 --- /dev/null +++ b/internal/api/templates/settings.html @@ -0,0 +1,291 @@ +{{define "settings_content"}} + + +
+
+
+

Account

+
+ {{if .User}} +
+
Username
+
{{.User.Username}}
+
Display Name
+
{{.User.DisplayName}}
+
Email
+
+ {{if .User.Email}}{{.User.Email}}{{else}}Not set{{end}} +
+
Auth Source
+
{{.User.AuthSource}}
+
Role
+
+ {{.User.Role}} +
+
+ {{end}} +
+
+ +
+
+
+

API Tokens

+
+

+ API tokens allow the FreeCAD plugin and scripts to authenticate with + Silo. Tokens inherit your role permissions. +

+ + {{if and .Data (index .Data "new_token")}} {{if ne (index .Data + "new_token") ""}} +
+

Your new API token (copy it now — it won't be shown again):

+ {{index .Data "new_token"}} + +

+ Store this token securely. You will not be able to see it again. +

+
+ {{end}} {{end}} + +
+ +
+ + +
+ +
+ +
+ + + + + + + + + + + + +
NamePrefixCreatedLast UsedExpiresActions
+
+
+
+{{end}} {{define "settings_scripts"}} + +{{end}} diff --git a/internal/api/web.go b/internal/api/web.go index bdd64a3..a923613 100644 --- a/internal/api/web.go +++ b/internal/api/web.go @@ -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,58 +17,49 @@ 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. 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) - } + Title string + Page string + Data any + 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 } data := PageData{ - Title: "Items", - Page: "items", + Title: "Items", + Page: "items", + User: auth.UserFromContext(r.Context()), + CSRFToken: nosurf.Token(r), } w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -80,8 +72,10 @@ func (h *WebHandler) HandleIndex(w http.ResponseWriter, r *http.Request) { // HandleProjectsPage serves the projects page. func (h *WebHandler) HandleProjectsPage(w http.ResponseWriter, r *http.Request) { data := PageData{ - Title: "Projects", - Page: "projects", + Title: "Projects", + Page: "projects", + User: auth.UserFromContext(r.Context()), + CSRFToken: nosurf.Token(r), } w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -94,8 +88,10 @@ func (h *WebHandler) HandleProjectsPage(w http.ResponseWriter, r *http.Request) // HandleSchemasPage serves the schemas page. func (h *WebHandler) HandleSchemasPage(w http.ResponseWriter, r *http.Request) { data := PageData{ - Title: "Schemas", - Page: "schemas", + Title: "Schemas", + Page: "schemas", + User: auth.UserFromContext(r.Context()), + CSRFToken: nosurf.Token(r), } w.Header().Set("Content-Type", "text/html; charset=utf-8") diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..d714f17 --- /dev/null +++ b/internal/auth/auth.go @@ -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 +} diff --git a/internal/auth/ldap.go b/internal/auth/ldap.go new file mode 100644 index 0000000..edb19d4 --- /dev/null +++ b/internal/auth/ldap.go @@ -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 +} diff --git a/internal/auth/local.go b/internal/auth/local.go new file mode 100644 index 0000000..fa8cd9b --- /dev/null +++ b/internal/auth/local.go @@ -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 +} diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go new file mode 100644 index 0000000..633926f --- /dev/null +++ b/internal/auth/oidc.go @@ -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 +} diff --git a/internal/auth/token.go b/internal/auth/token.go new file mode 100644 index 0000000..ab68625 --- /dev/null +++ b/internal/auth/token.go @@ -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) +} diff --git a/internal/config/config.go b/internal/config/config.go index 487d38a..4b38ec3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/db/items.go b/internal/db/items.go index ef23788..0b2103f 100644 --- a/internal/db/items.go +++ b/internal/db/items.go @@ -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) } diff --git a/internal/db/projects.go b/internal/db/projects.go index f428c51..8c47d91 100644 --- a/internal/db/projects.go +++ b/internal/db/projects.go @@ -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. diff --git a/internal/db/relationships.go b/internal/db/relationships.go index 5b70225..37ce28d 100644 --- a/internal/db/relationships.go +++ b/internal/db/relationships.go @@ -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) diff --git a/internal/db/users.go b/internal/db/users.go new file mode 100644 index 0000000..9880da5 --- /dev/null +++ b/internal/db/users.go @@ -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 +} diff --git a/migrations/009_auth.sql b/migrations/009_auth.sql new file mode 100644 index 0000000..f98becc --- /dev/null +++ b/migrations/009_auth.sql @@ -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; diff --git a/pkg/freecad/silo_commands.py b/pkg/freecad/silo_commands.py index 1c18809..b63de3b 100644 --- a/pkg/freecad/silo_commands.py +++ b/pkg/freecad/silo_commands.py @@ -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: