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

Add a complete authentication and authorization system to Silo with
three pluggable backends (local bcrypt, LDAP/FreeIPA, OIDC/Keycloak),
session management, API token support, and role-based access control.

Authentication backends:
- Local: bcrypt (cost 12) password verification against users table
- LDAP: FreeIPA simple bind with group-to-role mapping
- OIDC: Keycloak redirect flow with realm role mapping
- Backends are tried in order; users upserted to DB on first login

Session and token management:
- PostgreSQL-backed sessions via alexedwards/scs + pgxstore
- Opaque API tokens (silo_ prefix, SHA-256 hashed, shown once)
- 24h session lifetime, HttpOnly/SameSite=Lax/Secure cookies

Role-based access control (admin > editor > viewer):
- RequireAuth middleware: Bearer token -> session -> redirect/401
- RequireRole middleware: per-route-group minimum role enforcement
- CSRF protection via justinas/nosurf on web forms, API exempt
- CORS locked to configured origins when auth enabled

Route restructuring:
- Public: /health, /ready, /login, /auth/oidc, /auth/callback
- Web (auth + CSRF): /, /projects, /schemas, /settings
- API read (viewer): GET /api/**
- API write (editor): POST/PUT/PATCH/DELETE /api/**

User context wiring:
- created_by/updated_by columns on items, projects, relationships
- All create/update handlers populate tracking fields from context
- CSV and BOM import handlers pass authenticated username
- Revision creation tracks user across all code paths

Default admin account:
- Configurable via auth.local.default_admin_username/password
- Env var overrides: SILO_ADMIN_USERNAME, SILO_ADMIN_PASSWORD
- Idempotent: created on first startup, skipped if exists

CLI and FreeCAD plugin:
- silo token create/list/revoke subcommands (HTTP API client)
- FreeCAD SiloClient sends Bearer token on all requests
- Token read from ApiToken preference or SILO_API_TOKEN env var

Web UI:
- Login page (Catppuccin Mocha themed, OIDC button conditional)
- Settings page with account info and API token management
- User display name, role badge, and logout button in header
- One-time token display banner with copy-to-clipboard

Database (migration 009):
- users table with role, auth_source, oidc_subject, password_hash
- api_tokens table with SHA-256 hash, prefix, expiry, scopes
- sessions table (scs pgxstore schema)
- audit_log table (schema ready for future use)
- created_by/updated_by ALTER on items, relationships, projects

New dependencies: scs/v2, scs/pgxstore, go-oidc/v3, go-ldap/v3,
justinas/nosurf, golang.org/x/oauth2
This commit is contained in:
Forbes
2026-01-31 11:20:12 -06:00
parent a5fba7bf99
commit 4f0107f1b2
32 changed files with 3538 additions and 138 deletions

View File

@@ -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