feat(auth): add authentication, RBAC, API tokens, and default admin
Add a complete authentication and authorization system to Silo with three pluggable backends (local bcrypt, LDAP/FreeIPA, OIDC/Keycloak), session management, API token support, and role-based access control. Authentication backends: - Local: bcrypt (cost 12) password verification against users table - LDAP: FreeIPA simple bind with group-to-role mapping - OIDC: Keycloak redirect flow with realm role mapping - Backends are tried in order; users upserted to DB on first login Session and token management: - PostgreSQL-backed sessions via alexedwards/scs + pgxstore - Opaque API tokens (silo_ prefix, SHA-256 hashed, shown once) - 24h session lifetime, HttpOnly/SameSite=Lax/Secure cookies Role-based access control (admin > editor > viewer): - RequireAuth middleware: Bearer token -> session -> redirect/401 - RequireRole middleware: per-route-group minimum role enforcement - CSRF protection via justinas/nosurf on web forms, API exempt - CORS locked to configured origins when auth enabled Route restructuring: - Public: /health, /ready, /login, /auth/oidc, /auth/callback - Web (auth + CSRF): /, /projects, /schemas, /settings - API read (viewer): GET /api/** - API write (editor): POST/PUT/PATCH/DELETE /api/** User context wiring: - created_by/updated_by columns on items, projects, relationships - All create/update handlers populate tracking fields from context - CSV and BOM import handlers pass authenticated username - Revision creation tracks user across all code paths Default admin account: - Configurable via auth.local.default_admin_username/password - Env var overrides: SILO_ADMIN_USERNAME, SILO_ADMIN_PASSWORD - Idempotent: created on first startup, skipped if exists CLI and FreeCAD plugin: - silo token create/list/revoke subcommands (HTTP API client) - FreeCAD SiloClient sends Bearer token on all requests - Token read from ApiToken preference or SILO_API_TOKEN env var Web UI: - Login page (Catppuccin Mocha themed, OIDC button conditional) - Settings page with account info and API token management - User display name, role badge, and logout button in header - One-time token display banner with copy-to-clipboard Database (migration 009): - users table with role, auth_source, oidc_subject, password_hash - api_tokens table with SHA-256 hash, prefix, expiry, scopes - sessions table (scs pgxstore schema) - audit_log table (schema ready for future use) - created_by/updated_by ALTER on items, relationships, projects New dependencies: scs/v2, scs/pgxstore, go-oidc/v3, go-ldap/v3, justinas/nosurf, golang.org/x/oauth2
This commit is contained in:
177
cmd/silo/main.go
177
cmd/silo/main.go
@@ -2,9 +2,12 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/kindredsystems/silo/internal/config"
|
"github.com/kindredsystems/silo/internal/config"
|
||||||
@@ -35,6 +38,8 @@ func main() {
|
|||||||
cmdRevisions(ctx)
|
cmdRevisions(ctx)
|
||||||
case "schemas":
|
case "schemas":
|
||||||
cmdSchemas(ctx)
|
cmdSchemas(ctx)
|
||||||
|
case "token":
|
||||||
|
cmdToken(ctx)
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", cmd)
|
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", cmd)
|
||||||
printUsage()
|
printUsage()
|
||||||
@@ -53,12 +58,25 @@ Commands:
|
|||||||
show Show item details
|
show Show item details
|
||||||
revisions Show item revision history
|
revisions Show item revision history
|
||||||
schemas List available schemas
|
schemas List available schemas
|
||||||
|
token Manage API tokens (create, list, revoke)
|
||||||
|
|
||||||
|
Token subcommands:
|
||||||
|
silo token create --name "label" Create a new API token
|
||||||
|
silo token list List your API tokens
|
||||||
|
silo token revoke <id> Revoke a token
|
||||||
|
|
||||||
|
Environment variables for API access:
|
||||||
|
SILO_API_URL Base URL of the Silo server (e.g., https://silo.kindred.internal)
|
||||||
|
SILO_API_TOKEN API token for authentication
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
silo register --schema kindred-rd --project PROTO --type AS
|
silo register --schema kindred-rd --project PROTO --type AS
|
||||||
silo list --type assembly
|
silo list --type assembly
|
||||||
silo show PROTO-AS-0001
|
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 {
|
func loadConfig() *config.Config {
|
||||||
@@ -295,6 +313,163 @@ func getSchemaID(ctx context.Context, database *db.DB, name string) *string {
|
|||||||
return &id
|
return &id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// apiClient returns the base URL and an *http.Client with the Bearer token set.
|
||||||
|
func apiRequest(method, path string, body any) (*http.Response, error) {
|
||||||
|
baseURL := os.Getenv("SILO_API_URL")
|
||||||
|
if baseURL == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "SILO_API_URL environment variable is required for token commands")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
token := os.Getenv("SILO_API_TOKEN")
|
||||||
|
if token == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "SILO_API_TOKEN environment variable is required for token commands")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var reqBody io.Reader
|
||||||
|
if body != nil {
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshaling request body: %w", err)
|
||||||
|
}
|
||||||
|
reqBody = bytes.NewReader(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(method, baseURL+path, reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.DefaultClient.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdToken(_ context.Context) {
|
||||||
|
if len(os.Args) < 3 {
|
||||||
|
fmt.Fprintln(os.Stderr, "Usage: silo token <create|list|revoke> [options]")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
subcmd := os.Args[2]
|
||||||
|
switch subcmd {
|
||||||
|
case "create":
|
||||||
|
cmdTokenCreate()
|
||||||
|
case "list":
|
||||||
|
cmdTokenList()
|
||||||
|
case "revoke":
|
||||||
|
cmdTokenRevoke()
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "Unknown token subcommand: %s\n", subcmd)
|
||||||
|
fmt.Fprintln(os.Stderr, "Usage: silo token <create|list|revoke> [options]")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdTokenCreate() {
|
||||||
|
var name string
|
||||||
|
args := os.Args[3:]
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--name", "-n":
|
||||||
|
if i+1 < len(args) {
|
||||||
|
i++
|
||||||
|
name = args[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "Usage: silo token create --name \"label\"")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := apiRequest("POST", "/api/auth/tokens", map[string]any{"name": name})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
fmt.Fprintf(os.Stderr, "Error creating token (%d): %s\n", resp.StatusCode, string(body))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]any
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error decoding response: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Token created: %s\n", result["name"])
|
||||||
|
fmt.Printf("API Token: %s\n", result["token"])
|
||||||
|
fmt.Println("Save this token — it will not be shown again.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdTokenList() {
|
||||||
|
resp, err := apiRequest("GET", "/api/auth/tokens", nil)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
fmt.Fprintf(os.Stderr, "Error listing tokens (%d): %s\n", resp.StatusCode, string(body))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokens []map[string]any
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error decoding response: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
fmt.Println("No API tokens.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%-36s %-20s %-15s %s\n", "ID", "NAME", "PREFIX", "CREATED")
|
||||||
|
for _, t := range tokens {
|
||||||
|
id, _ := t["id"].(string)
|
||||||
|
name, _ := t["name"].(string)
|
||||||
|
prefix, _ := t["token_prefix"].(string)
|
||||||
|
created, _ := t["created_at"].(string)
|
||||||
|
if len(created) > 10 {
|
||||||
|
created = created[:10]
|
||||||
|
}
|
||||||
|
fmt.Printf("%-36s %-20s %-15s %s\n", id, name, prefix+"...", created)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdTokenRevoke() {
|
||||||
|
if len(os.Args) < 4 {
|
||||||
|
fmt.Fprintln(os.Stderr, "Usage: silo token revoke <token-id>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
tokenID := os.Args[3]
|
||||||
|
|
||||||
|
resp, err := apiRequest("DELETE", "/api/auth/tokens/"+tokenID, nil)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
fmt.Fprintf(os.Stderr, "Error revoking token (%d): %s\n", resp.StatusCode, string(body))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Token revoked.")
|
||||||
|
}
|
||||||
|
|
||||||
func mapPartType(code string) string {
|
func mapPartType(code string) string {
|
||||||
types := map[string]string{
|
types := map[string]string{
|
||||||
"AS": "assembly",
|
"AS": "assembly",
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/alexedwards/scs/pgxstore"
|
||||||
|
"github.com/alexedwards/scs/v2"
|
||||||
"github.com/kindredsystems/silo/internal/api"
|
"github.com/kindredsystems/silo/internal/api"
|
||||||
|
"github.com/kindredsystems/silo/internal/auth"
|
||||||
"github.com/kindredsystems/silo/internal/config"
|
"github.com/kindredsystems/silo/internal/config"
|
||||||
"github.com/kindredsystems/silo/internal/db"
|
"github.com/kindredsystems/silo/internal/db"
|
||||||
"github.com/kindredsystems/silo/internal/schema"
|
"github.com/kindredsystems/silo/internal/schema"
|
||||||
@@ -85,8 +88,99 @@ func main() {
|
|||||||
}
|
}
|
||||||
logger.Info().Int("count", len(schemas)).Msg("loaded schemas")
|
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
|
// 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)
|
router := api.NewRouter(server, logger)
|
||||||
|
|
||||||
// Create HTTP server
|
// Create HTTP server
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ database:
|
|||||||
port: 5432
|
port: 5432
|
||||||
name: "silo"
|
name: "silo"
|
||||||
user: "silo"
|
user: "silo"
|
||||||
password: "" # Use SILO_DB_PASSWORD env var
|
password: "" # Use SILO_DB_PASSWORD env var
|
||||||
sslmode: "require"
|
sslmode: "require"
|
||||||
max_connections: 10
|
max_connections: 10
|
||||||
|
|
||||||
storage:
|
storage:
|
||||||
endpoint: "minio.kindred.internal:9000"
|
endpoint: "minio.kindred.internal:9000"
|
||||||
access_key: "" # Use SILO_MINIO_ACCESS_KEY env var
|
access_key: "" # Use SILO_MINIO_ACCESS_KEY env var
|
||||||
secret_key: "" # Use SILO_MINIO_SECRET_KEY env var
|
secret_key: "" # Use SILO_MINIO_SECRET_KEY env var
|
||||||
bucket: "silo-files"
|
bucket: "silo-files"
|
||||||
use_ssl: true
|
use_ssl: true
|
||||||
region: "us-east-1"
|
region: "us-east-1"
|
||||||
@@ -35,8 +35,58 @@ freecad:
|
|||||||
# Path to FreeCAD executable (for CLI operations)
|
# Path to FreeCAD executable (for CLI operations)
|
||||||
executable: "/usr/bin/freecad"
|
executable: "/usr/bin/freecad"
|
||||||
|
|
||||||
# Future: LDAP authentication
|
# Authentication
|
||||||
# auth:
|
# Set enabled: true to require login. When false, all routes are open
|
||||||
# provider: "ldap"
|
# with a synthetic "dev" user (admin role).
|
||||||
# server: "ldaps://ipa.kindred.internal"
|
auth:
|
||||||
# base_dn: "dc=kindred,dc=internal"
|
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"
|
||||||
|
|||||||
@@ -35,3 +35,48 @@ schemas:
|
|||||||
freecad:
|
freecad:
|
||||||
uri_scheme: "silo"
|
uri_scheme: "silo"
|
||||||
executable: "/usr/bin/freecad"
|
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"
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ services:
|
|||||||
SILO_MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-silominiosecret}
|
SILO_MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-silominiosecret}
|
||||||
SILO_MINIO_BUCKET: silo-files
|
SILO_MINIO_BUCKET: silo-files
|
||||||
SILO_MINIO_USE_SSL: "false"
|
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:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
226
docs/AUTH.md
Normal file
226
docs/AUTH.md
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
# Silo Authentication Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Silo supports three authentication backends that can be enabled independently or in combination:
|
||||||
|
|
||||||
|
| Backend | Use Case | Config Key |
|
||||||
|
|---------|----------|------------|
|
||||||
|
| **Local** | Username/password stored in Silo's database (bcrypt) | `auth.local` |
|
||||||
|
| **LDAP** | FreeIPA / Active Directory via LDAP bind | `auth.ldap` |
|
||||||
|
| **OIDC** | Keycloak or any OpenID Connect provider (redirect flow) | `auth.oidc` |
|
||||||
|
|
||||||
|
When authentication is disabled (`auth.enabled: false`), all routes are open and a synthetic developer user with the `admin` role is injected into every request.
|
||||||
|
|
||||||
|
## Authentication Flow
|
||||||
|
|
||||||
|
### Browser (Session-Based)
|
||||||
|
|
||||||
|
```
|
||||||
|
User -> /login (GET) -> Renders login form
|
||||||
|
User -> /login (POST) -> Validates credentials via backends
|
||||||
|
-> Creates server-side session (PostgreSQL)
|
||||||
|
-> Sets silo_session cookie
|
||||||
|
-> Redirects to / or ?next= URL
|
||||||
|
|
||||||
|
User -> /auth/oidc (GET) -> Generates state, stores in session
|
||||||
|
-> Redirects to Keycloak authorize endpoint
|
||||||
|
Keycloak -> /auth/callback -> Verifies state, exchanges code for token
|
||||||
|
-> Extracts claims, upserts user in DB
|
||||||
|
-> Creates session, redirects to /
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Client (Token-Based)
|
||||||
|
|
||||||
|
```
|
||||||
|
Client -> Authorization: Bearer silo_<hex> -> SHA-256 hash lookup
|
||||||
|
-> Validates expiry + user active
|
||||||
|
-> Injects user into context
|
||||||
|
```
|
||||||
|
|
||||||
|
Both paths converge at the `RequireAuth` middleware, which injects an `auth.User` into the request context. All downstream handlers use `auth.UserFromContext(ctx)` to access the authenticated user.
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### users
|
||||||
|
|
||||||
|
The single identity table that all backends resolve to. LDAP and OIDC users are upserted on first login.
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `id` | UUID | Primary key |
|
||||||
|
| `username` | TEXT | Unique. FreeIPA `uid` or OIDC `preferred_username` |
|
||||||
|
| `display_name` | TEXT | Human-readable name |
|
||||||
|
| `email` | TEXT | Not unique-constrained |
|
||||||
|
| `password_hash` | TEXT | NULL for LDAP/OIDC-only users (bcrypt cost 12) |
|
||||||
|
| `auth_source` | TEXT | `local`, `ldap`, or `oidc` |
|
||||||
|
| `oidc_subject` | TEXT | Stable OIDC `sub` claim (unique partial index) |
|
||||||
|
| `role` | TEXT | `admin`, `editor`, or `viewer` |
|
||||||
|
| `is_active` | BOOLEAN | Deactivated users are rejected at middleware |
|
||||||
|
| `last_login_at` | TIMESTAMPTZ | Updated on each successful login |
|
||||||
|
| `created_at` | TIMESTAMPTZ | |
|
||||||
|
| `updated_at` | TIMESTAMPTZ | |
|
||||||
|
|
||||||
|
### api_tokens
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `id` | UUID | Primary key |
|
||||||
|
| `user_id` | UUID | FK to users, CASCADE on delete |
|
||||||
|
| `name` | TEXT | Human-readable label |
|
||||||
|
| `token_hash` | TEXT | SHA-256 of raw token (unique) |
|
||||||
|
| `token_prefix` | TEXT | `silo_` + 8 hex chars for display |
|
||||||
|
| `scopes` | TEXT[] | Reserved for future fine-grained permissions |
|
||||||
|
| `last_used_at` | TIMESTAMPTZ | Updated asynchronously on use |
|
||||||
|
| `expires_at` | TIMESTAMPTZ | NULL = never expires |
|
||||||
|
| `created_at` | TIMESTAMPTZ | |
|
||||||
|
|
||||||
|
Raw token format: `silo_` + 64 hex characters (32 random bytes). Shown once at creation. Only the SHA-256 hash is stored.
|
||||||
|
|
||||||
|
### sessions
|
||||||
|
|
||||||
|
Required by `alexedwards/scs` pgxstore:
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `token` | TEXT | Primary key (session ID) |
|
||||||
|
| `data` | BYTEA | Serialized session data |
|
||||||
|
| `expiry` | TIMESTAMPTZ | Indexed for cleanup |
|
||||||
|
|
||||||
|
Session data contains `user_id` and `username`. Cookie: `silo_session`, HttpOnly, SameSite=Lax, 24h lifetime. `Secure` flag is set when `auth.enabled` is true.
|
||||||
|
|
||||||
|
### audit_log
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `id` | UUID | Primary key |
|
||||||
|
| `timestamp` | TIMESTAMPTZ | Indexed DESC |
|
||||||
|
| `user_id` | UUID | FK to users, SET NULL on delete |
|
||||||
|
| `username` | TEXT | Preserved after user deletion |
|
||||||
|
| `action` | TEXT | `create`, `update`, `delete`, `login`, etc. |
|
||||||
|
| `resource_type` | TEXT | `item`, `revision`, `project`, `relationship` |
|
||||||
|
| `resource_id` | TEXT | |
|
||||||
|
| `details` | JSONB | Arbitrary structured data |
|
||||||
|
| `ip_address` | TEXT | |
|
||||||
|
|
||||||
|
### User Tracking Columns
|
||||||
|
|
||||||
|
Migration 009 adds `created_by` and `updated_by` TEXT columns to:
|
||||||
|
|
||||||
|
- `items` (created_by, updated_by)
|
||||||
|
- `relationships` (created_by, updated_by)
|
||||||
|
- `projects` (created_by)
|
||||||
|
- `sync_log` (triggered_by)
|
||||||
|
|
||||||
|
These store the `username` string (not a foreign key) so audit records survive user deletion and dev mode uses `"dev"`.
|
||||||
|
|
||||||
|
## Role Model
|
||||||
|
|
||||||
|
Three roles with a strict hierarchy:
|
||||||
|
|
||||||
|
```
|
||||||
|
admin > editor > viewer
|
||||||
|
```
|
||||||
|
|
||||||
|
| Permission | viewer | editor | admin |
|
||||||
|
|-----------|--------|--------|-------|
|
||||||
|
| Read items, projects, schemas, BOMs | Yes | Yes | Yes |
|
||||||
|
| Create/update items and revisions | No | Yes | Yes |
|
||||||
|
| Upload files | No | Yes | Yes |
|
||||||
|
| Manage BOMs | No | Yes | Yes |
|
||||||
|
| Create/update projects | No | Yes | Yes |
|
||||||
|
| Import CSV / BOM CSV | No | Yes | Yes |
|
||||||
|
| Generate part numbers | No | Yes | Yes |
|
||||||
|
| Manage own API tokens | Yes | Yes | Yes |
|
||||||
|
| User management (future) | No | No | Yes |
|
||||||
|
|
||||||
|
Role enforcement uses `auth.RoleSatisfies(userRole, minimumRole)` which checks the hierarchy. A user with `admin` satisfies any minimum role.
|
||||||
|
|
||||||
|
### Role Mapping from External Sources
|
||||||
|
|
||||||
|
**LDAP/FreeIPA**: Mapped from group membership. Checked in priority order (admin > editor > viewer). First match wins.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ldap:
|
||||||
|
role_mapping:
|
||||||
|
admin:
|
||||||
|
- "cn=silo-admins,cn=groups,cn=accounts,dc=kindred,dc=internal"
|
||||||
|
editor:
|
||||||
|
- "cn=silo-users,cn=groups,cn=accounts,dc=kindred,dc=internal"
|
||||||
|
- "cn=engineers,cn=groups,cn=accounts,dc=kindred,dc=internal"
|
||||||
|
viewer:
|
||||||
|
- "cn=silo-viewers,cn=groups,cn=accounts,dc=kindred,dc=internal"
|
||||||
|
```
|
||||||
|
|
||||||
|
**OIDC/Keycloak**: Mapped from `realm_access.roles` in the ID token.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
oidc:
|
||||||
|
admin_role: "silo-admin"
|
||||||
|
editor_role: "silo-editor"
|
||||||
|
default_role: "viewer"
|
||||||
|
```
|
||||||
|
|
||||||
|
Roles are re-evaluated on every external login. If an LDAP user's group membership changes in FreeIPA, their Silo role updates on their next login.
|
||||||
|
|
||||||
|
## User Lifecycle
|
||||||
|
|
||||||
|
| Event | What Happens |
|
||||||
|
|-------|-------------|
|
||||||
|
| First LDAP login | User row created with `auth_source=ldap`, role from group mapping |
|
||||||
|
| First OIDC login | User row created with `auth_source=oidc`, `oidc_subject` set |
|
||||||
|
| Subsequent external login | `display_name`, `email`, `role` updated; `last_login_at` updated |
|
||||||
|
| Local account creation | Admin creates user with username, password, role |
|
||||||
|
| Deactivation | `is_active=false` — sessions and tokens rejected at middleware |
|
||||||
|
| Password change | Only for `auth_source=local` users |
|
||||||
|
|
||||||
|
## Default Admin Account
|
||||||
|
|
||||||
|
On startup, if `auth.local.default_admin_username` and `auth.local.default_admin_password` are both set, the daemon checks for an existing user with that username. If none exists, it creates a local admin account. This is idempotent — subsequent startups skip creation.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
local:
|
||||||
|
enabled: true
|
||||||
|
default_admin_username: "admin"
|
||||||
|
default_admin_password: "" # Use SILO_ADMIN_PASSWORD env var
|
||||||
|
```
|
||||||
|
|
||||||
|
Set via environment variables for production:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export SILO_ADMIN_USERNAME=admin
|
||||||
|
export SILO_ADMIN_PASSWORD=<strong-password>
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Token Security
|
||||||
|
|
||||||
|
- Raw token: `silo_` + 64 hex characters (32 bytes of `crypto/rand`)
|
||||||
|
- Storage: SHA-256 hash only — the raw token cannot be recovered from the database
|
||||||
|
- Display prefix: `silo_` + first 8 hex characters (for identification in UI)
|
||||||
|
- Tokens inherit the owning user's role. If the user is deactivated, all their tokens stop working
|
||||||
|
- Revocation is immediate (row deletion)
|
||||||
|
- `last_used_at` is updated asynchronously to avoid slowing down API requests
|
||||||
|
|
||||||
|
## Dev Mode
|
||||||
|
|
||||||
|
When `auth.enabled: false`:
|
||||||
|
|
||||||
|
- No login is required
|
||||||
|
- A synthetic user is injected into every request:
|
||||||
|
- Username: `dev`
|
||||||
|
- Role: `admin`
|
||||||
|
- Auth source: `local`
|
||||||
|
- `created_by` fields are set to `"dev"`
|
||||||
|
- CORS allows all origins
|
||||||
|
- Session cookies are not marked `Secure`
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- **Password hashing**: bcrypt with cost factor 12
|
||||||
|
- **Token entropy**: 256 bits (32 bytes from `crypto/rand`)
|
||||||
|
- **LDAP**: Always use `ldaps://` (TLS). `tls_skip_verify` is available but should only be used for testing
|
||||||
|
- **OIDC state parameter**: 128 bits, stored server-side in session, verified on callback
|
||||||
|
- **Session cookies**: HttpOnly, SameSite=Lax, Secure when auth enabled
|
||||||
|
- **CSRF protection**: nosurf library on all web form routes. API routes exempt (use Bearer tokens instead)
|
||||||
|
- **CORS**: Locked down to configured origins when auth is enabled. Credentials allowed for session cookies
|
||||||
185
docs/AUTH_MIDDLEWARE.md
Normal file
185
docs/AUTH_MIDDLEWARE.md
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
# Silo Auth Middleware
|
||||||
|
|
||||||
|
## Middleware Chain
|
||||||
|
|
||||||
|
Every request passes through this middleware stack in order:
|
||||||
|
|
||||||
|
```
|
||||||
|
RequestID Assigns X-Request-ID header
|
||||||
|
|
|
||||||
|
RealIP Extracts client IP from X-Forwarded-For
|
||||||
|
|
|
||||||
|
RequestLogger Logs method, path, status, duration (zerolog)
|
||||||
|
|
|
||||||
|
Recoverer Catches panics, logs stack trace, returns 500
|
||||||
|
|
|
||||||
|
CORS Validates origin, sets Access-Control headers
|
||||||
|
|
|
||||||
|
SessionLoadAndSave Loads session from PostgreSQL, saves on response
|
||||||
|
|
|
||||||
|
[route group middleware applied per-group below]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Route Groups
|
||||||
|
|
||||||
|
### Public (no auth)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /health
|
||||||
|
GET /ready
|
||||||
|
GET /login
|
||||||
|
POST /login
|
||||||
|
POST /logout
|
||||||
|
GET /auth/oidc
|
||||||
|
GET /auth/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
No authentication or CSRF middleware. The login form includes a CSRF hidden field but nosurf is not enforced on these routes (they are outside the CSRF-protected group).
|
||||||
|
|
||||||
|
### Web UI (auth + CSRF)
|
||||||
|
|
||||||
|
```
|
||||||
|
RequireAuth -> CSRFProtect -> Handler
|
||||||
|
|
||||||
|
GET / Items page
|
||||||
|
GET /projects Projects page
|
||||||
|
GET /schemas Schemas page
|
||||||
|
GET /settings Settings page (account info, tokens)
|
||||||
|
POST /settings/tokens Create API token (form)
|
||||||
|
POST /settings/tokens/{id}/revoke Revoke token (form)
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `RequireAuth` and `CSRFProtect` are applied. Form submissions must include a `csrf_token` hidden field with the value from `nosurf.Token(r)`.
|
||||||
|
|
||||||
|
### API (auth, no CSRF)
|
||||||
|
|
||||||
|
```
|
||||||
|
RequireAuth -> [RequireRole where needed] -> Handler
|
||||||
|
|
||||||
|
GET /api/auth/me Current user info (viewer)
|
||||||
|
GET /api/auth/tokens List tokens (viewer)
|
||||||
|
POST /api/auth/tokens Create token (viewer)
|
||||||
|
DELETE /api/auth/tokens/{id} Revoke token (viewer)
|
||||||
|
|
||||||
|
GET /api/schemas/* Read schemas (viewer)
|
||||||
|
POST /api/schemas/*/segments/* Modify schema values (editor)
|
||||||
|
|
||||||
|
GET /api/projects/* Read projects (viewer)
|
||||||
|
POST /api/projects Create project (editor)
|
||||||
|
PUT /api/projects/{code} Update project (editor)
|
||||||
|
DELETE /api/projects/{code} Delete project (editor)
|
||||||
|
|
||||||
|
GET /api/items/* Read items (viewer)
|
||||||
|
POST /api/items Create item (editor)
|
||||||
|
PUT /api/items/{partNumber} Update item (editor)
|
||||||
|
DELETE /api/items/{partNumber} Delete item (editor)
|
||||||
|
POST /api/items/{partNumber}/file Upload file (editor)
|
||||||
|
POST /api/items/import CSV import (editor)
|
||||||
|
|
||||||
|
GET /api/items/{partNumber}/bom/* Read BOM (viewer)
|
||||||
|
POST /api/items/{partNumber}/bom Add BOM entry (editor)
|
||||||
|
PUT /api/items/{partNumber}/bom/* Update BOM entry (editor)
|
||||||
|
DELETE /api/items/{partNumber}/bom/* Delete BOM entry (editor)
|
||||||
|
|
||||||
|
POST /api/generate-part-number Generate PN (editor)
|
||||||
|
|
||||||
|
GET /api/integrations/odoo/* Read Odoo config (viewer)
|
||||||
|
PUT /api/integrations/odoo/* Modify Odoo config (editor)
|
||||||
|
POST /api/integrations/odoo/* Odoo sync operations (editor)
|
||||||
|
```
|
||||||
|
|
||||||
|
API routes are exempt from CSRF (they use Bearer token auth). CORS credentials are allowed so browser-based API clients with session cookies work.
|
||||||
|
|
||||||
|
## RequireAuth
|
||||||
|
|
||||||
|
`internal/api/middleware.go`
|
||||||
|
|
||||||
|
Authentication check order:
|
||||||
|
|
||||||
|
1. **Auth disabled?** Inject synthetic dev user (`admin` role) and continue
|
||||||
|
2. **Bearer token?** Extract from `Authorization: Bearer silo_...` header, validate via `auth.Service.ValidateToken()`. On success, inject user into context
|
||||||
|
3. **Session cookie?** Read `user_id` from `scs` session, look up user via `auth.Service.GetUserByID()`. On success, inject user into context. On stale session (user not found), destroy session
|
||||||
|
4. **None of the above?**
|
||||||
|
- API requests (`/api/*`): Return `401 Unauthorized` JSON
|
||||||
|
- Web requests: Redirect to `/login?next=<current-path>`
|
||||||
|
|
||||||
|
## RequireRole
|
||||||
|
|
||||||
|
`internal/api/middleware.go`
|
||||||
|
|
||||||
|
Applied as per-group middleware on routes that require a minimum role:
|
||||||
|
|
||||||
|
```go
|
||||||
|
r.Use(server.RequireRole(auth.RoleEditor))
|
||||||
|
```
|
||||||
|
|
||||||
|
Checks `auth.RoleSatisfies(user.Role, minimum)` against the hierarchy `admin > editor > viewer`. Returns:
|
||||||
|
|
||||||
|
- `401 Unauthorized` if no user in context (should not happen after RequireAuth)
|
||||||
|
- `403 Forbidden` with message `"Insufficient permissions: requires <role> role"` if role is too low
|
||||||
|
|
||||||
|
## CSRFProtect
|
||||||
|
|
||||||
|
`internal/api/middleware.go`
|
||||||
|
|
||||||
|
Wraps the `justinas/nosurf` library:
|
||||||
|
|
||||||
|
- Cookie: `csrf_token`, HttpOnly, SameSite=Lax, Secure when auth enabled
|
||||||
|
- Exempt paths: `/api/*`, `/health`, `/ready`
|
||||||
|
- Form field name: `csrf_token`
|
||||||
|
- Failure: Returns `403 Forbidden` with `"CSRF token validation failed"`
|
||||||
|
|
||||||
|
Templates inject the token via `{{.CSRFToken}}` which is populated from `nosurf.Token(r)`.
|
||||||
|
|
||||||
|
## CORS Configuration
|
||||||
|
|
||||||
|
Configured in `internal/api/routes.go`:
|
||||||
|
|
||||||
|
| Setting | Auth Disabled | Auth Enabled |
|
||||||
|
|---------|---------------|--------------|
|
||||||
|
| Allowed Origins | `*` | From `auth.cors.allowed_origins` config |
|
||||||
|
| Allow Credentials | `false` | `true` (needed for session cookies) |
|
||||||
|
| Allowed Methods | GET, POST, PUT, PATCH, DELETE, OPTIONS | Same |
|
||||||
|
| Allowed Headers | Accept, Authorization, Content-Type, X-CSRF-Token, X-Request-ID | Same |
|
||||||
|
| Max Age | 300 seconds | 300 seconds |
|
||||||
|
|
||||||
|
FreeCAD uses direct HTTP (not browser), so CORS does not affect it. Browser-based tools on other origins need their origin in the allowed list.
|
||||||
|
|
||||||
|
## Request Flow Examples
|
||||||
|
|
||||||
|
### Browser Login
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /projects
|
||||||
|
-> RequestLogger
|
||||||
|
-> CORS (pass)
|
||||||
|
-> Session (loads empty session)
|
||||||
|
-> RequireAuth: no token, no session user_id
|
||||||
|
-> Redirect 303 /login?next=/projects
|
||||||
|
|
||||||
|
POST /login (username=alice, password=...)
|
||||||
|
-> RequestLogger
|
||||||
|
-> CORS (pass)
|
||||||
|
-> Session (loads)
|
||||||
|
-> HandleLogin: auth.Authenticate(ctx, "alice", password)
|
||||||
|
-> Session: put user_id, renew token
|
||||||
|
-> Redirect 303 /projects
|
||||||
|
|
||||||
|
GET /projects
|
||||||
|
-> Session (loads, has user_id)
|
||||||
|
-> RequireAuth: session -> GetUserByID -> inject alice
|
||||||
|
-> CSRFProtect: GET request, passes
|
||||||
|
-> HandleProjectsPage: renders with alice's info
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Token Request
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/items
|
||||||
|
Authorization: Bearer silo_a1b2c3d4...
|
||||||
|
|
||||||
|
-> RequireAuth: Bearer token found
|
||||||
|
-> ValidateToken: SHA-256 hash lookup, check expiry, check user active
|
||||||
|
-> Inject user into context
|
||||||
|
-> HandleListItems: returns JSON
|
||||||
|
```
|
||||||
257
docs/AUTH_USER_GUIDE.md
Normal file
257
docs/AUTH_USER_GUIDE.md
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
# Silo Authentication User Guide
|
||||||
|
|
||||||
|
## Logging In
|
||||||
|
|
||||||
|
### Username and Password
|
||||||
|
|
||||||
|
Navigate to the Silo web UI. If authentication is enabled, you'll be redirected to the login page.
|
||||||
|
|
||||||
|
Enter your username and password. This works for both local accounts and LDAP/FreeIPA accounts — Silo tries local authentication first, then LDAP if configured.
|
||||||
|
|
||||||
|
### Keycloak / OIDC
|
||||||
|
|
||||||
|
If your deployment has OIDC enabled, the login page will show a "Sign in with Keycloak" button. Click it to be redirected to your identity provider. After authenticating there, you'll be redirected back to Silo with a session.
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
Your role determines what you can do in Silo:
|
||||||
|
|
||||||
|
| Role | Permissions |
|
||||||
|
|------|-------------|
|
||||||
|
| **viewer** | Read all items, projects, schemas, BOMs. Manage own API tokens. |
|
||||||
|
| **editor** | Everything a viewer can do, plus: create/update/delete items, upload files, manage BOMs, import CSV, generate part numbers. |
|
||||||
|
| **admin** | Everything an editor can do, plus: user management (future), configuration changes. |
|
||||||
|
|
||||||
|
Your role is shown as a badge next to your name in the header. For LDAP and OIDC users, the role is determined by group membership or token claims and re-evaluated on each login.
|
||||||
|
|
||||||
|
## API Tokens
|
||||||
|
|
||||||
|
API tokens allow the FreeCAD plugin, scripts, and CI pipelines to authenticate with Silo without a browser session. Tokens inherit your role.
|
||||||
|
|
||||||
|
### Creating a Token (Web UI)
|
||||||
|
|
||||||
|
1. Click **Settings** in the navigation bar
|
||||||
|
2. Under **API Tokens**, enter a name (e.g., "FreeCAD workstation") and click **Create Token**
|
||||||
|
3. The raw token is displayed once — copy it immediately
|
||||||
|
4. Store the token securely. It cannot be shown again.
|
||||||
|
|
||||||
|
### Creating a Token (CLI)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export SILO_API_URL=https://silo.kindred.internal
|
||||||
|
export SILO_API_TOKEN=silo_<your-existing-token>
|
||||||
|
|
||||||
|
silo token create --name "CI pipeline"
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
```
|
||||||
|
Token created: CI pipeline
|
||||||
|
API Token: silo_a1b2c3d4e5f6...
|
||||||
|
Save this token — it will not be shown again.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Listing Tokens
|
||||||
|
|
||||||
|
```sh
|
||||||
|
silo token list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Revoking a Token
|
||||||
|
|
||||||
|
Via the web UI settings page (click **Revoke** next to the token), or via CLI:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
silo token revoke <token-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Revocation is immediate. Any in-flight requests using the token will fail.
|
||||||
|
|
||||||
|
## FreeCAD Plugin Configuration
|
||||||
|
|
||||||
|
The FreeCAD plugin reads the API token from two sources (checked in order):
|
||||||
|
|
||||||
|
1. **FreeCAD Preferences**: `Tools > Edit parameters > BaseApp/Preferences/Mod/Silo > ApiToken`
|
||||||
|
2. **Environment variable**: `SILO_API_TOKEN`
|
||||||
|
|
||||||
|
To set the token in FreeCAD preferences:
|
||||||
|
|
||||||
|
1. Open FreeCAD
|
||||||
|
2. Go to `Edit > Preferences > General > Macro` or use the parameter editor
|
||||||
|
3. Navigate to `BaseApp/Preferences/Mod/Silo`
|
||||||
|
4. Set `ApiToken` to your token string (e.g., `silo_a1b2c3d4...`)
|
||||||
|
|
||||||
|
Or set the environment variable before launching FreeCAD:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export SILO_API_TOKEN=silo_a1b2c3d4...
|
||||||
|
freecad
|
||||||
|
```
|
||||||
|
|
||||||
|
The API URL is configured the same way via the `ApiUrl` preference or `SILO_API_URL` environment variable.
|
||||||
|
|
||||||
|
## Default Admin Account
|
||||||
|
|
||||||
|
On first deployment, configure a default admin account to bootstrap access:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# config.yaml
|
||||||
|
auth:
|
||||||
|
enabled: true
|
||||||
|
local:
|
||||||
|
enabled: true
|
||||||
|
default_admin_username: "admin"
|
||||||
|
default_admin_password: "" # Set via SILO_ADMIN_PASSWORD env var
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export SILO_ADMIN_PASSWORD=<strong-password>
|
||||||
|
```
|
||||||
|
|
||||||
|
The admin account is created on the first startup if it doesn't already exist. Subsequent startups skip creation. Change the password after first login via the database or a future admin UI.
|
||||||
|
|
||||||
|
## Configuration Reference
|
||||||
|
|
||||||
|
### Minimal (Local Auth Only)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
enabled: true
|
||||||
|
session_secret: "" # Set via SILO_SESSION_SECRET
|
||||||
|
|
||||||
|
local:
|
||||||
|
enabled: true
|
||||||
|
default_admin_username: "admin"
|
||||||
|
default_admin_password: "" # Set via SILO_ADMIN_PASSWORD
|
||||||
|
```
|
||||||
|
|
||||||
|
### LDAP / FreeIPA
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
enabled: true
|
||||||
|
session_secret: ""
|
||||||
|
|
||||||
|
local:
|
||||||
|
enabled: true
|
||||||
|
default_admin_username: "admin"
|
||||||
|
default_admin_password: ""
|
||||||
|
|
||||||
|
ldap:
|
||||||
|
enabled: true
|
||||||
|
url: "ldaps://ipa.kindred.internal"
|
||||||
|
base_dn: "dc=kindred,dc=internal"
|
||||||
|
user_search_dn: "cn=users,cn=accounts,dc=kindred,dc=internal"
|
||||||
|
user_attr: "uid"
|
||||||
|
email_attr: "mail"
|
||||||
|
display_attr: "displayName"
|
||||||
|
group_attr: "memberOf"
|
||||||
|
role_mapping:
|
||||||
|
admin:
|
||||||
|
- "cn=silo-admins,cn=groups,cn=accounts,dc=kindred,dc=internal"
|
||||||
|
editor:
|
||||||
|
- "cn=silo-users,cn=groups,cn=accounts,dc=kindred,dc=internal"
|
||||||
|
- "cn=engineers,cn=groups,cn=accounts,dc=kindred,dc=internal"
|
||||||
|
viewer:
|
||||||
|
- "cn=silo-viewers,cn=groups,cn=accounts,dc=kindred,dc=internal"
|
||||||
|
tls_skip_verify: false
|
||||||
|
```
|
||||||
|
|
||||||
|
### OIDC / Keycloak
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
enabled: true
|
||||||
|
session_secret: ""
|
||||||
|
|
||||||
|
local:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
oidc:
|
||||||
|
enabled: true
|
||||||
|
issuer_url: "https://keycloak.kindred.internal/realms/silo"
|
||||||
|
client_id: "silo"
|
||||||
|
client_secret: "" # Set via SILO_OIDC_CLIENT_SECRET
|
||||||
|
redirect_url: "https://silo.kindred.internal/auth/callback"
|
||||||
|
scopes: ["openid", "profile", "email"]
|
||||||
|
admin_role: "silo-admin"
|
||||||
|
editor_role: "silo-editor"
|
||||||
|
default_role: "viewer"
|
||||||
|
```
|
||||||
|
|
||||||
|
### CORS (Production)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
cors:
|
||||||
|
allowed_origins:
|
||||||
|
- "https://silo.kindred.internal"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Config Path |
|
||||||
|
|----------|-------------|-------------|
|
||||||
|
| `SILO_SESSION_SECRET` | Session encryption key | `auth.session_secret` |
|
||||||
|
| `SILO_ADMIN_USERNAME` | Default admin username | `auth.local.default_admin_username` |
|
||||||
|
| `SILO_ADMIN_PASSWORD` | Default admin password | `auth.local.default_admin_password` |
|
||||||
|
| `SILO_OIDC_CLIENT_SECRET` | OIDC client secret | `auth.oidc.client_secret` |
|
||||||
|
| `SILO_LDAP_BIND_PASSWORD` | LDAP service account password | `auth.ldap.bind_password` |
|
||||||
|
| `SILO_API_URL` | API base URL (CLI and FreeCAD) | — |
|
||||||
|
| `SILO_API_TOKEN` | API token (CLI and FreeCAD) | — |
|
||||||
|
|
||||||
|
Environment variables override config file values.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Authentication required" on every request
|
||||||
|
|
||||||
|
- Verify `auth.enabled: true` in your config
|
||||||
|
- Check that the `sessions` table exists in PostgreSQL (migration 009)
|
||||||
|
- Ensure `SILO_SESSION_SECRET` is set (empty string is allowed for dev but not recommended)
|
||||||
|
- Check browser cookies — `silo_session` should be present after login
|
||||||
|
|
||||||
|
### API token returns 401
|
||||||
|
|
||||||
|
- Tokens are case-sensitive. Ensure no trailing whitespace
|
||||||
|
- Check token expiry with `silo token list`
|
||||||
|
- Verify the user account is still active
|
||||||
|
- Ensure the `Authorization` header format is exactly `Bearer silo_<hex>`
|
||||||
|
|
||||||
|
### LDAP login fails
|
||||||
|
|
||||||
|
- Check `ldaps://` URL is reachable from the Silo server
|
||||||
|
- Verify `base_dn` and `user_search_dn` match your FreeIPA tree
|
||||||
|
- Test with `ldapsearch` from the command line first
|
||||||
|
- Set `tls_skip_verify: true` temporarily to rule out certificate issues
|
||||||
|
- Check Silo logs for the specific LDAP error message
|
||||||
|
|
||||||
|
### OIDC redirect loops
|
||||||
|
|
||||||
|
- Verify `redirect_url` matches the Keycloak client configuration exactly
|
||||||
|
- Check that `issuer_url` is reachable from the Silo server
|
||||||
|
- Ensure the Keycloak client has the correct redirect URI registered
|
||||||
|
- Check for clock skew between Silo and Keycloak servers (JWT validation is time-sensitive)
|
||||||
|
|
||||||
|
### Locked out (no admin account)
|
||||||
|
|
||||||
|
If you've lost access to all admin accounts:
|
||||||
|
|
||||||
|
1. Set `auth.local.default_admin_password` to a new password via `SILO_ADMIN_PASSWORD`
|
||||||
|
2. Use a different username (e.g., `default_admin_username: "recovery-admin"`)
|
||||||
|
3. Restart Silo — the new account will be created
|
||||||
|
4. Log in and fix the original accounts
|
||||||
|
|
||||||
|
Or directly update the database:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Reset a local user's password (generate bcrypt hash externally)
|
||||||
|
UPDATE users SET password_hash = '<bcrypt-hash>', is_active = true WHERE username = 'admin';
|
||||||
|
```
|
||||||
|
|
||||||
|
### FreeCAD plugin gets 401
|
||||||
|
|
||||||
|
- Verify the token is set in FreeCAD preferences or `SILO_API_TOKEN`
|
||||||
|
- Check the API URL points to the correct server
|
||||||
|
- Test with curl: `curl -H "Authorization: Bearer silo_..." https://silo.kindred.internal/api/items`
|
||||||
29
go.mod
29
go.mod
@@ -1,26 +1,37 @@
|
|||||||
module github.com/kindredsystems/silo
|
module github.com/kindredsystems/silo
|
||||||
|
|
||||||
go 1.23
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
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/chi/v5 v5.0.12
|
||||||
github.com/go-chi/cors v1.2.1
|
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/minio/minio-go/v7 v7.0.66
|
||||||
github.com/rs/zerolog v1.32.0
|
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
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // 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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/compress v1.17.4 // indirect
|
github.com/klauspost/compress v1.17.4 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.6 // 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-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
github.com/minio/md5-simd v1.1.2 // 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/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/rs/xid v1.5.0 // 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
|
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||||
golang.org/x/crypto v0.16.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/net v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sync v0.1.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
golang.org/x/sys v0.26.0 // indirect
|
golang.org/x/text v0.33.0 // indirect
|
||||||
golang.org/x/text v0.14.0 // indirect
|
|
||||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
108
go.sum
108
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/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/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=
|
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/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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
|
||||||
github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
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 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||||
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
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/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/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.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
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 h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
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.4 h1:Xp2aQS8uXButQdnCMWNmvx6UysWQQC+u1EoizjguY+8=
|
||||||
github.com/jackc/pgx/v5 v5.5.0/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
|
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 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
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 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
||||||
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
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.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 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
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 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
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=
|
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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
|
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 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
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.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.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.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 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||||
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
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.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-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.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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.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.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
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.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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
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 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
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=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
373
internal/api/auth_handlers.go
Normal file
373
internal/api/auth_handlers.go
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/justinas/nosurf"
|
||||||
|
"github.com/kindredsystems/silo/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// loginPageData holds template data for the login page.
|
||||||
|
type loginPageData struct {
|
||||||
|
Error string
|
||||||
|
Username string
|
||||||
|
Next string
|
||||||
|
CSRFToken string
|
||||||
|
OIDCEnabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleLoginPage renders the login page.
|
||||||
|
func (s *Server) HandleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// If auth is disabled, redirect to home
|
||||||
|
if s.authConfig == nil || !s.authConfig.Enabled {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If already logged in, redirect to home
|
||||||
|
if s.sessions != nil {
|
||||||
|
userID := s.sessions.GetString(r.Context(), "user_id")
|
||||||
|
if userID != "" {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.renderLogin(w, r, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleLogin processes the login form submission.
|
||||||
|
func (s *Server) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.authConfig == nil || !s.authConfig.Enabled {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := strings.TrimSpace(r.FormValue("username"))
|
||||||
|
password := r.FormValue("password")
|
||||||
|
|
||||||
|
if username == "" || password == "" {
|
||||||
|
s.renderLogin(w, r, "Username and password are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.auth.Authenticate(r.Context(), username, password)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn().Str("username", username).Err(err).Msg("login failed")
|
||||||
|
s.renderLogin(w, r, "Invalid username or password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
if err := s.sessions.RenewToken(r.Context()); err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to renew session token")
|
||||||
|
s.renderLogin(w, r, "Internal error, please try again")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.sessions.Put(r.Context(), "user_id", user.ID)
|
||||||
|
s.sessions.Put(r.Context(), "username", user.Username)
|
||||||
|
|
||||||
|
s.logger.Info().Str("username", username).Str("source", user.AuthSource).Msg("user logged in")
|
||||||
|
|
||||||
|
// Redirect to original destination or home
|
||||||
|
next := r.URL.Query().Get("next")
|
||||||
|
if next == "" || !strings.HasPrefix(next, "/") {
|
||||||
|
next = "/"
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, next, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleLogout destroys the session and redirects to login.
|
||||||
|
func (s *Server) HandleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.sessions != nil {
|
||||||
|
_ = s.sessions.Destroy(r.Context())
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleOIDCLogin initiates the OIDC redirect to Keycloak.
|
||||||
|
func (s *Server) HandleOIDCLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.oidc == nil {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := generateRandomState()
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to generate OIDC state")
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sessions.Put(r.Context(), "oidc_state", state)
|
||||||
|
http.Redirect(w, r, s.oidc.AuthCodeURL(state), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleOIDCCallback processes the OIDC redirect from Keycloak.
|
||||||
|
func (s *Server) HandleOIDCCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.oidc == nil {
|
||||||
|
http.Error(w, "OIDC not configured", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify state
|
||||||
|
expectedState := s.sessions.GetString(r.Context(), "oidc_state")
|
||||||
|
actualState := r.URL.Query().Get("state")
|
||||||
|
if expectedState == "" || actualState != expectedState {
|
||||||
|
s.logger.Warn().Msg("OIDC state mismatch")
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.sessions.Remove(r.Context(), "oidc_state")
|
||||||
|
|
||||||
|
// Check for error from IdP
|
||||||
|
if errParam := r.URL.Query().Get("error"); errParam != "" {
|
||||||
|
desc := r.URL.Query().Get("error_description")
|
||||||
|
s.logger.Warn().Str("error", errParam).Str("description", desc).Msg("OIDC error from IdP")
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange code for token
|
||||||
|
code := r.URL.Query().Get("code")
|
||||||
|
user, err := s.oidc.Exchange(r.Context(), code)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("OIDC exchange failed")
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert user into DB
|
||||||
|
if err := s.auth.UpsertOIDCUser(r.Context(), user); err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to upsert OIDC user")
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
if err := s.sessions.RenewToken(r.Context()); err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to renew session token")
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.sessions.Put(r.Context(), "user_id", user.ID)
|
||||||
|
s.sessions.Put(r.Context(), "username", user.Username)
|
||||||
|
|
||||||
|
s.logger.Info().Str("username", user.Username).Msg("OIDC user logged in")
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleGetCurrentUser returns the authenticated user as JSON.
|
||||||
|
func (s *Server) HandleGetCurrentUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := auth.UserFromContext(r.Context())
|
||||||
|
if user == nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"id": user.ID,
|
||||||
|
"username": user.Username,
|
||||||
|
"display_name": user.DisplayName,
|
||||||
|
"email": user.Email,
|
||||||
|
"role": user.Role,
|
||||||
|
"auth_source": user.AuthSource,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTokenRequest is the request body for token creation.
|
||||||
|
type createTokenRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ExpiresInDays *int `json:"expires_in_days,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleCreateToken creates a new API token (JSON API).
|
||||||
|
func (s *Server) HandleCreateToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := auth.UserFromContext(r.Context())
|
||||||
|
if user == nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req createTokenRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Name == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "Token name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiresAt *time.Time
|
||||||
|
if req.ExpiresInDays != nil && *req.ExpiresInDays > 0 {
|
||||||
|
t := time.Now().AddDate(0, 0, *req.ExpiresInDays)
|
||||||
|
expiresAt = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
rawToken, info, err := s.auth.GenerateToken(r.Context(), user.ID, req.Name, nil, expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to generate token")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{
|
||||||
|
"token": rawToken,
|
||||||
|
"id": info.ID,
|
||||||
|
"name": info.Name,
|
||||||
|
"token_prefix": info.TokenPrefix,
|
||||||
|
"expires_at": info.ExpiresAt,
|
||||||
|
"created_at": info.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleListTokens lists all tokens for the current user.
|
||||||
|
func (s *Server) HandleListTokens(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := auth.UserFromContext(r.Context())
|
||||||
|
if user == nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err := s.auth.ListTokens(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list tokens")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type tokenResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
TokenPrefix string `json:"token_prefix"`
|
||||||
|
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]tokenResponse, 0, len(tokens))
|
||||||
|
for _, t := range tokens {
|
||||||
|
result = append(result, tokenResponse{
|
||||||
|
ID: t.ID,
|
||||||
|
Name: t.Name,
|
||||||
|
TokenPrefix: t.TokenPrefix,
|
||||||
|
LastUsedAt: t.LastUsedAt,
|
||||||
|
ExpiresAt: t.ExpiresAt,
|
||||||
|
CreatedAt: t.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleRevokeToken deletes an API token (JSON API).
|
||||||
|
func (s *Server) HandleRevokeToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := auth.UserFromContext(r.Context())
|
||||||
|
if user == nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenID := chi.URLParam(r, "id")
|
||||||
|
if err := s.auth.RevokeToken(r.Context(), user.ID, tokenID); err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "Token not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSettingsPage renders the settings page.
|
||||||
|
func (s *Server) HandleSettingsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := auth.UserFromContext(r.Context())
|
||||||
|
tokens, _ := s.auth.ListTokens(r.Context(), user.ID)
|
||||||
|
|
||||||
|
// Retrieve one-time new token from session (if just created)
|
||||||
|
var newToken string
|
||||||
|
if s.sessions != nil {
|
||||||
|
newToken = s.sessions.PopString(r.Context(), "new_token")
|
||||||
|
}
|
||||||
|
|
||||||
|
data := PageData{
|
||||||
|
Title: "Settings",
|
||||||
|
Page: "settings",
|
||||||
|
User: user,
|
||||||
|
CSRFToken: nosurf.Token(r),
|
||||||
|
Data: map[string]any{
|
||||||
|
"tokens": tokens,
|
||||||
|
"new_token": newToken,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := s.webHandler.templates.ExecuteTemplate(w, "base.html", data); err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to render settings page")
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleCreateTokenWeb creates a token via the web form.
|
||||||
|
func (s *Server) HandleCreateTokenWeb(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := auth.UserFromContext(r.Context())
|
||||||
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
|
if name == "" {
|
||||||
|
name = "Unnamed token"
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiresAt *time.Time
|
||||||
|
if daysStr := r.FormValue("expires_in_days"); daysStr != "" {
|
||||||
|
if d, err := strconv.Atoi(daysStr); err == nil && d > 0 {
|
||||||
|
t := time.Now().AddDate(0, 0, d)
|
||||||
|
expiresAt = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rawToken, _, err := s.auth.GenerateToken(r.Context(), user.ID, name, nil, expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to generate token")
|
||||||
|
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the raw token in session for one-time display
|
||||||
|
s.sessions.Put(r.Context(), "new_token", rawToken)
|
||||||
|
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleRevokeTokenWeb revokes a token via the web form.
|
||||||
|
func (s *Server) HandleRevokeTokenWeb(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := auth.UserFromContext(r.Context())
|
||||||
|
tokenID := chi.URLParam(r, "id")
|
||||||
|
_ = s.auth.RevokeToken(r.Context(), user.ID, tokenID)
|
||||||
|
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) renderLogin(w http.ResponseWriter, r *http.Request, errMsg string) {
|
||||||
|
data := loginPageData{
|
||||||
|
Error: errMsg,
|
||||||
|
Username: r.FormValue("username"),
|
||||||
|
Next: r.URL.Query().Get("next"),
|
||||||
|
CSRFToken: nosurf.Token(r),
|
||||||
|
OIDCEnabled: s.oidc != nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := s.webHandler.templates.ExecuteTemplate(w, "login.html", data); err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to render login page")
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateRandomState() (string, error) {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b), nil
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/kindredsystems/silo/internal/auth"
|
||||||
"github.com/kindredsystems/silo/internal/db"
|
"github.com/kindredsystems/silo/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -240,6 +241,9 @@ func (s *Server) HandleAddBOMEntry(w http.ResponseWriter, r *http.Request) {
|
|||||||
ChildRevision: req.ChildRevision,
|
ChildRevision: req.ChildRevision,
|
||||||
Metadata: req.Metadata,
|
Metadata: req.Metadata,
|
||||||
}
|
}
|
||||||
|
if user := auth.UserFromContext(ctx); user != nil {
|
||||||
|
rel.CreatedBy = &user.Username
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.relationships.Create(ctx, rel); err != nil {
|
if err := s.relationships.Create(ctx, rel); err != nil {
|
||||||
if strings.Contains(err.Error(), "cycle") {
|
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")
|
s.logger.Error().Err(err).Msg("failed to update relationship")
|
||||||
writeError(w, http.StatusInternalServerError, "update_failed", err.Error())
|
writeError(w, http.StatusInternalServerError, "update_failed", err.Error())
|
||||||
return
|
return
|
||||||
@@ -776,9 +784,14 @@ func (s *Server) HandleImportBOMCSV(w http.ResponseWriter, r *http.Request) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var importUsername *string
|
||||||
|
if user := auth.UserFromContext(ctx); user != nil {
|
||||||
|
importUsername = &user.Username
|
||||||
|
}
|
||||||
|
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
// Update existing
|
// 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.ErrorCount++
|
||||||
result.Errors = append(result.Errors, CSVImportErr{
|
result.Errors = append(result.Errors, CSVImportErr{
|
||||||
Row: rowNum,
|
Row: rowNum,
|
||||||
@@ -794,6 +807,7 @@ func (s *Server) HandleImportBOMCSV(w http.ResponseWriter, r *http.Request) {
|
|||||||
RelType: "component",
|
RelType: "component",
|
||||||
Quantity: quantity,
|
Quantity: quantity,
|
||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
|
CreatedBy: importUsername,
|
||||||
}
|
}
|
||||||
if err := s.relationships.Create(ctx, rel); err != nil {
|
if err := s.relationships.Create(ctx, rel); err != nil {
|
||||||
result.ErrorCount++
|
result.ErrorCount++
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kindredsystems/silo/internal/auth"
|
||||||
"github.com/kindredsystems/silo/internal/db"
|
"github.com/kindredsystems/silo/internal/db"
|
||||||
"github.com/kindredsystems/silo/internal/partnum"
|
"github.com/kindredsystems/silo/internal/partnum"
|
||||||
)
|
)
|
||||||
@@ -355,6 +356,9 @@ func (s *Server) HandleImportCSV(w http.ResponseWriter, r *http.Request) {
|
|||||||
ItemType: itemType,
|
ItemType: itemType,
|
||||||
Description: description,
|
Description: description,
|
||||||
}
|
}
|
||||||
|
if user := auth.UserFromContext(ctx); user != nil {
|
||||||
|
item.CreatedBy = &user.Username
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.items.Create(ctx, item, properties); err != nil {
|
if err := s.items.Create(ctx, item, properties); err != nil {
|
||||||
result.Errors = append(result.Errors, CSVImportErr{
|
result.Errors = append(result.Errors, CSVImportErr{
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/alexedwards/scs/v2"
|
||||||
"github.com/go-chi/chi/v5"
|
"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/db"
|
||||||
"github.com/kindredsystems/silo/internal/partnum"
|
"github.com/kindredsystems/silo/internal/partnum"
|
||||||
"github.com/kindredsystems/silo/internal/schema"
|
"github.com/kindredsystems/silo/internal/schema"
|
||||||
@@ -30,6 +33,11 @@ type Server struct {
|
|||||||
schemasDir string
|
schemasDir string
|
||||||
partgen *partnum.Generator
|
partgen *partnum.Generator
|
||||||
storage *storage.Storage
|
storage *storage.Storage
|
||||||
|
auth *auth.Service
|
||||||
|
sessions *scs.SessionManager
|
||||||
|
oidc *auth.OIDCBackend
|
||||||
|
authConfig *config.AuthConfig
|
||||||
|
webHandler *WebHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new API server.
|
// NewServer creates a new API server.
|
||||||
@@ -39,6 +47,10 @@ func NewServer(
|
|||||||
schemas map[string]*schema.Schema,
|
schemas map[string]*schema.Schema,
|
||||||
schemasDir string,
|
schemasDir string,
|
||||||
store *storage.Storage,
|
store *storage.Storage,
|
||||||
|
authService *auth.Service,
|
||||||
|
sessionManager *scs.SessionManager,
|
||||||
|
oidcBackend *auth.OIDCBackend,
|
||||||
|
authCfg *config.AuthConfig,
|
||||||
) *Server {
|
) *Server {
|
||||||
items := db.NewItemRepository(database)
|
items := db.NewItemRepository(database)
|
||||||
projects := db.NewProjectRepository(database)
|
projects := db.NewProjectRepository(database)
|
||||||
@@ -56,6 +68,10 @@ func NewServer(
|
|||||||
schemasDir: schemasDir,
|
schemasDir: schemasDir,
|
||||||
partgen: partgen,
|
partgen: partgen,
|
||||||
storage: store,
|
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,
|
ItemType: itemType,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
}
|
}
|
||||||
|
if user := auth.UserFromContext(ctx); user != nil {
|
||||||
|
item.CreatedBy = &user.Username
|
||||||
|
}
|
||||||
|
|
||||||
properties := req.Properties
|
properties := req.Properties
|
||||||
if properties == nil {
|
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)
|
// 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")
|
s.logger.Error().Err(err).Msg("failed to update item")
|
||||||
writeError(w, http.StatusInternalServerError, "update_failed", err.Error())
|
writeError(w, http.StatusInternalServerError, "update_failed", err.Error())
|
||||||
return
|
return
|
||||||
@@ -478,6 +501,9 @@ func (s *Server) HandleUpdateItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
Properties: req.Properties,
|
Properties: req.Properties,
|
||||||
Comment: &req.Comment,
|
Comment: &req.Comment,
|
||||||
}
|
}
|
||||||
|
if user := auth.UserFromContext(ctx); user != nil {
|
||||||
|
rev.CreatedBy = &user.Username
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.items.CreateRevision(ctx, rev); err != nil {
|
if err := s.items.CreateRevision(ctx, rev); err != nil {
|
||||||
s.logger.Error().Err(err).Msg("failed to create revision")
|
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)
|
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 {
|
if err != nil {
|
||||||
s.logger.Error().Err(err).Msg("failed to create rollback revision")
|
s.logger.Error().Err(err).Msg("failed to create rollback revision")
|
||||||
writeError(w, http.StatusBadRequest, "rollback_failed", err.Error())
|
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,
|
Properties: req.Properties,
|
||||||
Comment: &req.Comment,
|
Comment: &req.Comment,
|
||||||
}
|
}
|
||||||
|
if user := auth.UserFromContext(ctx); user != nil {
|
||||||
|
rev.CreatedBy = &user.Username
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.items.CreateRevision(ctx, rev); err != nil {
|
if err := s.items.CreateRevision(ctx, rev); err != nil {
|
||||||
s.logger.Error().Err(err).Msg("failed to create revision")
|
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,
|
FileSize: &result.Size,
|
||||||
Comment: &comment,
|
Comment: &comment,
|
||||||
}
|
}
|
||||||
|
if user := auth.UserFromContext(ctx); user != nil {
|
||||||
|
rev.CreatedBy = &user.Username
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.items.CreateRevision(ctx, rev); err != nil {
|
if err := s.items.CreateRevision(ctx, rev); err != nil {
|
||||||
s.logger.Error().Err(err).Msg("failed to create revision")
|
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,
|
Name: req.Name,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
}
|
}
|
||||||
|
if user := auth.UserFromContext(ctx); user != nil {
|
||||||
|
project.CreatedBy = &user.Username
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.projects.Create(ctx, project); err != nil {
|
if err := s.projects.Create(ctx, project); err != nil {
|
||||||
s.logger.Error().Err(err).Msg("failed to create project")
|
s.logger.Error().Err(err).Msg("failed to create project")
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/justinas/nosurf"
|
||||||
|
"github.com/kindredsystems/silo/internal/auth"
|
||||||
"github.com/rs/zerolog"
|
"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/")
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
|
"github.com/kindredsystems/silo/internal/auth"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,119 +14,170 @@ import (
|
|||||||
func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
// Middleware stack
|
// Base middleware stack
|
||||||
r.Use(middleware.RequestID)
|
r.Use(middleware.RequestID)
|
||||||
r.Use(middleware.RealIP)
|
r.Use(middleware.RealIP)
|
||||||
r.Use(RequestLogger(logger))
|
r.Use(RequestLogger(logger))
|
||||||
r.Use(Recoverer(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{
|
r.Use(cors.Handler(cors.Options{
|
||||||
AllowedOrigins: []string{"*"},
|
AllowedOrigins: corsOrigins,
|
||||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Request-ID"},
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "X-Request-ID"},
|
||||||
ExposedHeaders: []string{"Link", "X-Request-ID"},
|
ExposedHeaders: []string{"Link", "X-Request-ID"},
|
||||||
AllowCredentials: false,
|
AllowCredentials: corsCredentials,
|
||||||
MaxAge: 300,
|
MaxAge: 300,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Session middleware (must come before auth middleware)
|
||||||
|
if server.sessions != nil {
|
||||||
|
r.Use(server.sessions.LoadAndSave)
|
||||||
|
}
|
||||||
|
|
||||||
// Web handler for HTML pages
|
// Web handler for HTML pages
|
||||||
webHandler, err := NewWebHandler(logger)
|
webHandler, err := NewWebHandler(logger, server)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Fatal().Err(err).Msg("failed to create web handler")
|
logger.Fatal().Err(err).Msg("failed to create web handler")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Health endpoints
|
// Health endpoints (no auth)
|
||||||
r.Get("/health", server.HandleHealth)
|
r.Get("/health", server.HandleHealth)
|
||||||
r.Get("/ready", server.HandleReady)
|
r.Get("/ready", server.HandleReady)
|
||||||
|
|
||||||
// Web UI routes
|
// Auth endpoints (no auth required)
|
||||||
r.Get("/", webHandler.HandleIndex)
|
r.Get("/login", server.HandleLoginPage)
|
||||||
r.Get("/projects", webHandler.HandleProjectsPage)
|
r.Post("/login", server.HandleLogin)
|
||||||
r.Get("/schemas", webHandler.HandleSchemasPage)
|
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) {
|
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.Route("/schemas", func(r chi.Router) {
|
||||||
r.Get("/", server.HandleListSchemas)
|
r.Get("/", server.HandleListSchemas)
|
||||||
r.Get("/{name}", server.HandleGetSchema)
|
r.Get("/{name}", server.HandleGetSchema)
|
||||||
r.Get("/{name}/properties", server.HandleGetPropertySchema)
|
r.Get("/{name}/properties", server.HandleGetPropertySchema)
|
||||||
|
|
||||||
// Schema segment value management
|
r.Group(func(r chi.Router) {
|
||||||
r.Route("/{name}/segments/{segment}/values", func(r chi.Router) {
|
r.Use(server.RequireRole(auth.RoleEditor))
|
||||||
r.Post("/", server.HandleAddSchemaValue)
|
r.Route("/{name}/segments/{segment}/values", func(r chi.Router) {
|
||||||
r.Put("/{code}", server.HandleUpdateSchemaValue)
|
r.Post("/", server.HandleAddSchemaValue)
|
||||||
r.Delete("/{code}", server.HandleDeleteSchemaValue)
|
r.Put("/{code}", server.HandleUpdateSchemaValue)
|
||||||
|
r.Delete("/{code}", server.HandleDeleteSchemaValue)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Projects
|
// Projects (read: viewer, write: editor)
|
||||||
r.Route("/projects", func(r chi.Router) {
|
r.Route("/projects", func(r chi.Router) {
|
||||||
r.Get("/", server.HandleListProjects)
|
r.Get("/", server.HandleListProjects)
|
||||||
r.Post("/", server.HandleCreateProject)
|
|
||||||
r.Get("/{code}", server.HandleGetProject)
|
r.Get("/{code}", server.HandleGetProject)
|
||||||
r.Put("/{code}", server.HandleUpdateProject)
|
|
||||||
r.Delete("/{code}", server.HandleDeleteProject)
|
|
||||||
r.Get("/{code}/items", server.HandleGetProjectItems)
|
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.Route("/items", func(r chi.Router) {
|
||||||
r.Get("/", server.HandleListItems)
|
r.Get("/", server.HandleListItems)
|
||||||
r.Get("/search", server.HandleFuzzySearch)
|
r.Get("/search", server.HandleFuzzySearch)
|
||||||
r.Post("/", server.HandleCreateItem)
|
|
||||||
|
|
||||||
// CSV Import/Export
|
|
||||||
r.Get("/export.csv", server.HandleExportCSV)
|
r.Get("/export.csv", server.HandleExportCSV)
|
||||||
r.Post("/import", server.HandleImportCSV)
|
|
||||||
r.Get("/template.csv", server.HandleCSVTemplate)
|
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.Route("/{partNumber}", func(r chi.Router) {
|
||||||
r.Get("/", server.HandleGetItem)
|
r.Get("/", server.HandleGetItem)
|
||||||
r.Put("/", server.HandleUpdateItem)
|
|
||||||
r.Delete("/", server.HandleDeleteItem)
|
|
||||||
|
|
||||||
// Item project tags
|
|
||||||
r.Get("/projects", server.HandleGetItemProjects)
|
r.Get("/projects", server.HandleGetItemProjects)
|
||||||
r.Post("/projects", server.HandleAddItemProjects)
|
|
||||||
r.Delete("/projects/{code}", server.HandleRemoveItemProject)
|
|
||||||
|
|
||||||
// Revisions
|
|
||||||
r.Get("/revisions", server.HandleListRevisions)
|
r.Get("/revisions", server.HandleListRevisions)
|
||||||
r.Post("/revisions", server.HandleCreateRevision)
|
|
||||||
r.Get("/revisions/compare", server.HandleCompareRevisions)
|
r.Get("/revisions/compare", server.HandleCompareRevisions)
|
||||||
r.Get("/revisions/{revision}", server.HandleGetRevision)
|
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", server.HandleDownloadLatestFile)
|
||||||
r.Get("/file/{revision}", server.HandleDownloadFile)
|
r.Get("/file/{revision}", server.HandleDownloadFile)
|
||||||
|
|
||||||
// BOM / Relationships
|
|
||||||
r.Get("/bom", server.HandleGetBOM)
|
r.Get("/bom", server.HandleGetBOM)
|
||||||
r.Post("/bom", server.HandleAddBOMEntry)
|
|
||||||
r.Get("/bom/expanded", server.HandleGetExpandedBOM)
|
r.Get("/bom/expanded", server.HandleGetExpandedBOM)
|
||||||
r.Get("/bom/where-used", server.HandleGetWhereUsed)
|
r.Get("/bom/where-used", server.HandleGetWhereUsed)
|
||||||
r.Get("/bom/export.csv", server.HandleExportBOMCSV)
|
r.Get("/bom/export.csv", server.HandleExportBOMCSV)
|
||||||
r.Post("/bom/import", server.HandleImportBOMCSV)
|
|
||||||
r.Put("/bom/{childPartNumber}", server.HandleUpdateBOMEntry)
|
r.Group(func(r chi.Router) {
|
||||||
r.Delete("/bom/{childPartNumber}", server.HandleDeleteBOMEntry)
|
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.Route("/integrations/odoo", func(r chi.Router) {
|
||||||
r.Get("/config", server.HandleGetOdooConfig)
|
r.Get("/config", server.HandleGetOdooConfig)
|
||||||
r.Put("/config", server.HandleUpdateOdooConfig)
|
|
||||||
r.Get("/sync-log", server.HandleGetOdooSyncLog)
|
r.Get("/sync-log", server.HandleGetOdooSyncLog)
|
||||||
r.Post("/test-connection", server.HandleTestOdooConnection)
|
|
||||||
r.Post("/sync/push/{partNumber}", server.HandleOdooPush)
|
r.Group(func(r chi.Router) {
|
||||||
r.Post("/sync/pull/{odooId}", server.HandleOdooPull)
|
r.Use(server.RequireRole(auth.RoleEditor))
|
||||||
|
r.Put("/config", server.HandleUpdateOdooConfig)
|
||||||
|
r.Post("/test-connection", server.HandleTestOdooConnection)
|
||||||
|
r.Post("/sync/push/{partNumber}", server.HandleOdooPush)
|
||||||
|
r.Post("/sync/pull/{odooId}", server.HandleOdooPull)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Part number generation
|
// Part number generation (editor)
|
||||||
r.Post("/generate-part-number", server.HandleGeneratePartNumber)
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(server.RequireRole(auth.RoleEditor))
|
||||||
|
r.Post("/generate-part-number", server.HandleGeneratePartNumber)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -482,7 +482,20 @@
|
|||||||
<a href="/" class="{{if eq .Page "items"}}active{{end}}">Items</a>
|
<a href="/" class="{{if eq .Page "items"}}active{{end}}">Items</a>
|
||||||
<a href="/projects" class="{{if eq .Page "projects"}}active{{end}}">Projects</a>
|
<a href="/projects" class="{{if eq .Page "projects"}}active{{end}}">Projects</a>
|
||||||
<a href="/schemas" class="{{if eq .Page "schemas"}}active{{end}}">Schemas</a>
|
<a href="/schemas" class="{{if eq .Page "schemas"}}active{{end}}">Schemas</a>
|
||||||
|
<a href="/settings" class="{{if eq .Page "settings"}}active{{end}}">Settings</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
{{if .User}}
|
||||||
|
<div class="header-user" style="display:flex;align-items:center;gap:0.75rem;">
|
||||||
|
<span style="color:var(--ctp-subtext1);font-size:0.9rem;">{{.User.DisplayName}}</span>
|
||||||
|
<span style="display:inline-block;padding:0.15rem 0.5rem;border-radius:1rem;font-size:0.75rem;font-weight:600;
|
||||||
|
{{if eq .User.Role "admin"}}background:rgba(203,166,247,0.2);color:var(--ctp-mauve);
|
||||||
|
{{else if eq .User.Role "editor"}}background:rgba(137,180,250,0.2);color:var(--ctp-blue);
|
||||||
|
{{else}}background:rgba(148,226,213,0.2);color:var(--ctp-teal);{{end}}">{{.User.Role}}</span>
|
||||||
|
<form method="POST" action="/logout" style="display:inline;margin:0;">
|
||||||
|
<button type="submit" class="btn-secondary" style="padding:0.35rem 0.75rem;font-size:0.8rem;border-radius:0.4rem;cursor:pointer;border:none;background:var(--ctp-surface1);color:var(--ctp-subtext1);">Logout</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="main">
|
<main class="main">
|
||||||
@@ -492,6 +505,8 @@
|
|||||||
{{template "projects_content" .}}
|
{{template "projects_content" .}}
|
||||||
{{else if eq .Page "schemas"}}
|
{{else if eq .Page "schemas"}}
|
||||||
{{template "schemas_content" .}}
|
{{template "schemas_content" .}}
|
||||||
|
{{else if eq .Page "settings"}}
|
||||||
|
{{template "settings_content" .}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -501,6 +516,8 @@
|
|||||||
{{template "projects_scripts" .}}
|
{{template "projects_scripts" .}}
|
||||||
{{else if eq .Page "schemas"}}
|
{{else if eq .Page "schemas"}}
|
||||||
{{template "schemas_scripts" .}}
|
{{template "schemas_scripts" .}}
|
||||||
|
{{else if eq .Page "settings"}}
|
||||||
|
{{template "settings_scripts" .}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
222
internal/api/templates/login.html
Normal file
222
internal/api/templates/login.html
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Login - Silo</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--ctp-rosewater: #f5e0dc;
|
||||||
|
--ctp-flamingo: #f2cdcd;
|
||||||
|
--ctp-pink: #f5c2e7;
|
||||||
|
--ctp-mauve: #cba6f7;
|
||||||
|
--ctp-red: #f38ba8;
|
||||||
|
--ctp-maroon: #eba0ac;
|
||||||
|
--ctp-peach: #fab387;
|
||||||
|
--ctp-yellow: #f9e2af;
|
||||||
|
--ctp-green: #a6e3a1;
|
||||||
|
--ctp-teal: #94e2d5;
|
||||||
|
--ctp-sky: #89dceb;
|
||||||
|
--ctp-sapphire: #74c7ec;
|
||||||
|
--ctp-blue: #89b4fa;
|
||||||
|
--ctp-lavender: #b4befe;
|
||||||
|
--ctp-text: #cdd6f4;
|
||||||
|
--ctp-subtext1: #bac2de;
|
||||||
|
--ctp-subtext0: #a6adc8;
|
||||||
|
--ctp-overlay2: #9399b2;
|
||||||
|
--ctp-overlay1: #7f849c;
|
||||||
|
--ctp-overlay0: #6c7086;
|
||||||
|
--ctp-surface2: #585b70;
|
||||||
|
--ctp-surface1: #45475a;
|
||||||
|
--ctp-surface0: #313244;
|
||||||
|
--ctp-base: #1e1e2e;
|
||||||
|
--ctp-mantle: #181825;
|
||||||
|
--ctp-crust: #11111b;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family:
|
||||||
|
"Inter",
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
"Segoe UI",
|
||||||
|
Roboto,
|
||||||
|
sans-serif;
|
||||||
|
background-color: var(--ctp-base);
|
||||||
|
color: var(--ctp-text);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background-color: var(--ctp-surface0);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 2.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
color: var(--ctp-mauve);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-subtitle {
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
color: var(--ctp-red);
|
||||||
|
background: rgba(243, 139, 168, 0.1);
|
||||||
|
border: 1px solid rgba(243, 139, 168, 0.2);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ctp-subtext1);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background-color: var(--ctp-base);
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--ctp-mauve);
|
||||||
|
box-shadow: 0 0 0 3px rgba(203, 166, 247, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input::placeholder {
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--ctp-mauve);
|
||||||
|
color: var(--ctp-crust);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: var(--ctp-lavender);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-oidc {
|
||||||
|
background-color: var(--ctp-blue);
|
||||||
|
color: var(--ctp-crust);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-oidc:hover {
|
||||||
|
background-color: var(--ctp-sapphire);
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider::before,
|
||||||
|
.divider::after {
|
||||||
|
content: "";
|
||||||
|
flex: 1;
|
||||||
|
border-top: 1px solid var(--ctp-surface1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider span {
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-card">
|
||||||
|
<h1 class="login-title">Silo</h1>
|
||||||
|
<p class="login-subtitle">Product Lifecycle Management</p>
|
||||||
|
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="error-msg">{{.Error}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="/login{{if .Next}}?next={{.Next}}{{end}}"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" />
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Username or LDAP uid"
|
||||||
|
autofocus
|
||||||
|
required
|
||||||
|
value="{{.Username}}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Sign In</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{if .OIDCEnabled}}
|
||||||
|
<div class="divider"><span>or</span></div>
|
||||||
|
<a href="/auth/oidc" class="btn btn-oidc">Sign in with Keycloak</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
291
internal/api/templates/settings.html
Normal file
291
internal/api/templates/settings.html
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
{{define "settings_content"}}
|
||||||
|
<style>
|
||||||
|
.settings-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.settings-info {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 0.5rem 1.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.settings-info dt {
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.settings-info dd {
|
||||||
|
color: var(--ctp-text);
|
||||||
|
}
|
||||||
|
.role-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.role-admin {
|
||||||
|
background: rgba(203, 166, 247, 0.2);
|
||||||
|
color: var(--ctp-mauve);
|
||||||
|
}
|
||||||
|
.role-editor {
|
||||||
|
background: rgba(137, 180, 250, 0.2);
|
||||||
|
color: var(--ctp-blue);
|
||||||
|
}
|
||||||
|
.role-viewer {
|
||||||
|
background: rgba(148, 226, 213, 0.2);
|
||||||
|
color: var(--ctp-teal);
|
||||||
|
}
|
||||||
|
.token-display {
|
||||||
|
display: block;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--ctp-base);
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--ctp-peach);
|
||||||
|
word-break: break-all;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
.new-token-banner {
|
||||||
|
background: rgba(166, 227, 161, 0.1);
|
||||||
|
border: 1px solid rgba(166, 227, 161, 0.3);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.new-token-banner p {
|
||||||
|
color: var(--ctp-green);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.new-token-banner .hint {
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.copy-btn {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
background: var(--ctp-surface1);
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.copy-btn:hover {
|
||||||
|
background: var(--ctp-surface2);
|
||||||
|
}
|
||||||
|
.token-prefix {
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||||
|
color: var(--ctp-peach);
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: rgba(243, 139, 168, 0.15);
|
||||||
|
color: var(--ctp-red);
|
||||||
|
border: none;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: rgba(243, 139, 168, 0.25);
|
||||||
|
}
|
||||||
|
.create-token-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.create-token-form .form-group {
|
||||||
|
margin-bottom: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
.no-tokens {
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">Account</h2>
|
||||||
|
</div>
|
||||||
|
{{if .User}}
|
||||||
|
<dl class="settings-info">
|
||||||
|
<dt>Username</dt>
|
||||||
|
<dd>{{.User.Username}}</dd>
|
||||||
|
<dt>Display Name</dt>
|
||||||
|
<dd>{{.User.DisplayName}}</dd>
|
||||||
|
<dt>Email</dt>
|
||||||
|
<dd>
|
||||||
|
{{if .User.Email}}{{.User.Email}}{{else}}<span
|
||||||
|
style="color: var(--ctp-overlay0)"
|
||||||
|
>Not set</span
|
||||||
|
>{{end}}
|
||||||
|
</dd>
|
||||||
|
<dt>Auth Source</dt>
|
||||||
|
<dd>{{.User.AuthSource}}</dd>
|
||||||
|
<dt>Role</dt>
|
||||||
|
<dd>
|
||||||
|
<span class="role-badge role-{{.User.Role}}"
|
||||||
|
>{{.User.Role}}</span
|
||||||
|
>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">API Tokens</h2>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
API tokens allow the FreeCAD plugin and scripts to authenticate with
|
||||||
|
Silo. Tokens inherit your role permissions.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{if and .Data (index .Data "new_token")}} {{if ne (index .Data
|
||||||
|
"new_token") ""}}
|
||||||
|
<div class="new-token-banner">
|
||||||
|
<p>Your new API token (copy it now — it won't be shown again):</p>
|
||||||
|
<code class="token-display" id="new-token-value"
|
||||||
|
>{{index .Data "new_token"}}</code
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="copy-btn"
|
||||||
|
onclick="
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(
|
||||||
|
document.getElementById('new-token-value')
|
||||||
|
.textContent,
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
this.textContent = 'Copied!';
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Copy to clipboard
|
||||||
|
</button>
|
||||||
|
<p class="hint">
|
||||||
|
Store this token securely. You will not be able to see it again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{end}} {{end}}
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="/settings/tokens"
|
||||||
|
class="create-token-form"
|
||||||
|
style="margin-bottom: 1.5rem"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" />
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Token Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="e.g., FreeCAD workstation"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
style="padding: 0.75rem 1.25rem; white-space: nowrap"
|
||||||
|
>
|
||||||
|
Create Token
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Prefix</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Last Used</th>
|
||||||
|
<th>Expires</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tokens-table"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}} {{define "settings_scripts"}}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
async function loadTokens() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/api/auth/tokens");
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const tokens = await resp.json();
|
||||||
|
const tbody = document.getElementById("tokens-table");
|
||||||
|
if (!tokens || tokens.length === 0) {
|
||||||
|
tbody.innerHTML =
|
||||||
|
'<tr><td colspan="6" class="no-tokens">No API tokens yet. Create one to get started.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = tokens
|
||||||
|
.map(
|
||||||
|
(t) => `
|
||||||
|
<tr>
|
||||||
|
<td>${escHtml(t.name)}</td>
|
||||||
|
<td><span class="token-prefix">${escHtml(t.token_prefix)}...</span></td>
|
||||||
|
<td>${formatDate(t.created_at)}</td>
|
||||||
|
<td>${t.last_used_at ? formatDate(t.last_used_at) : '<span style="color:var(--ctp-overlay0)">Never</span>'}</td>
|
||||||
|
<td>${t.expires_at ? formatDate(t.expires_at) : '<span style="color:var(--ctp-overlay0)">Never</span>'}</td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" action="/settings/tokens/${t.id}/revoke" style="display:inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
<button type="submit" class="btn-danger" onclick="return confirm('Revoke this token?')">Revoke</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load tokens:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(s) {
|
||||||
|
if (!s) return "";
|
||||||
|
const d = new Date(s);
|
||||||
|
return (
|
||||||
|
d.toLocaleDateString() +
|
||||||
|
" " +
|
||||||
|
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = s;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for newly created token in URL or page state
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
// The token is passed via a cookie/session flash, rendered by the server if present
|
||||||
|
loadTokens();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"embed"
|
"embed"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/justinas/nosurf"
|
||||||
|
"github.com/kindredsystems/silo/internal/auth"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,58 +17,49 @@ var templatesFS embed.FS
|
|||||||
type WebHandler struct {
|
type WebHandler struct {
|
||||||
templates *template.Template
|
templates *template.Template
|
||||||
logger zerolog.Logger
|
logger zerolog.Logger
|
||||||
|
server *Server
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWebHandler creates a new web handler.
|
// NewWebHandler creates a new web handler.
|
||||||
func NewWebHandler(logger zerolog.Logger) (*WebHandler, error) {
|
func NewWebHandler(logger zerolog.Logger, server *Server) (*WebHandler, error) {
|
||||||
// Parse templates from embedded filesystem
|
|
||||||
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
|
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &WebHandler{
|
wh := &WebHandler{
|
||||||
templates: tmpl,
|
templates: tmpl,
|
||||||
logger: logger,
|
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.
|
// PageData holds data for page rendering.
|
||||||
type PageData struct {
|
type PageData struct {
|
||||||
Title string
|
Title string
|
||||||
Page string
|
Page string
|
||||||
Data any
|
Data any
|
||||||
}
|
User *auth.User
|
||||||
|
CSRFToken string
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleIndex serves the main items page.
|
// HandleIndex serves the main items page.
|
||||||
func (h *WebHandler) HandleIndex(w http.ResponseWriter, r *http.Request) {
|
func (h *WebHandler) HandleIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
// Check if this is the root path
|
|
||||||
if r.URL.Path != "/" {
|
if r.URL.Path != "/" {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data := PageData{
|
data := PageData{
|
||||||
Title: "Items",
|
Title: "Items",
|
||||||
Page: "items",
|
Page: "items",
|
||||||
|
User: auth.UserFromContext(r.Context()),
|
||||||
|
CSRFToken: nosurf.Token(r),
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
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.
|
// HandleProjectsPage serves the projects page.
|
||||||
func (h *WebHandler) HandleProjectsPage(w http.ResponseWriter, r *http.Request) {
|
func (h *WebHandler) HandleProjectsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
data := PageData{
|
data := PageData{
|
||||||
Title: "Projects",
|
Title: "Projects",
|
||||||
Page: "projects",
|
Page: "projects",
|
||||||
|
User: auth.UserFromContext(r.Context()),
|
||||||
|
CSRFToken: nosurf.Token(r),
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
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.
|
// HandleSchemasPage serves the schemas page.
|
||||||
func (h *WebHandler) HandleSchemasPage(w http.ResponseWriter, r *http.Request) {
|
func (h *WebHandler) HandleSchemasPage(w http.ResponseWriter, r *http.Request) {
|
||||||
data := PageData{
|
data := PageData{
|
||||||
Title: "Schemas",
|
Title: "Schemas",
|
||||||
Page: "schemas",
|
Page: "schemas",
|
||||||
|
User: auth.UserFromContext(r.Context()),
|
||||||
|
CSRFToken: nosurf.Token(r),
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|||||||
188
internal/auth/auth.go
Normal file
188
internal/auth/auth.go
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
// Package auth provides authentication and authorization for Silo.
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
|
||||||
|
"github.com/kindredsystems/silo/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Role constants.
|
||||||
|
const (
|
||||||
|
RoleAdmin = "admin"
|
||||||
|
RoleEditor = "editor"
|
||||||
|
RoleViewer = "viewer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// roleRank maps roles to their privilege level for comparison.
|
||||||
|
var roleRank = map[string]int{
|
||||||
|
RoleViewer: 1,
|
||||||
|
RoleEditor: 2,
|
||||||
|
RoleAdmin: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleSatisfies returns true if the user's role meets or exceeds the minimum required role.
|
||||||
|
func RoleSatisfies(userRole, minimumRole string) bool {
|
||||||
|
return roleRank[userRole] >= roleRank[minimumRole]
|
||||||
|
}
|
||||||
|
|
||||||
|
// User represents an authenticated user in the system.
|
||||||
|
type User struct {
|
||||||
|
ID string
|
||||||
|
Username string
|
||||||
|
DisplayName string
|
||||||
|
Email string
|
||||||
|
Role string // "admin", "editor", "viewer"
|
||||||
|
AuthSource string // "local", "ldap", "oidc"
|
||||||
|
}
|
||||||
|
|
||||||
|
// contextKey is a private type for context keys in this package.
|
||||||
|
type contextKey int
|
||||||
|
|
||||||
|
const userContextKey contextKey = iota
|
||||||
|
|
||||||
|
// UserFromContext extracts the authenticated user from the request context.
|
||||||
|
// Returns nil if no user is present (unauthenticated request).
|
||||||
|
func UserFromContext(ctx context.Context) *User {
|
||||||
|
u, _ := ctx.Value(userContextKey).(*User)
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContextWithUser returns a new context carrying the given user.
|
||||||
|
func ContextWithUser(ctx context.Context, u *User) context.Context {
|
||||||
|
return context.WithValue(ctx, userContextKey, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backend is the interface every auth provider must implement.
|
||||||
|
type Backend interface {
|
||||||
|
// Name returns the backend identifier ("local", "ldap").
|
||||||
|
Name() string
|
||||||
|
// Authenticate validates credentials and returns the authenticated user.
|
||||||
|
Authenticate(ctx context.Context, username, password string) (*User, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service orchestrates authentication across all configured backends.
|
||||||
|
type Service struct {
|
||||||
|
users *db.UserRepository
|
||||||
|
tokens *db.TokenRepository
|
||||||
|
backends []Backend
|
||||||
|
logger zerolog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates the auth service with the given backends.
|
||||||
|
func NewService(logger zerolog.Logger, users *db.UserRepository, tokens *db.TokenRepository, backends ...Backend) *Service {
|
||||||
|
return &Service{
|
||||||
|
users: users,
|
||||||
|
tokens: tokens,
|
||||||
|
backends: backends,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate tries each backend in order until one succeeds.
|
||||||
|
// On success, upserts the user into the local database and updates last_login_at.
|
||||||
|
func (s *Service) Authenticate(ctx context.Context, username, password string) (*User, error) {
|
||||||
|
for _, b := range s.backends {
|
||||||
|
user, err := b.Authenticate(ctx, username, password)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Debug().Str("backend", b.Name()).Str("username", username).Err(err).Msg("auth attempt failed")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert user into local database
|
||||||
|
dbUser := &db.User{
|
||||||
|
Username: user.Username,
|
||||||
|
DisplayName: user.DisplayName,
|
||||||
|
Email: user.Email,
|
||||||
|
AuthSource: user.AuthSource,
|
||||||
|
Role: user.Role,
|
||||||
|
}
|
||||||
|
if err := s.users.Upsert(ctx, dbUser); err != nil {
|
||||||
|
return nil, fmt.Errorf("upserting user: %w", err)
|
||||||
|
}
|
||||||
|
user.ID = dbUser.ID
|
||||||
|
|
||||||
|
s.logger.Info().Str("backend", b.Name()).Str("username", username).Msg("user authenticated")
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertOIDCUser upserts a user from OIDC claims into the local database.
|
||||||
|
func (s *Service) UpsertOIDCUser(ctx context.Context, user *User) error {
|
||||||
|
dbUser := &db.User{
|
||||||
|
Username: user.Username,
|
||||||
|
DisplayName: user.DisplayName,
|
||||||
|
Email: user.Email,
|
||||||
|
AuthSource: "oidc",
|
||||||
|
OIDCSubject: &user.ID, // ID carries the OIDC subject before DB upsert
|
||||||
|
Role: user.Role,
|
||||||
|
}
|
||||||
|
if err := s.users.Upsert(ctx, dbUser); err != nil {
|
||||||
|
return fmt.Errorf("upserting oidc user: %w", err)
|
||||||
|
}
|
||||||
|
user.ID = dbUser.ID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateToken checks a raw API token and returns the owning user.
|
||||||
|
func (s *Service) ValidateToken(ctx context.Context, rawToken string) (*User, error) {
|
||||||
|
tokenInfo, err := s.tokens.ValidateToken(ctx, rawToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dbUser, err := s.users.GetByID(ctx, tokenInfo.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("looking up token user: %w", err)
|
||||||
|
}
|
||||||
|
if dbUser == nil || !dbUser.IsActive {
|
||||||
|
return nil, fmt.Errorf("token user not found or inactive")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last_used_at asynchronously
|
||||||
|
go func() {
|
||||||
|
_ = s.tokens.TouchLastUsed(context.Background(), tokenInfo.ID)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return &User{
|
||||||
|
ID: dbUser.ID,
|
||||||
|
Username: dbUser.Username,
|
||||||
|
DisplayName: dbUser.DisplayName,
|
||||||
|
Email: dbUser.Email,
|
||||||
|
Role: dbUser.Role,
|
||||||
|
AuthSource: dbUser.AuthSource,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByID retrieves a user by their database ID.
|
||||||
|
func (s *Service) GetUserByID(ctx context.Context, id string) (*User, error) {
|
||||||
|
dbUser, err := s.users.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if dbUser == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &User{
|
||||||
|
ID: dbUser.ID,
|
||||||
|
Username: dbUser.Username,
|
||||||
|
DisplayName: dbUser.DisplayName,
|
||||||
|
Email: dbUser.Email,
|
||||||
|
Role: dbUser.Role,
|
||||||
|
AuthSource: dbUser.AuthSource,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users returns the underlying user repository for direct access.
|
||||||
|
func (s *Service) Users() *db.UserRepository {
|
||||||
|
return s.users
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tokens returns the underlying token repository for direct access.
|
||||||
|
func (s *Service) Tokens() *db.TokenRepository {
|
||||||
|
return s.tokens
|
||||||
|
}
|
||||||
107
internal/auth/ldap.go
Normal file
107
internal/auth/ldap.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
ldapv3 "github.com/go-ldap/ldap/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LDAPConfig holds settings for the LDAP backend.
|
||||||
|
type LDAPConfig struct {
|
||||||
|
URL string
|
||||||
|
BaseDN string
|
||||||
|
UserSearchDN string
|
||||||
|
BindDN string
|
||||||
|
BindPassword string
|
||||||
|
UserAttr string
|
||||||
|
EmailAttr string
|
||||||
|
DisplayAttr string
|
||||||
|
GroupAttr string
|
||||||
|
RoleMapping map[string][]string // role -> list of group DNs
|
||||||
|
TLSSkipVerify bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// LDAPBackend authenticates via LDAP simple bind against FreeIPA.
|
||||||
|
type LDAPBackend struct {
|
||||||
|
cfg LDAPConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLDAPBackend creates an LDAP authentication backend.
|
||||||
|
func NewLDAPBackend(cfg LDAPConfig) *LDAPBackend {
|
||||||
|
return &LDAPBackend{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns "ldap".
|
||||||
|
func (b *LDAPBackend) Name() string { return "ldap" }
|
||||||
|
|
||||||
|
// Authenticate verifies credentials against the LDAP server.
|
||||||
|
func (b *LDAPBackend) Authenticate(_ context.Context, username, password string) (*User, error) {
|
||||||
|
if username == "" || password == "" {
|
||||||
|
return nil, fmt.Errorf("username and password required")
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := ldapv3.DialURL(b.cfg.URL, ldapv3.DialWithTLSConfig(&tls.Config{
|
||||||
|
InsecureSkipVerify: b.cfg.TLSSkipVerify,
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ldap dial: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Build user DN and bind with user credentials
|
||||||
|
userDN := fmt.Sprintf("%s=%s,%s", b.cfg.UserAttr, ldapv3.EscapeFilter(username), b.cfg.UserSearchDN)
|
||||||
|
if err := conn.Bind(userDN, password); err != nil {
|
||||||
|
return nil, fmt.Errorf("ldap bind failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for user attributes
|
||||||
|
searchReq := ldapv3.NewSearchRequest(
|
||||||
|
userDN,
|
||||||
|
ldapv3.ScopeBaseObject, ldapv3.NeverDerefAliases, 1, 10, false,
|
||||||
|
"(objectClass=*)",
|
||||||
|
[]string{b.cfg.EmailAttr, b.cfg.DisplayAttr, b.cfg.GroupAttr},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
sr, err := conn.Search(searchReq)
|
||||||
|
if err != nil || len(sr.Entries) == 0 {
|
||||||
|
return nil, fmt.Errorf("ldap user search failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := sr.Entries[0]
|
||||||
|
email := entry.GetAttributeValue(b.cfg.EmailAttr)
|
||||||
|
displayName := entry.GetAttributeValue(b.cfg.DisplayAttr)
|
||||||
|
if displayName == "" {
|
||||||
|
displayName = username
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := entry.GetAttributeValues(b.cfg.GroupAttr)
|
||||||
|
role := b.resolveRole(groups)
|
||||||
|
|
||||||
|
return &User{
|
||||||
|
Username: username,
|
||||||
|
DisplayName: displayName,
|
||||||
|
Email: email,
|
||||||
|
Role: role,
|
||||||
|
AuthSource: "ldap",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveRole maps LDAP group memberships to a Silo role.
|
||||||
|
// Checks in priority order: admin > editor > viewer.
|
||||||
|
func (b *LDAPBackend) resolveRole(groups []string) string {
|
||||||
|
groupSet := make(map[string]struct{}, len(groups))
|
||||||
|
for _, g := range groups {
|
||||||
|
groupSet[g] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, role := range []string{RoleAdmin, RoleEditor, RoleViewer} {
|
||||||
|
for _, requiredGroup := range b.cfg.RoleMapping[role] {
|
||||||
|
if _, ok := groupSet[requiredGroup]; ok {
|
||||||
|
return role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return RoleViewer
|
||||||
|
}
|
||||||
68
internal/auth/local.go
Normal file
68
internal/auth/local.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"github.com/kindredsystems/silo/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BcryptCost is the cost parameter for bcrypt hashing.
|
||||||
|
const BcryptCost = 12
|
||||||
|
|
||||||
|
// LocalBackend authenticates against bcrypt password hashes in the users table.
|
||||||
|
type LocalBackend struct {
|
||||||
|
users *db.UserRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLocalBackend creates a local authentication backend.
|
||||||
|
func NewLocalBackend(users *db.UserRepository) *LocalBackend {
|
||||||
|
return &LocalBackend{users: users}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns "local".
|
||||||
|
func (b *LocalBackend) Name() string { return "local" }
|
||||||
|
|
||||||
|
// Authenticate verifies a username and password against the local database.
|
||||||
|
func (b *LocalBackend) Authenticate(ctx context.Context, username, password string) (*User, error) {
|
||||||
|
dbUser, hash, err := b.users.GetWithPasswordHash(ctx, username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("looking up user: %w", err)
|
||||||
|
}
|
||||||
|
if dbUser == nil {
|
||||||
|
return nil, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
if hash == "" {
|
||||||
|
return nil, fmt.Errorf("no local password set")
|
||||||
|
}
|
||||||
|
if !dbUser.IsActive {
|
||||||
|
return nil, fmt.Errorf("account is disabled")
|
||||||
|
}
|
||||||
|
if dbUser.AuthSource != "local" {
|
||||||
|
return nil, fmt.Errorf("user authenticates via %s", dbUser.AuthSource)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid password")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &User{
|
||||||
|
ID: dbUser.ID,
|
||||||
|
Username: dbUser.Username,
|
||||||
|
DisplayName: dbUser.DisplayName,
|
||||||
|
Email: dbUser.Email,
|
||||||
|
Role: dbUser.Role,
|
||||||
|
AuthSource: "local",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashPassword creates a bcrypt hash of the given password.
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("hashing password: %w", err)
|
||||||
|
}
|
||||||
|
return string(hash), nil
|
||||||
|
}
|
||||||
148
internal/auth/oidc.go
Normal file
148
internal/auth/oidc.go
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OIDCConfig holds settings for the OIDC backend.
|
||||||
|
type OIDCConfig struct {
|
||||||
|
IssuerURL string
|
||||||
|
ClientID string
|
||||||
|
ClientSecret string
|
||||||
|
RedirectURL string
|
||||||
|
Scopes []string
|
||||||
|
AdminRole string
|
||||||
|
EditorRole string
|
||||||
|
DefaultRole string
|
||||||
|
}
|
||||||
|
|
||||||
|
// OIDCBackend handles OIDC redirect-based authentication (e.g., Keycloak).
|
||||||
|
type OIDCBackend struct {
|
||||||
|
cfg OIDCConfig
|
||||||
|
provider *oidc.Provider
|
||||||
|
verifier *oidc.IDTokenVerifier
|
||||||
|
oauth oauth2.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOIDCBackend creates and initializes an OIDC backend.
|
||||||
|
// Contacts the issuer URL to discover endpoints, so requires network access.
|
||||||
|
func NewOIDCBackend(ctx context.Context, cfg OIDCConfig) (*OIDCBackend, error) {
|
||||||
|
provider, err := oidc.NewProvider(ctx, cfg.IssuerURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("oidc provider discovery: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
scopes := cfg.Scopes
|
||||||
|
if len(scopes) == 0 {
|
||||||
|
scopes = []string{oidc.ScopeOpenID, "profile", "email"}
|
||||||
|
}
|
||||||
|
|
||||||
|
oauthConfig := oauth2.Config{
|
||||||
|
ClientID: cfg.ClientID,
|
||||||
|
ClientSecret: cfg.ClientSecret,
|
||||||
|
RedirectURL: cfg.RedirectURL,
|
||||||
|
Endpoint: provider.Endpoint(),
|
||||||
|
Scopes: scopes,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &OIDCBackend{
|
||||||
|
cfg: cfg,
|
||||||
|
provider: provider,
|
||||||
|
verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
|
||||||
|
oauth: oauthConfig,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns "oidc".
|
||||||
|
func (b *OIDCBackend) Name() string { return "oidc" }
|
||||||
|
|
||||||
|
// Authenticate is not used for OIDC — the redirect flow is handled by
|
||||||
|
// AuthCodeURL and Exchange instead.
|
||||||
|
func (b *OIDCBackend) Authenticate(_ context.Context, _, _ string) (*User, error) {
|
||||||
|
return nil, fmt.Errorf("OIDC requires redirect flow, not direct authentication")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthCodeURL generates the OIDC authorization URL with the given state parameter.
|
||||||
|
func (b *OIDCBackend) AuthCodeURL(state string) string {
|
||||||
|
return b.oauth.AuthCodeURL(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange handles the OIDC callback: exchanges the authorization code for tokens,
|
||||||
|
// verifies the ID token, and extracts user claims.
|
||||||
|
func (b *OIDCBackend) Exchange(ctx context.Context, code string) (*User, error) {
|
||||||
|
token, err := b.oauth.Exchange(ctx, code)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("oidc code exchange: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rawIDToken, ok := token.Extra("id_token").(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("no id_token in oidc response")
|
||||||
|
}
|
||||||
|
|
||||||
|
idToken, err := b.verifier.Verify(ctx, rawIDToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("oidc token verification: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var claims struct {
|
||||||
|
Subject string `json:"sub"`
|
||||||
|
PreferredUsername string `json:"preferred_username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
RealmAccess struct {
|
||||||
|
Roles []string `json:"roles"`
|
||||||
|
} `json:"realm_access"`
|
||||||
|
}
|
||||||
|
if err := idToken.Claims(&claims); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing oidc claims: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
username := claims.PreferredUsername
|
||||||
|
if username == "" {
|
||||||
|
username = claims.Subject
|
||||||
|
}
|
||||||
|
displayName := claims.Name
|
||||||
|
if displayName == "" {
|
||||||
|
displayName = username
|
||||||
|
}
|
||||||
|
|
||||||
|
role := b.resolveRole(claims.RealmAccess.Roles)
|
||||||
|
|
||||||
|
return &User{
|
||||||
|
ID: claims.Subject, // Temporarily holds OIDC subject; replaced by DB ID after upsert
|
||||||
|
Username: username,
|
||||||
|
DisplayName: displayName,
|
||||||
|
Email: claims.Email,
|
||||||
|
Role: role,
|
||||||
|
AuthSource: "oidc",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveRole maps Keycloak realm roles to a Silo role.
|
||||||
|
func (b *OIDCBackend) resolveRole(roles []string) string {
|
||||||
|
roleSet := make(map[string]struct{}, len(roles))
|
||||||
|
for _, r := range roles {
|
||||||
|
roleSet[r] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if b.cfg.AdminRole != "" {
|
||||||
|
if _, ok := roleSet[b.cfg.AdminRole]; ok {
|
||||||
|
return RoleAdmin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b.cfg.EditorRole != "" {
|
||||||
|
if _, ok := roleSet[b.cfg.EditorRole]; ok {
|
||||||
|
return RoleEditor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if b.cfg.DefaultRole != "" {
|
||||||
|
return b.cfg.DefaultRole
|
||||||
|
}
|
||||||
|
return RoleViewer
|
||||||
|
}
|
||||||
51
internal/auth/token.go
Normal file
51
internal/auth/token.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kindredsystems/silo/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tokenPrefixStr = "silo_"
|
||||||
|
tokenRawBytes = 32 // 32 random bytes = 64 hex chars
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateToken creates a new API token. Returns the raw token string
|
||||||
|
// (shown once to the user) and the persisted token info.
|
||||||
|
func (s *Service) GenerateToken(ctx context.Context, userID, name string, scopes []string, expiresAt *time.Time) (string, *db.TokenInfo, error) {
|
||||||
|
rawBytes := make([]byte, tokenRawBytes)
|
||||||
|
if _, err := rand.Read(rawBytes); err != nil {
|
||||||
|
return "", nil, fmt.Errorf("generating random bytes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rawToken := tokenPrefixStr + hex.EncodeToString(rawBytes)
|
||||||
|
|
||||||
|
hash := sha256.Sum256([]byte(rawToken))
|
||||||
|
tokenHash := hex.EncodeToString(hash[:])
|
||||||
|
|
||||||
|
// Prefix for display: "silo_" + first 8 hex chars
|
||||||
|
displayPrefix := rawToken[:len(tokenPrefixStr)+8]
|
||||||
|
|
||||||
|
info, err := s.tokens.Create(ctx, userID, name, tokenHash, displayPrefix, scopes, expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawToken, info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTokens returns all tokens for a user.
|
||||||
|
func (s *Service) ListTokens(ctx context.Context, userID string) ([]*db.TokenInfo, error) {
|
||||||
|
return s.tokens.ListByUser(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeToken deletes a token by ID, ensuring it belongs to the given user.
|
||||||
|
func (s *Service) RevokeToken(ctx context.Context, userID, tokenID string) error {
|
||||||
|
return s.tokens.Delete(ctx, userID, tokenID)
|
||||||
|
}
|
||||||
@@ -16,6 +16,58 @@ type Config struct {
|
|||||||
Schemas SchemasConfig `yaml:"schemas"`
|
Schemas SchemasConfig `yaml:"schemas"`
|
||||||
FreeCAD FreeCADConfig `yaml:"freecad"`
|
FreeCAD FreeCADConfig `yaml:"freecad"`
|
||||||
Odoo OdooConfig `yaml:"odoo"`
|
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.
|
// ServerConfig holds HTTP server settings.
|
||||||
@@ -128,5 +180,39 @@ func Load(path string) (*Config, error) {
|
|||||||
cfg.Storage.SecretKey = v
|
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
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ type Item struct {
|
|||||||
CurrentRevision int
|
CurrentRevision int
|
||||||
CADSyncedAt *time.Time
|
CADSyncedAt *time.Time
|
||||||
CADFilePath *string
|
CADFilePath *string
|
||||||
|
CreatedBy *string
|
||||||
|
UpdatedBy *string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Revision represents a revision record.
|
// 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 {
|
return r.db.Tx(ctx, func(tx pgx.Tx) error {
|
||||||
// Insert item
|
// Insert item
|
||||||
err := tx.QueryRow(ctx, `
|
err := tx.QueryRow(ctx, `
|
||||||
INSERT INTO items (part_number, schema_id, item_type, description)
|
INSERT INTO items (part_number, schema_id, item_type, description, created_by)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
RETURNING id, created_at, updated_at, current_revision
|
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,
|
&item.ID, &item.CreatedAt, &item.UpdatedAt, &item.CurrentRevision,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -101,9 +103,9 @@ func (r *ItemRepository) Create(ctx context.Context, item *Item, properties map[
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err = tx.Exec(ctx, `
|
_, err = tx.Exec(ctx, `
|
||||||
INSERT INTO revisions (item_id, revision_number, properties)
|
INSERT INTO revisions (item_id, revision_number, properties, created_by)
|
||||||
VALUES ($1, 1, $2)
|
VALUES ($1, 1, $2, $3)
|
||||||
`, item.ID, propsJSON)
|
`, item.ID, propsJSON, item.CreatedBy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("inserting revision: %w", err)
|
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.
|
// Update modifies an item's part number, type, and description.
|
||||||
// The UUID remains stable.
|
// 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, `
|
_, err := r.db.pool.Exec(ctx, `
|
||||||
UPDATE items
|
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
|
WHERE id = $1 AND archived_at IS NULL
|
||||||
`, id, partNumber, itemType, description)
|
`, id, partNumber, itemType, description, updatedBy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("updating item: %w", err)
|
return fmt.Errorf("updating item: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type Project struct {
|
|||||||
Name string
|
Name string
|
||||||
Description string
|
Description string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
CreatedBy *string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProjectRepository provides project database operations.
|
// 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.
|
// Create inserts a new project.
|
||||||
func (r *ProjectRepository) Create(ctx context.Context, p *Project) error {
|
func (r *ProjectRepository) Create(ctx context.Context, p *Project) error {
|
||||||
return r.db.pool.QueryRow(ctx, `
|
return r.db.pool.QueryRow(ctx, `
|
||||||
INSERT INTO projects (code, name, description)
|
INSERT INTO projects (code, name, description, created_by)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING id, created_at
|
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.
|
// Update updates a project's name and description.
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ type Relationship struct {
|
|||||||
ParentRevisionID *string
|
ParentRevisionID *string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
|
CreatedBy *string
|
||||||
|
UpdatedBy *string
|
||||||
}
|
}
|
||||||
|
|
||||||
// BOMEntry is a denormalized row for BOM display, combining relationship
|
// 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, `
|
err = r.db.pool.QueryRow(ctx, `
|
||||||
INSERT INTO relationships (
|
INSERT INTO relationships (
|
||||||
parent_item_id, child_item_id, rel_type, quantity, unit,
|
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
|
RETURNING id, created_at, updated_at
|
||||||
`, rel.ParentItemID, rel.ChildItemID, rel.RelType, rel.Quantity, rel.Unit,
|
`, rel.ParentItemID, rel.ChildItemID, rel.RelType, rel.Quantity, rel.Unit,
|
||||||
rel.ReferenceDesignators, rel.ChildRevision, metadataJSON, rel.ParentRevisionID,
|
rel.ReferenceDesignators, rel.ChildRevision, metadataJSON, rel.ParentRevisionID,
|
||||||
|
rel.CreatedBy,
|
||||||
).Scan(&rel.ID, &rel.CreatedAt, &rel.UpdatedAt)
|
).Scan(&rel.ID, &rel.CreatedAt, &rel.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("inserting relationship: %w", err)
|
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.
|
// 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
|
// Build dynamic update query
|
||||||
query := "UPDATE relationships SET updated_at = now()"
|
query := "UPDATE relationships SET updated_at = now()"
|
||||||
args := []any{}
|
args := []any{}
|
||||||
argNum := 1
|
argNum := 1
|
||||||
|
|
||||||
|
if updatedBy != nil {
|
||||||
|
query += fmt.Sprintf(", updated_by = $%d", argNum)
|
||||||
|
args = append(args, *updatedBy)
|
||||||
|
argNum++
|
||||||
|
}
|
||||||
|
|
||||||
if relType != nil {
|
if relType != nil {
|
||||||
query += fmt.Sprintf(", rel_type = $%d", argNum)
|
query += fmt.Sprintf(", rel_type = $%d", argNum)
|
||||||
args = append(args, *relType)
|
args = append(args, *relType)
|
||||||
|
|||||||
371
internal/db/users.go
Normal file
371
internal/db/users.go
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User represents a user in the database.
|
||||||
|
type User struct {
|
||||||
|
ID string
|
||||||
|
Username string
|
||||||
|
DisplayName string
|
||||||
|
Email string
|
||||||
|
AuthSource string
|
||||||
|
OIDCSubject *string
|
||||||
|
Role string
|
||||||
|
IsActive bool
|
||||||
|
LastLoginAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenInfo holds metadata about an API token (never the raw token or hash).
|
||||||
|
type TokenInfo struct {
|
||||||
|
ID string
|
||||||
|
UserID string
|
||||||
|
Name string
|
||||||
|
TokenPrefix string
|
||||||
|
Scopes []string
|
||||||
|
LastUsedAt *time.Time
|
||||||
|
ExpiresAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserRepository provides user database operations.
|
||||||
|
type UserRepository struct {
|
||||||
|
db *DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserRepository creates a new user repository.
|
||||||
|
func NewUserRepository(db *DB) *UserRepository {
|
||||||
|
return &UserRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID returns a user by ID.
|
||||||
|
func (r *UserRepository) GetByID(ctx context.Context, id string) (*User, error) {
|
||||||
|
u := &User{}
|
||||||
|
var email, oidcSubject *string
|
||||||
|
err := r.db.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, username, display_name, email, auth_source, oidc_subject,
|
||||||
|
role, is_active, last_login_at, created_at, updated_at
|
||||||
|
FROM users WHERE id = $1
|
||||||
|
`, id).Scan(
|
||||||
|
&u.ID, &u.Username, &u.DisplayName, &email, &u.AuthSource, &oidcSubject,
|
||||||
|
&u.Role, &u.IsActive, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getting user by id: %w", err)
|
||||||
|
}
|
||||||
|
if email != nil {
|
||||||
|
u.Email = *email
|
||||||
|
}
|
||||||
|
u.OIDCSubject = oidcSubject
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByUsername returns a user by username.
|
||||||
|
func (r *UserRepository) GetByUsername(ctx context.Context, username string) (*User, error) {
|
||||||
|
u := &User{}
|
||||||
|
var email, oidcSubject *string
|
||||||
|
err := r.db.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, username, display_name, email, auth_source, oidc_subject,
|
||||||
|
role, is_active, last_login_at, created_at, updated_at
|
||||||
|
FROM users WHERE username = $1
|
||||||
|
`, username).Scan(
|
||||||
|
&u.ID, &u.Username, &u.DisplayName, &email, &u.AuthSource, &oidcSubject,
|
||||||
|
&u.Role, &u.IsActive, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getting user by username: %w", err)
|
||||||
|
}
|
||||||
|
if email != nil {
|
||||||
|
u.Email = *email
|
||||||
|
}
|
||||||
|
u.OIDCSubject = oidcSubject
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWithPasswordHash returns a user and their password hash for local authentication.
|
||||||
|
func (r *UserRepository) GetWithPasswordHash(ctx context.Context, username string) (*User, string, error) {
|
||||||
|
u := &User{}
|
||||||
|
var email, oidcSubject, passwordHash *string
|
||||||
|
err := r.db.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, username, display_name, email, password_hash, auth_source,
|
||||||
|
oidc_subject, role, is_active, last_login_at, created_at, updated_at
|
||||||
|
FROM users WHERE username = $1
|
||||||
|
`, username).Scan(
|
||||||
|
&u.ID, &u.Username, &u.DisplayName, &email, &passwordHash, &u.AuthSource,
|
||||||
|
&oidcSubject, &u.Role, &u.IsActive, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, "", nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("getting user with password: %w", err)
|
||||||
|
}
|
||||||
|
if email != nil {
|
||||||
|
u.Email = *email
|
||||||
|
}
|
||||||
|
u.OIDCSubject = oidcSubject
|
||||||
|
hash := ""
|
||||||
|
if passwordHash != nil {
|
||||||
|
hash = *passwordHash
|
||||||
|
}
|
||||||
|
return u, hash, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByOIDCSubject returns a user by their OIDC subject claim.
|
||||||
|
func (r *UserRepository) GetByOIDCSubject(ctx context.Context, subject string) (*User, error) {
|
||||||
|
u := &User{}
|
||||||
|
var email, oidcSubject *string
|
||||||
|
err := r.db.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, username, display_name, email, auth_source, oidc_subject,
|
||||||
|
role, is_active, last_login_at, created_at, updated_at
|
||||||
|
FROM users WHERE oidc_subject = $1
|
||||||
|
`, subject).Scan(
|
||||||
|
&u.ID, &u.Username, &u.DisplayName, &email, &u.AuthSource, &oidcSubject,
|
||||||
|
&u.Role, &u.IsActive, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getting user by oidc subject: %w", err)
|
||||||
|
}
|
||||||
|
if email != nil {
|
||||||
|
u.Email = *email
|
||||||
|
}
|
||||||
|
u.OIDCSubject = oidcSubject
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert inserts a new user or updates an existing one by username.
|
||||||
|
// Used when LDAP/OIDC users log in to sync their external attributes.
|
||||||
|
func (r *UserRepository) Upsert(ctx context.Context, u *User) error {
|
||||||
|
var email *string
|
||||||
|
if u.Email != "" {
|
||||||
|
email = &u.Email
|
||||||
|
}
|
||||||
|
err := r.db.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO users (username, display_name, email, auth_source, oidc_subject, role, last_login_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, now())
|
||||||
|
ON CONFLICT (username) DO UPDATE SET
|
||||||
|
display_name = EXCLUDED.display_name,
|
||||||
|
email = EXCLUDED.email,
|
||||||
|
auth_source = EXCLUDED.auth_source,
|
||||||
|
oidc_subject = COALESCE(EXCLUDED.oidc_subject, users.oidc_subject),
|
||||||
|
role = EXCLUDED.role,
|
||||||
|
last_login_at = now(),
|
||||||
|
updated_at = now()
|
||||||
|
RETURNING id
|
||||||
|
`, u.Username, u.DisplayName, email, u.AuthSource, u.OIDCSubject, u.Role).Scan(&u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("upserting user: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create inserts a new local user with a password hash.
|
||||||
|
func (r *UserRepository) Create(ctx context.Context, u *User, passwordHash string) error {
|
||||||
|
var email *string
|
||||||
|
if u.Email != "" {
|
||||||
|
email = &u.Email
|
||||||
|
}
|
||||||
|
err := r.db.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO users (username, display_name, email, password_hash, auth_source, role)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id
|
||||||
|
`, u.Username, u.DisplayName, email, passwordHash, u.AuthSource, u.Role).Scan(&u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating user: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLastLogin sets last_login_at to now.
|
||||||
|
func (r *UserRepository) UpdateLastLogin(ctx context.Context, id string) error {
|
||||||
|
_, err := r.db.pool.Exec(ctx, `
|
||||||
|
UPDATE users SET last_login_at = now(), updated_at = now() WHERE id = $1
|
||||||
|
`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPassword updates the password hash for a local user.
|
||||||
|
func (r *UserRepository) SetPassword(ctx context.Context, id string, hash string) error {
|
||||||
|
_, err := r.db.pool.Exec(ctx, `
|
||||||
|
UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1
|
||||||
|
`, id, hash)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetActive enables or disables a user.
|
||||||
|
func (r *UserRepository) SetActive(ctx context.Context, id string, active bool) error {
|
||||||
|
_, err := r.db.pool.Exec(ctx, `
|
||||||
|
UPDATE users SET is_active = $2, updated_at = now() WHERE id = $1
|
||||||
|
`, id, active)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRole updates the role for a user.
|
||||||
|
func (r *UserRepository) SetRole(ctx context.Context, id string, role string) error {
|
||||||
|
_, err := r.db.pool.Exec(ctx, `
|
||||||
|
UPDATE users SET role = $2, updated_at = now() WHERE id = $1
|
||||||
|
`, id, role)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns all users ordered by username.
|
||||||
|
func (r *UserRepository) List(ctx context.Context) ([]*User, error) {
|
||||||
|
rows, err := r.db.pool.Query(ctx, `
|
||||||
|
SELECT id, username, display_name, email, auth_source, oidc_subject,
|
||||||
|
role, is_active, last_login_at, created_at, updated_at
|
||||||
|
FROM users
|
||||||
|
ORDER BY username
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing users: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var users []*User
|
||||||
|
for rows.Next() {
|
||||||
|
u := &User{}
|
||||||
|
var email, oidcSubject *string
|
||||||
|
if err := rows.Scan(
|
||||||
|
&u.ID, &u.Username, &u.DisplayName, &email, &u.AuthSource, &oidcSubject,
|
||||||
|
&u.Role, &u.IsActive, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if email != nil {
|
||||||
|
u.Email = *email
|
||||||
|
}
|
||||||
|
u.OIDCSubject = oidcSubject
|
||||||
|
users = append(users, u)
|
||||||
|
}
|
||||||
|
return users, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenRepository provides API token database operations.
|
||||||
|
type TokenRepository struct {
|
||||||
|
db *DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTokenRepository creates a new token repository.
|
||||||
|
func NewTokenRepository(db *DB) *TokenRepository {
|
||||||
|
return &TokenRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create inserts a new API token record.
|
||||||
|
func (r *TokenRepository) Create(ctx context.Context, userID, name, tokenHash, tokenPrefix string, scopes []string, expiresAt *time.Time) (*TokenInfo, error) {
|
||||||
|
t := &TokenInfo{}
|
||||||
|
if scopes == nil {
|
||||||
|
scopes = []string{}
|
||||||
|
}
|
||||||
|
err := r.db.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, user_id, name, token_prefix, scopes, last_used_at, expires_at, created_at
|
||||||
|
`, userID, name, tokenHash, tokenPrefix, scopes, expiresAt).Scan(
|
||||||
|
&t.ID, &t.UserID, &t.Name, &t.TokenPrefix, &t.Scopes,
|
||||||
|
&t.LastUsedAt, &t.ExpiresAt, &t.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating api token: %w", err)
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateToken hashes the raw token, looks it up, and checks expiry and user active status.
|
||||||
|
// Returns the token info and user ID on success.
|
||||||
|
func (r *TokenRepository) ValidateToken(ctx context.Context, rawToken string) (*TokenInfo, error) {
|
||||||
|
hash := sha256.Sum256([]byte(rawToken))
|
||||||
|
tokenHash := hex.EncodeToString(hash[:])
|
||||||
|
|
||||||
|
t := &TokenInfo{}
|
||||||
|
var isActive bool
|
||||||
|
err := r.db.pool.QueryRow(ctx, `
|
||||||
|
SELECT t.id, t.user_id, t.name, t.token_prefix, t.scopes,
|
||||||
|
t.last_used_at, t.expires_at, t.created_at, u.is_active
|
||||||
|
FROM api_tokens t
|
||||||
|
JOIN users u ON u.id = t.user_id
|
||||||
|
WHERE t.token_hash = $1
|
||||||
|
`, tokenHash).Scan(
|
||||||
|
&t.ID, &t.UserID, &t.Name, &t.TokenPrefix, &t.Scopes,
|
||||||
|
&t.LastUsedAt, &t.ExpiresAt, &t.CreatedAt, &isActive,
|
||||||
|
)
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, fmt.Errorf("invalid token")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("validating token: %w", err)
|
||||||
|
}
|
||||||
|
if !isActive {
|
||||||
|
return nil, fmt.Errorf("user account is disabled")
|
||||||
|
}
|
||||||
|
if t.ExpiresAt != nil && t.ExpiresAt.Before(time.Now()) {
|
||||||
|
return nil, fmt.Errorf("token has expired")
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByUser returns all tokens for a user (without hashes).
|
||||||
|
func (r *TokenRepository) ListByUser(ctx context.Context, userID string) ([]*TokenInfo, error) {
|
||||||
|
rows, err := r.db.pool.Query(ctx, `
|
||||||
|
SELECT id, user_id, name, token_prefix, scopes, last_used_at, expires_at, created_at
|
||||||
|
FROM api_tokens
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing tokens: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var tokens []*TokenInfo
|
||||||
|
for rows.Next() {
|
||||||
|
t := &TokenInfo{}
|
||||||
|
if err := rows.Scan(
|
||||||
|
&t.ID, &t.UserID, &t.Name, &t.TokenPrefix, &t.Scopes,
|
||||||
|
&t.LastUsedAt, &t.ExpiresAt, &t.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tokens = append(tokens, t)
|
||||||
|
}
|
||||||
|
return tokens, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a token, ensuring it belongs to the given user.
|
||||||
|
func (r *TokenRepository) Delete(ctx context.Context, userID, tokenID string) error {
|
||||||
|
tag, err := r.db.pool.Exec(ctx, `
|
||||||
|
DELETE FROM api_tokens WHERE id = $1 AND user_id = $2
|
||||||
|
`, tokenID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting token: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return fmt.Errorf("token not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TouchLastUsed updates the last_used_at timestamp for a token.
|
||||||
|
func (r *TokenRepository) TouchLastUsed(ctx context.Context, tokenID string) error {
|
||||||
|
_, err := r.db.pool.Exec(ctx, `
|
||||||
|
UPDATE api_tokens SET last_used_at = now() WHERE id = $1
|
||||||
|
`, tokenID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
100
migrations/009_auth.sql
Normal file
100
migrations/009_auth.sql
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
-- Authentication: users, API tokens, sessions, audit log, and user tracking columns
|
||||||
|
-- Migration: 009_auth
|
||||||
|
-- Date: 2026-01
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
-- Users
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
display_name TEXT NOT NULL DEFAULT '',
|
||||||
|
email TEXT,
|
||||||
|
password_hash TEXT, -- NULL for LDAP/OIDC-only users
|
||||||
|
auth_source TEXT NOT NULL DEFAULT 'local', -- 'local', 'ldap', 'oidc'
|
||||||
|
oidc_subject TEXT, -- Stable OIDC sub claim
|
||||||
|
role TEXT NOT NULL DEFAULT 'viewer', -- 'admin', 'editor', 'viewer'
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
last_login_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_users_oidc_subject ON users(oidc_subject) WHERE oidc_subject IS NOT NULL;
|
||||||
|
CREATE INDEX idx_users_username ON users(username);
|
||||||
|
CREATE INDEX idx_users_auth_source ON users(auth_source);
|
||||||
|
CREATE INDEX idx_users_role ON users(role);
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
-- API Tokens
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE api_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL, -- Human-readable label
|
||||||
|
token_hash TEXT UNIQUE NOT NULL, -- SHA-256 of raw token
|
||||||
|
token_prefix TEXT NOT NULL, -- First 13 chars for display (silo_ + 8 hex)
|
||||||
|
scopes TEXT[] NOT NULL DEFAULT '{}', -- Reserved for future fine-grained permissions
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
expires_at TIMESTAMPTZ, -- NULL = never expires
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_api_tokens_user ON api_tokens(user_id);
|
||||||
|
CREATE INDEX idx_api_tokens_hash ON api_tokens(token_hash);
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
-- Sessions (schema required by alexedwards/scs pgxstore)
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
data BYTEA NOT NULL,
|
||||||
|
expiry TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_sessions_expiry ON sessions(expiry);
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
-- Audit Log
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE audit_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL, -- 'create', 'update', 'delete', 'login', etc.
|
||||||
|
resource_type TEXT NOT NULL, -- 'item', 'revision', 'project', 'relationship'
|
||||||
|
resource_id TEXT NOT NULL,
|
||||||
|
details JSONB,
|
||||||
|
ip_address TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_audit_log_timestamp ON audit_log(timestamp DESC);
|
||||||
|
CREATE INDEX idx_audit_log_user ON audit_log(username);
|
||||||
|
CREATE INDEX idx_audit_log_resource ON audit_log(resource_type, resource_id);
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
-- Add user tracking columns to existing tables
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Items: track who created and last updated
|
||||||
|
ALTER TABLE items ADD COLUMN created_by TEXT;
|
||||||
|
ALTER TABLE items ADD COLUMN updated_by TEXT;
|
||||||
|
|
||||||
|
-- Relationships/BOM: track who created and last updated
|
||||||
|
ALTER TABLE relationships ADD COLUMN created_by TEXT;
|
||||||
|
ALTER TABLE relationships ADD COLUMN updated_by TEXT;
|
||||||
|
|
||||||
|
-- Projects: track who created
|
||||||
|
ALTER TABLE projects ADD COLUMN created_by TEXT;
|
||||||
|
|
||||||
|
-- Sync log: track who triggered the sync
|
||||||
|
ALTER TABLE sync_log ADD COLUMN triggered_by TEXT;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -40,6 +40,15 @@ def _get_api_url() -> str:
|
|||||||
return url
|
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:
|
def _get_ssl_verify() -> bool:
|
||||||
"""Get SSL verification setting from preferences."""
|
"""Get SSL verification setting from preferences."""
|
||||||
param = FreeCAD.ParamGet(_PREF_GROUP)
|
param = FreeCAD.ParamGet(_PREF_GROUP)
|
||||||
@@ -306,6 +315,9 @@ class SiloClient:
|
|||||||
"""Make HTTP request to Silo API."""
|
"""Make HTTP request to Silo API."""
|
||||||
url = f"{self.base_url}{path}"
|
url = f"{self.base_url}{path}"
|
||||||
headers = {"Content-Type": "application/json"}
|
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
|
body = json.dumps(data).encode() if data else None
|
||||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||||
|
|
||||||
@@ -322,6 +334,9 @@ class SiloClient:
|
|||||||
"""Download a file from MinIO storage."""
|
"""Download a file from MinIO storage."""
|
||||||
url = f"{self.base_url}/items/{part_number}/file/{revision}"
|
url = f"{self.base_url}/items/{part_number}/file/{revision}"
|
||||||
req = urllib.request.Request(url, method="GET")
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
token = _get_api_token()
|
||||||
|
if token:
|
||||||
|
req.add_header("Authorization", f"Bearer {token}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, context=_get_ssl_context()) as resp:
|
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-Type": f"multipart/form-data; boundary={boundary}",
|
||||||
"Content-Length": str(len(body)),
|
"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")
|
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user