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
372 lines
11 KiB
Go
372 lines
11 KiB
Go
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
|
|
}
|