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
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/kindredsystems/silo/internal/config"
|
||||
@@ -35,6 +38,8 @@ func main() {
|
||||
cmdRevisions(ctx)
|
||||
case "schemas":
|
||||
cmdSchemas(ctx)
|
||||
case "token":
|
||||
cmdToken(ctx)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", cmd)
|
||||
printUsage()
|
||||
@@ -53,12 +58,25 @@ Commands:
|
||||
show Show item details
|
||||
revisions Show item revision history
|
||||
schemas List available schemas
|
||||
token Manage API tokens (create, list, revoke)
|
||||
|
||||
Token subcommands:
|
||||
silo token create --name "label" Create a new API token
|
||||
silo token list List your API tokens
|
||||
silo token revoke <id> Revoke a token
|
||||
|
||||
Environment variables for API access:
|
||||
SILO_API_URL Base URL of the Silo server (e.g., https://silo.kindred.internal)
|
||||
SILO_API_TOKEN API token for authentication
|
||||
|
||||
Examples:
|
||||
silo register --schema kindred-rd --project PROTO --type AS
|
||||
silo list --type assembly
|
||||
silo show PROTO-AS-0001
|
||||
silo revisions PROTO-AS-0001`)
|
||||
silo revisions PROTO-AS-0001
|
||||
silo token create --name "FreeCAD workstation"
|
||||
silo token list
|
||||
silo token revoke 550e8400-e29b-41d4-a716-446655440000`)
|
||||
}
|
||||
|
||||
func loadConfig() *config.Config {
|
||||
@@ -295,6 +313,163 @@ func getSchemaID(ctx context.Context, database *db.DB, name string) *string {
|
||||
return &id
|
||||
}
|
||||
|
||||
// apiClient returns the base URL and an *http.Client with the Bearer token set.
|
||||
func apiRequest(method, path string, body any) (*http.Response, error) {
|
||||
baseURL := os.Getenv("SILO_API_URL")
|
||||
if baseURL == "" {
|
||||
fmt.Fprintln(os.Stderr, "SILO_API_URL environment variable is required for token commands")
|
||||
os.Exit(1)
|
||||
}
|
||||
token := os.Getenv("SILO_API_TOKEN")
|
||||
if token == "" {
|
||||
fmt.Fprintln(os.Stderr, "SILO_API_TOKEN environment variable is required for token commands")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshaling request body: %w", err)
|
||||
}
|
||||
reqBody = bytes.NewReader(b)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, baseURL+path, reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
return http.DefaultClient.Do(req)
|
||||
}
|
||||
|
||||
func cmdToken(_ context.Context) {
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Fprintln(os.Stderr, "Usage: silo token <create|list|revoke> [options]")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
subcmd := os.Args[2]
|
||||
switch subcmd {
|
||||
case "create":
|
||||
cmdTokenCreate()
|
||||
case "list":
|
||||
cmdTokenList()
|
||||
case "revoke":
|
||||
cmdTokenRevoke()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Unknown token subcommand: %s\n", subcmd)
|
||||
fmt.Fprintln(os.Stderr, "Usage: silo token <create|list|revoke> [options]")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdTokenCreate() {
|
||||
var name string
|
||||
args := os.Args[3:]
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--name", "-n":
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
name = args[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
fmt.Fprintln(os.Stderr, "Usage: silo token create --name \"label\"")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
resp, err := apiRequest("POST", "/api/auth/tokens", map[string]any{"name": name})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Fprintf(os.Stderr, "Error creating token (%d): %s\n", resp.StatusCode, string(body))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error decoding response: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Token created: %s\n", result["name"])
|
||||
fmt.Printf("API Token: %s\n", result["token"])
|
||||
fmt.Println("Save this token — it will not be shown again.")
|
||||
}
|
||||
|
||||
func cmdTokenList() {
|
||||
resp, err := apiRequest("GET", "/api/auth/tokens", nil)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Fprintf(os.Stderr, "Error listing tokens (%d): %s\n", resp.StatusCode, string(body))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var tokens []map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error decoding response: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(tokens) == 0 {
|
||||
fmt.Println("No API tokens.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("%-36s %-20s %-15s %s\n", "ID", "NAME", "PREFIX", "CREATED")
|
||||
for _, t := range tokens {
|
||||
id, _ := t["id"].(string)
|
||||
name, _ := t["name"].(string)
|
||||
prefix, _ := t["token_prefix"].(string)
|
||||
created, _ := t["created_at"].(string)
|
||||
if len(created) > 10 {
|
||||
created = created[:10]
|
||||
}
|
||||
fmt.Printf("%-36s %-20s %-15s %s\n", id, name, prefix+"...", created)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdTokenRevoke() {
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Fprintln(os.Stderr, "Usage: silo token revoke <token-id>")
|
||||
os.Exit(1)
|
||||
}
|
||||
tokenID := os.Args[3]
|
||||
|
||||
resp, err := apiRequest("DELETE", "/api/auth/tokens/"+tokenID, nil)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Fprintf(os.Stderr, "Error revoking token (%d): %s\n", resp.StatusCode, string(body))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("Token revoked.")
|
||||
}
|
||||
|
||||
func mapPartType(code string) string {
|
||||
types := map[string]string{
|
||||
"AS": "assembly",
|
||||
|
||||
@@ -11,7 +11,10 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/scs/pgxstore"
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/kindredsystems/silo/internal/api"
|
||||
"github.com/kindredsystems/silo/internal/auth"
|
||||
"github.com/kindredsystems/silo/internal/config"
|
||||
"github.com/kindredsystems/silo/internal/db"
|
||||
"github.com/kindredsystems/silo/internal/schema"
|
||||
@@ -85,8 +88,99 @@ func main() {
|
||||
}
|
||||
logger.Info().Int("count", len(schemas)).Msg("loaded schemas")
|
||||
|
||||
// Initialize authentication
|
||||
userRepo := db.NewUserRepository(database)
|
||||
tokenRepo := db.NewTokenRepository(database)
|
||||
|
||||
// Session manager (PostgreSQL-backed via scs + pgxstore)
|
||||
sessionManager := scs.New()
|
||||
sessionManager.Store = pgxstore.New(database.Pool())
|
||||
sessionManager.Lifetime = 24 * time.Hour
|
||||
sessionManager.Cookie.Name = "silo_session"
|
||||
sessionManager.Cookie.HttpOnly = true
|
||||
sessionManager.Cookie.Secure = cfg.Auth.Enabled // Secure cookies when auth is active
|
||||
sessionManager.Cookie.SameSite = http.SameSiteLaxMode
|
||||
|
||||
// Build auth backends from config
|
||||
var backends []auth.Backend
|
||||
if cfg.Auth.Local.Enabled {
|
||||
backends = append(backends, auth.NewLocalBackend(userRepo))
|
||||
logger.Info().Msg("auth backend: local")
|
||||
}
|
||||
if cfg.Auth.LDAP.Enabled {
|
||||
backends = append(backends, auth.NewLDAPBackend(auth.LDAPConfig{
|
||||
URL: cfg.Auth.LDAP.URL,
|
||||
BaseDN: cfg.Auth.LDAP.BaseDN,
|
||||
UserSearchDN: cfg.Auth.LDAP.UserSearchDN,
|
||||
BindDN: cfg.Auth.LDAP.BindDN,
|
||||
BindPassword: cfg.Auth.LDAP.BindPassword,
|
||||
UserAttr: cfg.Auth.LDAP.UserAttr,
|
||||
EmailAttr: cfg.Auth.LDAP.EmailAttr,
|
||||
DisplayAttr: cfg.Auth.LDAP.DisplayAttr,
|
||||
GroupAttr: cfg.Auth.LDAP.GroupAttr,
|
||||
RoleMapping: cfg.Auth.LDAP.RoleMapping,
|
||||
TLSSkipVerify: cfg.Auth.LDAP.TLSSkipVerify,
|
||||
}))
|
||||
logger.Info().Str("url", cfg.Auth.LDAP.URL).Msg("auth backend: ldap")
|
||||
}
|
||||
|
||||
authService := auth.NewService(logger, userRepo, tokenRepo, backends...)
|
||||
|
||||
// OIDC backend (separate from the Backend interface since it uses redirect flow)
|
||||
var oidcBackend *auth.OIDCBackend
|
||||
if cfg.Auth.OIDC.Enabled {
|
||||
oidcBackend, err = auth.NewOIDCBackend(ctx, auth.OIDCConfig{
|
||||
IssuerURL: cfg.Auth.OIDC.IssuerURL,
|
||||
ClientID: cfg.Auth.OIDC.ClientID,
|
||||
ClientSecret: cfg.Auth.OIDC.ClientSecret,
|
||||
RedirectURL: cfg.Auth.OIDC.RedirectURL,
|
||||
Scopes: cfg.Auth.OIDC.Scopes,
|
||||
AdminRole: cfg.Auth.OIDC.AdminRole,
|
||||
EditorRole: cfg.Auth.OIDC.EditorRole,
|
||||
DefaultRole: cfg.Auth.OIDC.DefaultRole,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("failed to initialize OIDC backend")
|
||||
}
|
||||
logger.Info().Str("issuer", cfg.Auth.OIDC.IssuerURL).Msg("auth backend: oidc")
|
||||
}
|
||||
|
||||
if cfg.Auth.Enabled {
|
||||
logger.Info().Msg("authentication enabled")
|
||||
} else {
|
||||
logger.Warn().Msg("authentication disabled - all routes are open")
|
||||
}
|
||||
|
||||
// Seed default admin account (idempotent — skips if user already exists)
|
||||
if u := cfg.Auth.Local.DefaultAdminUsername; u != "" {
|
||||
if p := cfg.Auth.Local.DefaultAdminPassword; p != "" {
|
||||
existing, err := userRepo.GetByUsername(ctx, u)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("failed to check for default admin user")
|
||||
} else if existing != nil {
|
||||
logger.Debug().Str("username", u).Msg("default admin user already exists, skipping")
|
||||
} else {
|
||||
hash, err := auth.HashPassword(p)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("failed to hash default admin password")
|
||||
}
|
||||
adminUser := &db.User{
|
||||
Username: u,
|
||||
DisplayName: "Administrator",
|
||||
Role: auth.RoleAdmin,
|
||||
AuthSource: "local",
|
||||
}
|
||||
if err := userRepo.Create(ctx, adminUser, hash); err != nil {
|
||||
logger.Fatal().Err(err).Msg("failed to create default admin user")
|
||||
}
|
||||
logger.Info().Str("username", u).Msg("default admin user created")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create API server
|
||||
server := api.NewServer(logger, database, schemas, cfg.Schemas.Directory, store)
|
||||
server := api.NewServer(logger, database, schemas, cfg.Schemas.Directory, store,
|
||||
authService, sessionManager, oidcBackend, &cfg.Auth)
|
||||
router := api.NewRouter(server, logger)
|
||||
|
||||
// Create HTTP server
|
||||
|
||||
Reference in New Issue
Block a user