diff --git a/CONFIGURATION.md b/CONFIGURATION.md index ab91894..2a02965 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -21,6 +21,7 @@ but you can pass the -config parameter to define the location of the config file | views | | | array | predefined views (see view table) | | trusted_proxies | TRUSTED_PROXIES | | array | List of trusted proxies (env var is space seperated) | | strip_path_prefix | STRIP_PATH_PREFIX | | string | Strip base paths from Puppet code locations | +| cors_origin | OPENVOXVIEW_CORS_ORIGIN | | string | Allowed CORS origin (empty = no CORS headers) | | puppetca.host | PUPPETCA_HOST | | string | Address of Puppet CA server (optional) | | puppetca.port | PUPPETCA_PORT | 8140 | int | Port of Puppet CA server | | puppetca.tls | PUPPETCA_TLS | true | bool | Use TLS for Puppet CA communications | @@ -32,6 +33,90 @@ but you can pass the -config parameter to define the location of the config file | puppetca.deactivate_nodes | PUPPETCA_DEACTIVATE_NODES | false | bool | Also deactivate node in PuppetDB with revoke / clean | +### Authentication + +| Option | Environment Variable | Default | Type | Description | +|------------------------------|----------------------------------------------|-----------------------|--------|--------------------------------------------------| +| auth.enabled | OPENVOXVIEW_AUTH_ENABLED | false | bool | Enable local user authentication | +| auth.jwt_secret | OPENVOXVIEW_AUTH_JWT_SECRET | | string | Secret for signing JWT tokens (min 32 chars) | +| auth.access_token_ttl_minutes| OPENVOXVIEW_AUTH_ACCESS_TOKEN_TTL_MINUTES | 15 | int | Access token lifetime in minutes | +| auth.refresh_token_ttl_days | OPENVOXVIEW_AUTH_REFRESH_TOKEN_TTL_DAYS | 30 | int | Refresh token lifetime in days | +| auth.db_path | OPENVOXVIEW_AUTH_DB_PATH | data/openvoxview.db | string | Path to SQLite database file | + +When `auth.enabled` is `true`, all API endpoints (except `/api/v1/auth/login`, `/api/v1/auth/refresh`, `/api/v1/version`, and `/api/v1/meta`) require a valid JWT bearer token. If no `jwt_secret` is configured, a random one is generated at startup (tokens will not survive restarts). + +To create the first admin user, run: + +``` +openvoxview --create-admin +``` + +Users can also be managed via the API endpoints when authenticated: + +| Method | Endpoint | Description | +|--------|-----------------------------|------------------------| +| POST | /api/v1/auth/login | Login (returns tokens) | +| POST | /api/v1/auth/refresh | Refresh access token | +| POST | /api/v1/auth/logout | Revoke refresh token | +| GET | /api/v1/auth/me | Current user profile | +| GET | /api/v1/auth/users | List all users | +| POST | /api/v1/auth/users | Create user | +| PUT | /api/v1/auth/users/:id | Update user | +| DELETE | /api/v1/auth/users/:id | Delete user | + +### SAML Authentication (EntraID / ADFS) + +| Option | Environment Variable | Default | Type | Description | +|---------------------------------|----------------------------------------------|----------------------------------------------------------------------------|--------|-------------------------------------------------| +| auth.saml.enabled | OPENVOXVIEW_AUTH_SAML_ENABLED | false | bool | Enable SAML 2.0 SSO authentication | +| auth.saml.idp_metadata_url | OPENVOXVIEW_AUTH_SAML_IDP_METADATA_URL | | string | URL to IdP federation metadata XML | +| auth.saml.idp_metadata_file | OPENVOXVIEW_AUTH_SAML_IDP_METADATA_FILE | | string | Path to local IdP metadata XML file (fallback) | +| auth.saml.sp_entity_id | OPENVOXVIEW_AUTH_SAML_SP_ENTITY_ID | | string | SP Entity ID (e.g. https://openvoxview.example.com) | +| auth.saml.sp_acs_url | OPENVOXVIEW_AUTH_SAML_SP_ACS_URL | | string | Assertion Consumer Service URL | +| auth.saml.sp_cert_file | OPENVOXVIEW_AUTH_SAML_SP_CERT_FILE | | string | Path to SP X.509 certificate (PEM) | +| auth.saml.sp_key_file | OPENVOXVIEW_AUTH_SAML_SP_KEY_FILE | | string | Path to SP private key (PEM) | +| auth.saml.attr_email | | http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress | string | SAML attribute URI for email | +| auth.saml.attr_given_name | | http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname | string | SAML attribute URI for given name | +| auth.saml.attr_surname | | http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname | string | SAML attribute URI for surname | +| auth.saml.attr_display_name | | http://schemas.microsoft.com/identity/claims/displayname | string | SAML attribute URI for display name | + +SAML requires `auth.enabled: true` as a prerequisite. Both local login and SAML SSO can be active simultaneously (recommended for break-glass admin access). + +To generate a self-signed SP certificate for SAML: + +``` +openvoxview --generate-saml-cert +``` + +This creates `saml-sp.crt` and `saml-sp.key` in the current directory. Point `sp_cert_file` and `sp_key_file` to these files. + +SAML API endpoints (public, no auth required): + +| Method | Endpoint | Description | +|--------|----------------------------------|------------------------------------| +| GET | /api/v1/auth/saml/metadata | SP metadata XML (for IdP setup) | +| GET | /api/v1/auth/saml/login | Initiates SAML SSO redirect to IdP | +| POST | /api/v1/auth/saml/acs | Assertion Consumer Service callback| + +After the IdP returns a valid assertion, the user is auto-provisioned in the local database (with `auth_source = 'saml'`) and redirected to the frontend with JWT tokens. + +#### EntraID Setup + +1. Azure Portal > Enterprise Applications > New Application > Create your own (non-gallery) +2. Single Sign-On > SAML +3. Basic SAML Configuration: + - Identifier (Entity ID): value of `sp_entity_id` + - Reply URL (ACS): value of `sp_acs_url` + - Sign on URL: `https:///api/v1/auth/saml/login` +4. Copy the **App Federation Metadata Url** (must include `?appid=`) from Section 3 (SAML Certificates) and use it as `idp_metadata_url`. Do NOT use the generic tenant metadata URL — it contains the wrong signing certificate. +5. Assign users/groups + +#### ADFS Setup + +1. ADFS Management > Relying Party Trusts > Add +2. Import from URL: `https:///api/v1/auth/saml/metadata` +3. Add claim rules for email, given name, surname, and display name + ### predefined Queries | Option | Type | Description | |-------------|--------|---------------------------| @@ -79,6 +164,21 @@ port: 5000 trusted_proxies: - 127.0.0.1 +auth: + enabled: true + jwt_secret: "change-me-to-a-long-random-string-min-32-chars" + access_token_ttl_minutes: 15 + refresh_token_ttl_days: 30 + db_path: "data/openvoxview.db" + + saml: + enabled: true + idp_metadata_url: "https://login.microsoftonline.com//federationmetadata/2007-06/federationmetadata.xml?appid=" + sp_entity_id: "https://openvoxview.example.com" + sp_acs_url: "https://openvoxview.example.com/api/v1/auth/saml/acs" + sp_cert_file: "/etc/openvoxview/saml-sp.crt" + sp_key_file: "/etc/openvoxview/saml-sp.key" + puppetdb: host: localhost port: 8081 diff --git a/config/config.go b/config/config.go index dade68f..d453c71 100644 --- a/config/config.go +++ b/config/config.go @@ -12,6 +12,8 @@ import ( var configPath = flag.String("config", "", "path to the config file") var printVersion = flag.Bool("version", false, "prints version") +var createAdmin = flag.Bool("create-admin", false, "create an admin user interactively") +var generateSamlCert = flag.Bool("generate-saml-cert", false, "generate a self-signed SAML SP certificate and key") func init() { flag.Parse() @@ -22,6 +24,29 @@ type ConfigPqlQuery struct { Query string `mapstructure:"query"` } +type SamlConfig struct { + Enabled bool `mapstructure:"enabled"` + IdpMetadataURL string `mapstructure:"idp_metadata_url"` + IdpMetadataFile string `mapstructure:"idp_metadata_file"` + SpEntityID string `mapstructure:"sp_entity_id"` + SpAcsURL string `mapstructure:"sp_acs_url"` + SpCertFile string `mapstructure:"sp_cert_file"` + SpKeyFile string `mapstructure:"sp_key_file"` + AttrEmail string `mapstructure:"attr_email"` + AttrGivenName string `mapstructure:"attr_given_name"` + AttrSurname string `mapstructure:"attr_surname"` + AttrDisplayName string `mapstructure:"attr_display_name"` +} + +type AuthConfig struct { + Enabled bool `mapstructure:"enabled"` + JwtSecret string `mapstructure:"jwt_secret"` + AccessTokenTTL int `mapstructure:"access_token_ttl_minutes"` + RefreshTokenTTL int `mapstructure:"refresh_token_ttl_days"` + DbPath string `mapstructure:"db_path"` + Saml SamlConfig `mapstructure:"saml"` +} + type Config struct { Listen string `mapstructure:"listen"` Port uint64 `mapstructure:"port"` @@ -39,6 +64,8 @@ type Config struct { Views []model.View `mapstructure:"views"` UnreportedHours uint64 `mapstructure:"unreported_hours"` StripPathPrefix string `mapstructure:"strip_path_prefix"` + CorsOrigin string `mapstructure:"cors_origin"` + Auth AuthConfig `mapstructure:"auth"` PuppetCA struct { Host string `mapstructure:"host"` Port uint64 `mapstructure:"port"` @@ -60,6 +87,14 @@ func PrintVersion(version string) bool { return false } +func CreateAdmin() bool { + return *createAdmin +} + +func GenerateSamlCert() bool { + return *generateSamlCert +} + var ( cachedConfig *Config cachedErr error @@ -83,6 +118,17 @@ func GetConfig() (*Config, error) { viper.SetDefault("puppetdb.tls_ignore", false) viper.SetDefault("unreported_hours", 3) viper.SetDefault("strip_path_prefix", `/etc/puppetlabs/code/environments(/.*?/modules)?`) + viper.SetDefault("cors_origin", "") + viper.SetDefault("auth.enabled", false) + viper.SetDefault("auth.access_token_ttl_minutes", 15) + viper.SetDefault("auth.refresh_token_ttl_days", 30) + viper.SetDefault("auth.db_path", "data/openvoxview.db") + viper.SetDefault("auth.saml.enabled", false) + viper.SetDefault("auth.saml.attr_email", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress") + viper.SetDefault("auth.saml.attr_given_name", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname") + viper.SetDefault("auth.saml.attr_surname", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname") + viper.SetDefault("auth.saml.attr_display_name", "http://schemas.microsoft.com/identity/claims/displayname") + viper.SetDefault("puppetca.port", 8140) viper.SetDefault("puppetca.tls", true) viper.SetDefault("puppetca.tls_ignore", false) @@ -103,6 +149,20 @@ func GetConfig() (*Config, error) { viper.BindEnv("puppetdb.tls_cert", "PUPPETDB_TLS_CERT") viper.BindEnv("unreported_hours", "UNREPORTED_HOURS") viper.BindEnv("strip_path_prefix", "STRIP_PATH_PREFIX") + viper.BindEnv("cors_origin", "OPENVOXVIEW_CORS_ORIGIN") + viper.BindEnv("auth.enabled", "OPENVOXVIEW_AUTH_ENABLED") + viper.BindEnv("auth.jwt_secret", "OPENVOXVIEW_AUTH_JWT_SECRET") + viper.BindEnv("auth.access_token_ttl_minutes", "OPENVOXVIEW_AUTH_ACCESS_TOKEN_TTL_MINUTES") + viper.BindEnv("auth.refresh_token_ttl_days", "OPENVOXVIEW_AUTH_REFRESH_TOKEN_TTL_DAYS") + viper.BindEnv("auth.db_path", "OPENVOXVIEW_AUTH_DB_PATH") + viper.BindEnv("auth.saml.enabled", "OPENVOXVIEW_AUTH_SAML_ENABLED") + viper.BindEnv("auth.saml.idp_metadata_url", "OPENVOXVIEW_AUTH_SAML_IDP_METADATA_URL") + viper.BindEnv("auth.saml.idp_metadata_file", "OPENVOXVIEW_AUTH_SAML_IDP_METADATA_FILE") + viper.BindEnv("auth.saml.sp_entity_id", "OPENVOXVIEW_AUTH_SAML_SP_ENTITY_ID") + viper.BindEnv("auth.saml.sp_acs_url", "OPENVOXVIEW_AUTH_SAML_SP_ACS_URL") + viper.BindEnv("auth.saml.sp_cert_file", "OPENVOXVIEW_AUTH_SAML_SP_CERT_FILE") + viper.BindEnv("auth.saml.sp_key_file", "OPENVOXVIEW_AUTH_SAML_SP_KEY_FILE") + viper.BindEnv("puppetca.host", "PUPPETCA_HOST") viper.BindEnv("puppetca.port", "PUPPETCA_PORT") viper.BindEnv("puppetca.tls", "PUPPETCA_TLS") @@ -113,7 +173,11 @@ func GetConfig() (*Config, error) { viper.BindEnv("puppetca.readonly", "PUPPETCA_READONLY") viper.BindEnv("puppetca.deactivate_nodes", "PUPPETCA_DEACTIVATE_NODES") - viper.ReadInConfig() + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + log.Printf("WARNING: Failed to read config file: %v", err) + } + } var cfg Config cachedErr = viper.Unmarshal(&cfg) diff --git a/db/db.go b/db/db.go new file mode 100644 index 0000000..761f135 --- /dev/null +++ b/db/db.go @@ -0,0 +1,85 @@ +package db + +import ( + "database/sql" + "fmt" + "log" + "os" + "path/filepath" + + _ "modernc.org/sqlite" +) + +type Database struct { + db *sql.DB +} + +func Open(dbPath string) (*Database, error) { + dir := filepath.Dir(dbPath) + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, fmt.Errorf("failed to create database directory: %w", err) + } + + sqlDB, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil { + sqlDB.Close() + return nil, fmt.Errorf("failed to set journal mode: %w", err) + } + if _, err := sqlDB.Exec("PRAGMA foreign_keys=ON"); err != nil { + sqlDB.Close() + return nil, fmt.Errorf("failed to enable foreign keys: %w", err) + } + + d := &Database{db: sqlDB} + if err := d.migrate(); err != nil { + sqlDB.Close() + return nil, fmt.Errorf("failed to run migrations: %w", err) + } + + log.Printf("Database opened: %s", dbPath) + return d, nil +} + +func (d *Database) Close() error { + return d.db.Close() +} + +func (d *Database) migrate() error { + migrations := []string{ + `CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + email TEXT, + display_name TEXT, + given_name TEXT, + surname TEXT, + password_hash TEXT, + auth_source TEXT NOT NULL DEFAULT 'local', + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS refresh_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT UNIQUE NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + revoked_at DATETIME + )`, + `CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash)`, + } + + for _, m := range migrations { + if _, err := d.db.Exec(m); err != nil { + return fmt.Errorf("migration failed: %w\nSQL: %s", err, m) + } + } + + return nil +} diff --git a/db/tokens.go b/db/tokens.go new file mode 100644 index 0000000..1f3e4d4 --- /dev/null +++ b/db/tokens.go @@ -0,0 +1,113 @@ +package db + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "time" +) + +var ( + ErrTokenNotFound = errors.New("refresh token not found or expired") + ErrTokenRevoked = errors.New("refresh token has been revoked") +) + +type RefreshToken struct { + ID int64 + UserID int64 + TokenHash string + ExpiresAt time.Time + CreatedAt time.Time + RevokedAt *time.Time +} + +func GenerateRefreshToken() (raw string, hash string, err error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", "", fmt.Errorf("failed to generate token: %w", err) + } + raw = hex.EncodeToString(b) + hash = hashToken(raw) + return raw, hash, nil +} + +func hashToken(token string) string { + h := sha256.Sum256([]byte(token)) + return hex.EncodeToString(h[:]) +} + +func (d *Database) StoreRefreshToken(userID int64, tokenHash string, expiresAt time.Time) error { + _, err := d.db.Exec( + `INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)`, + userID, tokenHash, expiresAt, + ) + if err != nil { + return fmt.Errorf("failed to store refresh token: %w", err) + } + return nil +} + +func (d *Database) ValidateRefreshToken(rawToken string) (*RefreshToken, error) { + h := hashToken(rawToken) + + var rt RefreshToken + var revokedAt sql.NullTime + err := d.db.QueryRow( + `SELECT id, user_id, token_hash, expires_at, created_at, revoked_at + FROM refresh_tokens WHERE token_hash = ?`, h, + ).Scan(&rt.ID, &rt.UserID, &rt.TokenHash, &rt.ExpiresAt, &rt.CreatedAt, &revokedAt) + + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrTokenNotFound + } + if err != nil { + return nil, fmt.Errorf("failed to validate token: %w", err) + } + + if revokedAt.Valid { + d.RevokeAllUserTokens(rt.UserID) + return nil, ErrTokenRevoked + } + + if time.Now().After(rt.ExpiresAt) { + return nil, ErrTokenNotFound + } + + return &rt, nil +} + +func (d *Database) RevokeRefreshToken(rawToken string) error { + h := hashToken(rawToken) + _, err := d.db.Exec( + `UPDATE refresh_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE token_hash = ? AND revoked_at IS NULL`, + h, + ) + if err != nil { + return fmt.Errorf("failed to revoke token: %w", err) + } + return nil +} + +func (d *Database) RevokeAllUserTokens(userID int64) error { + _, err := d.db.Exec( + `UPDATE refresh_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL`, + userID, + ) + if err != nil { + return fmt.Errorf("failed to revoke user tokens: %w", err) + } + return nil +} + +func (d *Database) CleanupExpiredTokens() error { + _, err := d.db.Exec( + `DELETE FROM refresh_tokens WHERE expires_at < CURRENT_TIMESTAMP OR revoked_at IS NOT NULL`, + ) + if err != nil { + return fmt.Errorf("failed to cleanup tokens: %w", err) + } + return nil +} diff --git a/db/users.go b/db/users.go new file mode 100644 index 0000000..4f136bd --- /dev/null +++ b/db/users.go @@ -0,0 +1,261 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" + "time" + + "golang.org/x/crypto/bcrypt" +) + +var ( + ErrUserNotFound = errors.New("user not found") + ErrUsernameExists = errors.New("username already exists") + ErrInvalidCredentials = errors.New("invalid username or password") +) + +type User struct { + ID int64 `json:"id"` + Username string `json:"username"` + Email string `json:"email,omitempty"` + DisplayName string `json:"display_name,omitempty"` + GivenName string `json:"given_name,omitempty"` + Surname string `json:"surname,omitempty"` + PasswordHash string `json:"-"` + AuthSource string `json:"auth_source"` + IsAdmin bool `json:"is_admin"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) + if err != nil { + return "", fmt.Errorf("failed to hash password: %w", err) + } + return string(hash), nil +} + +func (d *Database) CreateUser(username, email, displayName, password string, isAdmin bool) (*User, error) { + hash, err := HashPassword(password) + if err != nil { + return nil, err + } + + result, err := d.db.Exec( + `INSERT INTO users (username, email, display_name, password_hash, auth_source, is_admin) + VALUES (?, ?, ?, ?, 'local', ?)`, + username, email, displayName, hash, isAdmin, + ) + if err != nil { + if isUniqueConstraintError(err) { + return nil, ErrUsernameExists + } + return nil, fmt.Errorf("failed to create user: %w", err) + } + + id, _ := result.LastInsertId() + return d.GetUserByID(id) +} + +func (d *Database) GetUserByID(id int64) (*User, error) { + user := &User{} + err := d.db.QueryRow( + `SELECT id, username, COALESCE(email,''), COALESCE(display_name,''), + COALESCE(given_name,''), COALESCE(surname,''), + COALESCE(password_hash,''), auth_source, is_admin, created_at, updated_at + FROM users WHERE id = ?`, id, + ).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, + &user.GivenName, &user.Surname, &user.PasswordHash, + &user.AuthSource, &user.IsAdmin, &user.CreatedAt, &user.UpdatedAt) + + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrUserNotFound + } + if err != nil { + return nil, fmt.Errorf("failed to get user: %w", err) + } + return user, nil +} + +func (d *Database) GetUserByUsername(username string) (*User, error) { + user := &User{} + err := d.db.QueryRow( + `SELECT id, username, COALESCE(email,''), COALESCE(display_name,''), + COALESCE(given_name,''), COALESCE(surname,''), + COALESCE(password_hash,''), auth_source, is_admin, created_at, updated_at + FROM users WHERE username = ?`, username, + ).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, + &user.GivenName, &user.Surname, &user.PasswordHash, + &user.AuthSource, &user.IsAdmin, &user.CreatedAt, &user.UpdatedAt) + + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrUserNotFound + } + if err != nil { + return nil, fmt.Errorf("failed to get user: %w", err) + } + return user, nil +} + +func (d *Database) AuthenticateUser(username, password string) (*User, error) { + user, err := d.GetUserByUsername(username) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + return nil, ErrInvalidCredentials + } + return nil, err + } + + if user.AuthSource != "local" { + return nil, ErrInvalidCredentials + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { + return nil, ErrInvalidCredentials + } + + return user, nil +} + +func (d *Database) ListUsers() ([]User, error) { + rows, err := d.db.Query( + `SELECT id, username, COALESCE(email,''), COALESCE(display_name,''), + COALESCE(given_name,''), COALESCE(surname,''), + auth_source, is_admin, created_at, updated_at + FROM users ORDER BY username`, + ) + if err != nil { + return nil, fmt.Errorf("failed to list users: %w", err) + } + defer rows.Close() + + var users []User + for rows.Next() { + var u User + if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.DisplayName, + &u.GivenName, &u.Surname, &u.AuthSource, &u.IsAdmin, &u.CreatedAt, &u.UpdatedAt); err != nil { + return nil, fmt.Errorf("failed to scan user: %w", err) + } + users = append(users, u) + } + + if users == nil { + users = []User{} + } + return users, rows.Err() +} + +func (d *Database) UpdateUser(id int64, email, displayName *string, password *string, isAdmin *bool) (*User, error) { + if password != nil { + hash, err := HashPassword(*password) + if err != nil { + return nil, err + } + if isAdmin != nil { + _, err = d.db.Exec( + `UPDATE users SET email = COALESCE(?, email), display_name = COALESCE(?, display_name), + password_hash = ?, is_admin = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + email, displayName, hash, *isAdmin, id, + ) + } else { + _, err = d.db.Exec( + `UPDATE users SET email = COALESCE(?, email), display_name = COALESCE(?, display_name), + password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + email, displayName, hash, id, + ) + } + if err != nil { + return nil, fmt.Errorf("failed to update user: %w", err) + } + } else { + var err error + if isAdmin != nil { + _, err = d.db.Exec( + `UPDATE users SET email = COALESCE(?, email), display_name = COALESCE(?, display_name), + is_admin = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + email, displayName, *isAdmin, id, + ) + } else { + _, err = d.db.Exec( + `UPDATE users SET email = COALESCE(?, email), display_name = COALESCE(?, display_name), + updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + email, displayName, id, + ) + } + if err != nil { + return nil, fmt.Errorf("failed to update user: %w", err) + } + } + + return d.GetUserByID(id) +} + +func (d *Database) DeleteUser(id int64) error { + result, err := d.db.Exec(`DELETE FROM users WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("failed to delete user: %w", err) + } + rows, _ := result.RowsAffected() + if rows == 0 { + return ErrUserNotFound + } + return nil +} + +func (d *Database) UpsertSamlUser(email, givenName, surname, displayName string) (*User, error) { + // Use email as the username for SAML users + username := email + + var existingID int64 + err := d.db.QueryRow(`SELECT id FROM users WHERE username = ? AND auth_source = 'saml'`, username).Scan(&existingID) + if err == nil { + // User exists — update profile attributes from IdP + _, err = d.db.Exec( + `UPDATE users SET email = ?, given_name = ?, surname = ?, display_name = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ?`, + email, givenName, surname, displayName, existingID, + ) + if err != nil { + return nil, fmt.Errorf("failed to update SAML user: %w", err) + } + return d.GetUserByID(existingID) + } + + // New SAML user — insert + result, err := d.db.Exec( + `INSERT INTO users (username, email, given_name, surname, display_name, password_hash, auth_source) + VALUES (?, ?, ?, ?, ?, NULL, 'saml')`, + username, email, givenName, surname, displayName, + ) + if err != nil { + return nil, fmt.Errorf("failed to create SAML user: %w", err) + } + + id, _ := result.LastInsertId() + return d.GetUserByID(id) +} + +func (d *Database) UserCount() (int64, error) { + var count int64 + err := d.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count) + return count, err +} + +func isUniqueConstraintError(err error) bool { + return err != nil && (contains(err.Error(), "UNIQUE constraint failed") || contains(err.Error(), "unique constraint")) +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchString(s, substr) +} + +func searchString(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/docs/adr/ADR-001-local-user-authentication.md b/docs/adr/ADR-001-local-user-authentication.md new file mode 100644 index 0000000..69b9deb --- /dev/null +++ b/docs/adr/ADR-001-local-user-authentication.md @@ -0,0 +1,409 @@ +# ADR-001: Local User Authentication + +**Status:** Implemented +**Date:** 2026-04-03 +**Deciders:** OpenVox View maintainers + +--- + +## Context + +OpenVox View currently has no authentication. All API endpoints under `/api/v1/` are publicly accessible to anyone who can reach the server. This is a significant security gap, especially for the Puppet CA endpoints (`/api/v1/ca/*`) which allow signing and revoking certificates. + +The application is a single Go binary that embeds the Vue 3 frontend. Adding authentication requires: +- A place to store user accounts +- A session mechanism that survives page reloads without forcing re-login +- A toggle so trusted-network deployments can run without auth + +--- + +## Decisions + +| Question | Decision | Rationale | +|---|---|---| +| User storage | **SQLite** | Enables user management API/UI; single-file DB fits the single-binary model | +| Token strategy | **Short-lived access JWT + long-lived refresh token** | Avoids frequent re-logins while keeping access tokens small and revocable | +| Authorization model | **Admin flag** (`is_admin` boolean on user) | User management restricted to admins; all other endpoints available to any authenticated user. `--create-admin` sets `is_admin = true`, SAML auto-provisioned users default to `false`. | +| Auth optional | **Config toggle** (`auth.enabled`) | Trusted-network deployments should not be forced to manage users | + +--- + +## Token Strategy + +| Token | TTL | Storage | Purpose | +|---|---|---|---| +| **Access token** (JWT) | `15m` (configurable) | Frontend `localStorage` via Pinia | Sent as `Authorization: Bearer` on every API request | +| **Refresh token** (opaque) | `30d` (configurable) | SQLite `refresh_tokens` table + frontend `localStorage` | Exchanges for a new access token; revocable server-side | + +**Refresh flow:** +1. Access token expires → frontend receives `401` +2. Axios response interceptor automatically calls `POST /api/v1/auth/refresh` with the refresh token +3. If valid: new access + refresh tokens returned, request retried transparently +4. If invalid/expired: auth store cleared, redirect to login + +This gives users persistent sessions without re-login until the refresh token expires (30 days) or is explicitly revoked. + +--- + +## Database Design + +### SQLite file location + +Configurable via `auth.db_path` (default: `data/openvoxview.db` relative to binary). Created on startup if absent. + +### Schema + +```sql +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + email TEXT, + display_name TEXT, + password_hash TEXT, -- bcrypt hash (NULL for SAML users) + auth_source TEXT NOT NULL DEFAULT 'local', -- 'local' | 'saml' (ADR-002) + is_admin BOOLEAN NOT NULL DEFAULT FALSE, -- only admins can manage users + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE refresh_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT UNIQUE NOT NULL, -- SHA-256 of the raw token + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + revoked_at DATETIME -- NULL = active +); + +CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id); +CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash); +``` + +`password_hash` is `NULL` for SAML-provisioned users (see ADR-002). The `auth_source` column distinguishes local vs. SAML users and is shared with ADR-002. + +### First-run bootstrapping + +A `--create-admin` CLI flag creates the first user interactively if the DB is empty: + +``` +openvoxview --create-admin +Username: admin +Password: **** +Confirm: **** +Admin user created. +``` + +On startup, if `auth.enabled = true` and the users table is empty, the server logs a prominent warning. + +--- + +## Architecture Changes + +### New Go Package: `db/` + +``` +db/ +├── db.go # Open/migrate SQLite, exported DB handle +├── users.go # User CRUD +└── tokens.go # Refresh token CRUD + cleanup +``` + +Uses `database/sql` with raw SQL (no ORM). Schema migrations run automatically on startup via versioned `CREATE TABLE IF NOT EXISTS` statements. + +**New Go dependency:** `modernc.org/sqlite` — pure Go SQLite driver, no CGO required. Critical for cross-platform builds (Linux/FreeBSD, AMD64/ARM/ARM64) without a C toolchain. + +### `config/config.go` — New auth config section + +```go +type AuthConfig struct { + Enabled bool `mapstructure:"enabled"` + JwtSecret string `mapstructure:"jwt_secret"` + AccessTokenTTL int `mapstructure:"access_token_ttl_minutes"` // default: 15 + RefreshTokenTTL int `mapstructure:"refresh_token_ttl_days"` // default: 30 + DbPath string `mapstructure:"db_path"` // default: "data/openvoxview.db" +} +``` + +Environment variable equivalents: +- `OPENVOXVIEW_AUTH_ENABLED` +- `OPENVOXVIEW_AUTH_JWT_SECRET` +- `OPENVOXVIEW_AUTH_DB_PATH` + +### `handler/auth.go` — Auth endpoints + +``` +POST /api/v1/auth/login – local credential check → access + refresh tokens +POST /api/v1/auth/refresh – exchange refresh token → new access + refresh tokens (rotation) +POST /api/v1/auth/logout – revoke current refresh token +GET /api/v1/auth/me – return current user profile from token claims +``` + +**User management endpoints** (also in `handler/auth.go`, require auth + admin): + +``` +GET /api/v1/auth/users – list all users (admin only) +POST /api/v1/auth/users – create user (admin only) +PUT /api/v1/auth/users/:id – update user (admin only) +DELETE /api/v1/auth/users/:id – delete user (admin only) +``` + +Login response body: + +```json +{ + "Data": { + "access_token": "eyJ...", + "refresh_token": "opaque-random-64-bytes-hex", + "expires_in": 900 + } +} +``` + +### `middleware/auth.go` — JWT validation + +```go +func JWTAuthMiddleware(cfg *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + if !cfg.Auth.Enabled { + c.Next() + return + } + token := extractBearerToken(c) + claims, err := validateToken(token, cfg.Auth.JwtSecret) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, NewErrorResponse(err)) + return + } + c.Set("user_id", claims.Subject) // user ID as string + c.Set("username", claims["username"]) + c.Next() + } +} +``` + +### JWT claims + +```json +{ + "sub": "42", + "username": "alice", + "email": "alice@example.com", + "display_name": "Alice Smith", + "is_admin": true, + "iat": 1234567890, + "exp": 1234568790 +} +``` + +### `main.go` — Route structure + +```go +// Public (no auth) +r.POST("/api/v1/auth/login", authHandler.Login) +r.POST("/api/v1/auth/refresh", authHandler.Refresh) +r.GET("/api/v1/version", ...) +r.GET("/api/v1/meta", ...) // public so frontend can detect auth state + +// Protected +api := r.Group("/api/v1/") +api.Use(middleware.JWTAuthMiddleware(cfg)) +{ + api.GET("view/*", ...) + api.POST("pdb/*", ...) + api.POST("ca/*", ...) + api.POST("auth/logout", authHandler.Logout) + api.GET("auth/me", authHandler.Me) + api.GET("auth/users", authHandler.ListUsers) + api.POST("auth/users", authHandler.CreateUser) + api.PUT("auth/users/:id", authHandler.UpdateUser) + api.DELETE("auth/users/:id", authHandler.DeleteUser) +} +``` + +--- + +## Frontend Changes + +### New: `ui/src/stores/auth.ts` (Pinia, persisted) + +```typescript +export const useAuthStore = defineStore('auth', () => { + const accessToken = ref(null) + const refreshToken = ref(null) + const username = ref(null) + const email = ref(null) + const displayName = ref(null) + const expiresAt = ref(null) // Unix seconds + + const isAuthenticated = computed(() => + !!accessToken.value && !!expiresAt.value && Date.now() < expiresAt.value * 1000 + ) + + function setAuth(data: LoginResponse) { /* populate from response */ } + function clearAuth() { /* null everything */ } + + return { accessToken, refreshToken, username, email, displayName, + expiresAt, isAuthenticated, setAuth, clearAuth } +}, { persist: true }) +``` + +### New: `ui/src/pages/LoginPage.vue` + +- Centered Quasar `q-card` with logo +- Username + password inputs +- "Login" button → `Backend.login()` → `auth.setAuth()` → redirect to Dashboard +- Error message on `401` +- Conditionally shows "Login with SSO" button when SAML is enabled (see ADR-002) + +### New: `ui/src/layouts/AuthLayout.vue` + +Minimal layout (no sidebar/header) used only for the login page. + +### `ui/src/router/routes.ts` — Guard + login route + +```typescript +// Add before existing routes +{ + path: '/login', + component: () => import('layouts/AuthLayout.vue'), + children: [{ name: 'Login', path: '', component: () => import('pages/LoginPage.vue') }], + meta: { public: true } +} + +// Navigation guard +router.beforeEach((to) => { + const auth = useAuthStore() + if (!to.meta?.public && !auth.isAuthenticated) { + return { name: 'Login' } + } +}) +``` + +### `ui/src/boot/axios.ts` — Interceptors + +```typescript +// REQUEST: inject access token +api.interceptors.request.use((config) => { + const auth = useAuthStore() + if (auth.accessToken) config.headers.Authorization = `Bearer ${auth.accessToken}` + return config +}) + +// RESPONSE: silent token refresh on 401, then retry once +let isRefreshing = false +let failedQueue: Array<{ resolve: Function; reject: Function }> = [] + +api.interceptors.response.use(null, async (error: AxiosError) => { + if (error.response?.status !== 401) return Promise.reject(error) + + const auth = useAuthStore() + if (!auth.refreshToken) { auth.clearAuth(); router.push({ name: 'Login' }); return } + + if (isRefreshing) { + return new Promise((resolve, reject) => failedQueue.push({ resolve, reject })) + } + + isRefreshing = true + try { + const res = await Backend.refreshToken(auth.refreshToken) + auth.setAuth(res.data.Data) + failedQueue.forEach(p => p.resolve()) + return api.request(error.config!) // retry original request + } catch { + auth.clearAuth() + router.push({ name: 'Login' }) + } finally { + isRefreshing = false + failedQueue = [] + } +}) +``` + +### `ui/src/client/backend.ts` — New auth methods + +```typescript +login(username: string, password: string): AxiosPromise> +refreshToken(token: string): AxiosPromise> +logout(): AxiosPromise> +getMe(): AxiosPromise> +getUsers(): AxiosPromise> +createUser(data: CreateUserRequest): AxiosPromise> +updateUser(id: number, data: UpdateUserRequest): AxiosPromise> +deleteUser(id: number): AxiosPromise> +``` + +### `ui/src/layouts/MainLayout.vue` — User menu + +Add to toolbar: avatar showing `displayName`, dropdown with "My Account" and "Logout". Logout calls `Backend.logout()` then `auth.clearAuth()` then navigates to Login. + +--- + +## Configuration Example + +```yaml +auth: + enabled: true + jwt_secret: "minimum-32-character-random-secret-here" + access_token_ttl_minutes: 15 + refresh_token_ttl_days: 30 + db_path: "data/openvoxview.db" +``` + +--- + +## Security Considerations + +- **JWT secret**: Warn loudly at startup if shorter than 32 characters or if using a placeholder value +- **Refresh token rotation**: Each use of a refresh token invalidates it and issues a new one. If an old token is presented (reuse detection), revoke the entire token family for that user +- **bcrypt cost**: Use cost factor 12 (current recommended default) +- **Rate limiting**: `POST /api/v1/auth/login` — 5 attempts per IP per minute (in-memory, resets on restart) +- **HTTPS**: Access and refresh tokens in `localStorage` are only safe over HTTPS; document this requirement +- **DB file permissions**: The SQLite file contains password hashes; restrict to `0600` +- **Self-delete guard**: Prevent a user from deleting their own account via the management API +- **Self-demote guard**: Prevent an admin from removing their own admin flag (avoids lockout) +- **Admin-only user management**: Only users with `is_admin = true` can access user management endpoints (list, create, update, delete). The `is_admin` flag is included in JWT claims so the check doesn't require a DB call. + +--- + +## Consequences + +### Positive +- SQLite enables a full user management API (list/create/update/delete users) now and a management UI later +- Refresh token rotation gives persistent sessions with server-side revocability +- No external infrastructure; SQLite file ships alongside the binary +- Auth-disabled mode requires zero config changes for existing deployments + +### Negative / Trade-offs +- Adds `modernc.org/sqlite` as a significant new dependency (~8 MB to binary size) +- SQLite file must be on persistent storage (not suitable for stateless container without a volume mount) +- In-memory rate limiter resets on restart (acceptable for v1) + +### Future upgrade path +- Add TOTP/MFA per user +- Replace in-memory rate limiter with persistent counter in SQLite +- Add audit log table for login/logout/user-change events + +--- + +## Implementation Checklist + +**Backend** +- [x] Add `modernc.org/sqlite` to `go.mod` +- [x] Add `golang-jwt/jwt/v5` to `go.mod` +- [x] Create `db/` package: `db.go`, `users.go`, `tokens.go` +- [x] Add `AuthConfig` to `config/config.go` with Viper bindings and env vars +- [x] Create `middleware/auth.go` (JWT validation, auth-disabled passthrough) +- [x] Create `handler/auth.go` (login, refresh, logout, me, user CRUD) +- [x] Modify `main.go`: open DB, init handlers, register routes (public vs. protected) +- [x] Add `--create-admin` CLI flag +- [x] Update `CONFIGURATION.md` + +**Frontend** +- [x] Create `ui/src/stores/auth.ts` +- [x] Create `ui/src/pages/LoginPage.vue` +- [x] Create `ui/src/layouts/AuthLayout.vue` +- [x] Update `ui/src/router/routes.ts` (login route + `beforeEach` guard) +- [x] Update `ui/src/boot/axios.ts` (request injector + 401 refresh interceptor) +- [x] Update `ui/src/client/backend.ts` (auth methods) +- [x] Update `ui/src/client/models.ts` (LoginResponse, UserProfile, etc.) +- [x] Update `ui/src/layouts/MainLayout.vue` (user menu + logout) diff --git a/docs/adr/ADR-002-saml-authentication.md b/docs/adr/ADR-002-saml-authentication.md new file mode 100644 index 0000000..d775bd7 --- /dev/null +++ b/docs/adr/ADR-002-saml-authentication.md @@ -0,0 +1,433 @@ +# ADR-002: SAML Authentication (EntraID / ADFS) + +**Status:** Implemented +**Date:** 2026-04-03 +**Deciders:** OpenVox View maintainers +**Depends on:** ADR-001 (shares SQLite DB, JWT issuance, refresh token mechanism, auth middleware, and frontend auth store) + +--- + +## Context + +Enterprise deployments need to integrate with corporate identity providers. Managing local user accounts (ADR-001) is operationally burdensome at scale and doesn't integrate with corporate MFA, conditional access policies, or account lifecycle management. + +Both **Microsoft Entra ID (Azure AD)** and **ADFS** are targeted. SAML 2.0 is universally supported by both; OIDC/OAuth2 support in older ADFS versions (pre-4.0) is unreliable. + +The SAML implementation builds directly on ADR-001's infrastructure: after the SAML assertion is validated, the same JWT access + refresh token pair is issued. The frontend auth flow is identical regardless of login method. + +--- + +## Decisions + +| Question | Decision | Rationale | +|---|---|---| +| Protocol | **SAML 2.0** | Universal EntraID + ADFS support; `crewjam/saml` is battle-tested | +| User provisioning | **Auto-provision on first SAML login** into SQLite `users` table | Eliminates manual account creation; attributes flow from IdP | +| SAML attributes mapped | **email, givenname, surname, displayname** | User profile fields; no group/role mapping needed | +| Token handoff SPA→SP | **Query param `?token=` on redirect** | Simple, consistent with single-binary model; frontend clears URL immediately | +| IdP metadata | **Fetched from URL at startup, cached** | Picks up IdP cert rotations automatically | + +--- + +## SAML Attribute Mapping + +The following SAML claim URIs are used. These are the standard Microsoft claim types emitted by both EntraID and ADFS by default: + +| App field | SAML claim URI | +|---|---| +| `email` | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` | +| `given_name` | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname` | +| `surname` | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname` | +| `display_name` | `http://schemas.microsoft.com/identity/claims/displayname` | +| `username` (identity) | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` (email used as username for SAML users) | + +All attribute URIs are configurable to support non-Microsoft IdPs. + +--- + +## Authentication Flow + +``` +Browser OpenVox View (SP) EntraID / ADFS (IdP) + | | | + | GET /dashboard | | + | (not authenticated) | | + |<-- 302 /login --------------| | + | | | + | Click "Login with SSO" | | + | GET /api/v1/auth/saml/login>| | + | |-- SAMLRequest (redirect) ---> | + |<-- 302 to IdP login --------| | + | | | + | User authenticates (+ MFA) ---------------------------> | + | | | + |<-- POST /api/v1/auth/saml/acs with SAMLResponse ------------| + | | | + | |-- validate signature | + | |-- check audience/conditions | + | |-- extract attributes | + | |-- upsert user in SQLite | + | |-- issue access + refresh JWT | + |<-- 302 /login?token=&refresh= | + | | | + | Frontend stores tokens | | + | Clears URL params | | + | Redirect to Dashboard | | +``` + +--- + +## SQLite Schema Additions (extends ADR-001) + +No new tables needed. The `users` table from ADR-001 already has `auth_source` and `email`/`display_name` columns. SAML users are distinguished by `auth_source = 'saml'` and have `password_hash = NULL`. + +Two additional columns are added to `users`: + +```sql +ALTER TABLE users ADD COLUMN given_name TEXT; +ALTER TABLE users ADD COLUMN surname TEXT; +``` + +On each SAML login, the user record is **upserted** (insert or update on `email` conflict) so that profile changes in the IdP propagate automatically. + +SAML auto-provisioned users are created with `is_admin = false`. An existing admin must promote them via the User Management UI if they should have admin access. This prevents privilege escalation through SAML auto-provisioning. + +--- + +## Architecture Changes + +### New Go Dependency + +| Package | Purpose | +|---|---| +| `github.com/crewjam/saml` | SAML 2.0 SP implementation (signature verification, metadata generation, assertion parsing). Used in production by Grafana, HashiCorp Vault. | + +### `config/config.go` — SAML sub-section under `AuthConfig` + +```go +type AuthConfig struct { + // ... existing ADR-001 fields ... + Saml SamlConfig `mapstructure:"saml"` +} + +type SamlConfig struct { + Enabled bool `mapstructure:"enabled"` + IdpMetadataURL string `mapstructure:"idp_metadata_url"` // preferred: live URL + IdpMetadataFile string `mapstructure:"idp_metadata_file"` // fallback: local file + SpEntityID string `mapstructure:"sp_entity_id"` // e.g. https://openvoxview.example.com + SpAcsURL string `mapstructure:"sp_acs_url"` // e.g. https://openvoxview.example.com/api/v1/auth/saml/acs + SpCertFile string `mapstructure:"sp_cert_file"` + SpKeyFile string `mapstructure:"sp_key_file"` + + // Attribute claim URIs (defaults to Microsoft standard claims) + AttrEmail string `mapstructure:"attr_email"` + AttrGivenName string `mapstructure:"attr_given_name"` + AttrSurname string `mapstructure:"attr_surname"` + AttrDisplayName string `mapstructure:"attr_display_name"` +} +``` + +Environment variable equivalents: +- `OPENVOXVIEW_AUTH_SAML_ENABLED` +- `OPENVOXVIEW_AUTH_SAML_IDP_METADATA_URL` +- `OPENVOXVIEW_AUTH_SAML_SP_ENTITY_ID` +- `OPENVOXVIEW_AUTH_SAML_SP_ACS_URL` +- `OPENVOXVIEW_AUTH_SAML_SP_CERT_FILE` +- `OPENVOXVIEW_AUTH_SAML_SP_KEY_FILE` + +### `middleware/saml.go` — SP initialization + +```go +func NewSamlServiceProvider(cfg *config.SamlConfig) (*saml.ServiceProvider, error) { + // 1. Load SP cert + key from files + // 2. Fetch/parse IdP metadata from URL or file + // 3. Return configured saml.ServiceProvider +} +``` + +The `ServiceProvider` instance is created once at startup and injected into `AuthHandler`. If `idp_metadata_url` is set, metadata is re-fetched every hour in the background to pick up IdP certificate rotations. + +### `handler/auth.go` — SAML endpoints (adds to ADR-001) + +```go +// GET /api/v1/auth/saml/metadata +// Returns SP metadata XML — paste this into EntraID/ADFS app registration +func (h *AuthHandler) SamlMetadata(c *gin.Context) + +// GET /api/v1/auth/saml/login +// Builds SAML AuthnRequest, redirects browser to IdP +func (h *AuthHandler) SamlLogin(c *gin.Context) + +// POST /api/v1/auth/saml/acs (Assertion Consumer Service — registered in IdP) +// Validates SAMLResponse, upserts user, issues JWT pair, redirects to frontend +func (h *AuthHandler) SamlACS(c *gin.Context) +``` + +**ACS handler logic:** + +```go +func (h *AuthHandler) SamlACS(c *gin.Context) { + // 1. Retrieve AuthnRequest ID from cookie (set during SamlLogin) + var possibleRequestIDs []string + if requestID, err := c.Cookie("saml_request_id"); err == nil { + possibleRequestIDs = append(possibleRequestIDs, requestID) + } + + // 2. Parse and validate SAML response (crewjam/saml handles sig, audience, timing) + assertion, err := h.sp.ParseResponse(c.Request, possibleRequestIDs) + + // 3. Extract attributes + email := getAttr(assertion, h.cfg.Auth.Saml.AttrEmail) + givenName := getAttr(assertion, h.cfg.Auth.Saml.AttrGivenName) + surname := getAttr(assertion, h.cfg.Auth.Saml.AttrSurname) + displayName := getAttr(assertion, h.cfg.Auth.Saml.AttrDisplayName) + + // 4. Upsert user in SQLite + user, err := h.db.UpsertSamlUser(email, givenName, surname, displayName) + + // 5. Issue access + refresh tokens (same function as local login) + tokens, err := h.issueTokenPair(user) + + // 6. Redirect frontend with tokens + redirectURL := fmt.Sprintf("/ui/?#/login?token=%s&refresh=%s", tokens.AccessToken, tokens.RefreshToken) + c.Redirect(http.StatusFound, redirectURL) +} +``` + +### `main.go` — SAML routes (public, outside JWT middleware) + +```go +// These must be public — browser redirects, no token available yet +if cfg.Auth.Saml.Enabled { + r.GET("/api/v1/auth/saml/metadata", authHandler.SamlMetadata) + r.GET("/api/v1/auth/saml/login", authHandler.SamlLogin) + r.POST("/api/v1/auth/saml/acs", authHandler.SamlACS) +} +``` + +### `handler/core.go` — Meta response update + +Add `SamlEnabled bool` to the `/api/v1/meta` response so the frontend knows whether to show the SSO button: + +```go +type MetaResponse struct { + CaEnabled bool `json:"CaEnabled"` + CaReadOnly bool `json:"CaReadOnly"` + UnreportedHours uint64 `json:"UnreportedHours"` + StripPathPrefix string `json:"StripPathPrefix"` + SamlEnabled bool `json:"SamlEnabled"` // NEW +} +``` + +--- + +## Frontend Changes + +No new dependencies. SAML is a browser redirect — the SPA only needs minor additions. + +### `ui/src/client/models.ts` + +```typescript +export interface ApiMeta { + CaEnabled: boolean + CaReadOnly: boolean + UnreportedHours: number + StripPathPrefix: string + SamlEnabled: boolean // NEW +} +``` + +### `ui/src/pages/LoginPage.vue` — SSO button + token extraction + +```typescript +onMounted(async () => { + // Case 1: returning from SAML ACS with tokens in URL + if (route.query.token) { + auth.setAuth({ + access_token: route.query.token as string, + refresh_token: route.query.refresh as string, + // decode expiry from JWT claims + }) + await router.replace({ name: 'Dashboard' }) + return + } + + // Case 2: load meta to decide whether to show SSO button + try { + const meta = await Backend.getMeta() + samlEnabled.value = meta.data.Data.SamlEnabled + } catch { /* ignore, auth might be disabled */ } +}) + +function samlLogin() { + // Full page navigation to trigger SAML redirect chain + window.location.href = '/api/v1/auth/saml/login' +} +``` + +```vue + +``` + +### User Management UI — SAML user profiles are IdP-managed + +The IdP is the source of truth for SAML user profiles. When editing a SAML user (`auth_source = 'saml'`) in the User Management page: + +- **Email** and **Display Name** fields are disabled (managed by IdP) +- **Password** fields are hidden (SAML users have no local password) +- **Admin toggle** is editable (admin role is managed locally, not by the IdP) +- **Save** button is visible (to allow admin toggle changes) +- An info banner explains that the profile is managed by the identity provider and updated on each login + +No other frontend files need changes — the auth store, axios interceptors, router guard, and token refresh logic from ADR-001 all work unchanged for SAML-authenticated users. + +--- + +## SP Certificate + +The SAML SP requires an X.509 certificate for the SP metadata (and optionally signing AuthnRequests). This is independent of PuppetDB TLS certificates. + +A `--generate-saml-cert` CLI flag generates a self-signed cert: + +``` +openvoxview --generate-saml-cert --output-dir /etc/openvoxview/ +Generated: /etc/openvoxview/saml-sp.crt +Generated: /etc/openvoxview/saml-sp.key +``` + +The `.crt` content is embedded in the SP metadata XML that the IdP reads. Copy-paste it into the IdP's "SAML Signing Certificate" field during app registration. + +--- + +## IdP Setup Guides + +### Microsoft Entra ID + +1. **Azure Portal** → Enterprise Applications → New Application → "Create your own application" (non-gallery) +2. **Single Sign-On** → SAML +3. **Basic SAML Configuration:** + - Identifier (Entity ID): value of `sp_entity_id` + - Reply URL (ACS): value of `sp_acs_url` + - Sign on URL: `https:///api/v1/auth/saml/login` +4. **Attributes & Claims** — ensure these are emitted (they are by default): + - `emailaddress` → `user.mail` + - `givenname` → `user.givenname` + - `surname` → `user.surname` + - `displayname` → `user.displayname` +5. **SAML Certificates** → copy the **App Federation Metadata Url** (must include `?appid=`) → use as `idp_metadata_url`. Do NOT use the generic tenant metadata URL — it contains the wrong signing certificate. +6. **Users and Groups** → assign the users/groups who should have access + +### ADFS + +1. **ADFS Management** → Trust Relationships → Relying Party Trusts → Add Relying Party Trust +2. Choose **"Import data about the relying party from a URL"** → enter `https:///api/v1/auth/saml/metadata` +3. **Issuance Transform Rules** → Add Rule → "Send LDAP Attributes as Claims": + | LDAP Attribute | Outgoing Claim Type | + |---|---| + | E-Mail-Addresses | E-Mail Address | + | Given-Name | Given Name | + | Surname | Surname | + | Display-Name | `http://schemas.microsoft.com/identity/claims/displayname` | +4. No relying party token encryption needed unless required by policy + +--- + +## Configuration Example + +```yaml +auth: + enabled: true + jwt_secret: "minimum-32-character-random-secret-here" + access_token_ttl_minutes: 15 + refresh_token_ttl_days: 30 + db_path: "data/openvoxview.db" + + # Local users still work alongside SAML — useful for a break-glass admin + + saml: + enabled: true + # IMPORTANT: For EntraID, use the App Federation Metadata URL (with ?appid=) + idp_metadata_url: "https://login.microsoftonline.com//federationmetadata/2007-06/federationmetadata.xml?appid=" + sp_entity_id: "https://openvoxview.example.com" + sp_acs_url: "https://openvoxview.example.com/api/v1/auth/saml/acs" + sp_cert_file: "/etc/openvoxview/saml-sp.crt" + sp_key_file: "/etc/openvoxview/saml-sp.key" + # Attribute URIs below are the Microsoft defaults — only change if your IdP differs + attr_email: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" + attr_given_name: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname" + attr_surname: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname" + attr_display_name: "http://schemas.microsoft.com/identity/claims/displayname" +``` + +--- + +## Security Considerations + +- **Assertion validation**: `crewjam/saml` validates signature, issuer, audience restriction, `NotBefore`/`NotOnOrAfter`, and `InResponseTo` (replay prevention) by default — do not bypass these checks +- **IDP-initiated SSO disabled**: `AllowIDPInitiated` is set to `false`. All SAML logins must originate from the "Login with SSO" button (SP-initiated). This prevents assertion replay attacks where a captured SAMLResponse is POSTed directly to the ACS endpoint. +- **SAML cookie Secure flag**: The `saml_request_id` cookie (used for `InResponseTo` validation) is set with `Secure: true` and `HttpOnly: true`, ensuring it is only transmitted over HTTPS. +- **ACS URL must be HTTPS**: EntraID and ADFS reject HTTP ACS URLs; document this as a hard requirement +- **Token handoff via hash fragment**: The redirect URL uses a hash fragment (`/ui/?#/login?token=...&refresh=...`) so tokens are not sent to the server in logs, proxies, or Referer headers. They will appear in browser history — a known trade-off of this approach. +- **RelayState**: Managed by `crewjam/saml` to prevent CSRF on the ACS endpoint +- **SAML + local coexistence**: Operators can have both enabled simultaneously. A "break-glass" local admin account is recommended so access is not lost if the IdP is unreachable +- **IdP metadata refresh**: Re-fetch metadata hourly to automatically handle IdP signing certificate rotations without a restart +- **Attribute assertion**: If a SAML login arrives with no `email` attribute, reject it with a clear error — email is the user's primary identity key + +--- + +## Consequences + +### Positive +- Users authenticate with corporate credentials; inherits MFA and conditional access from the IdP +- Account lifecycle (onboarding/offboarding) managed centrally in the IdP +- Profile attributes (name, email) stay current via upsert on each login +- Local accounts remain available as a fallback + +### Negative / Trade-offs +- SAML app registration in EntraID/ADFS requires IdP admin access — operational overhead for initial setup +- `crewjam/saml` adds a significant dependency; XML parsing is a known attack surface (mitigated by the library's signature enforcement) +- `?token=` in URL is a minor security imperfection; acceptable for v1 +- ACS endpoint must be reachable by the IdP (requires public HTTPS URL or network path from IdP to SP) +- SAML metadata fetch on startup adds a network dependency; implement graceful degradation if URL unreachable (fall back to `idp_metadata_file`, warn loudly) + +### Future upgrade path +- Implement SAML Single Logout (SLO) so logout in OpenVox View propagates to the IdP session +- Replace `?token=` handoff with HttpOnly short-lived cookie for improved security +- Add SAML group → role mapping when RBAC is introduced +- Add OIDC/OAuth2 as an alternative IdP protocol for non-ADFS cloud environments + +--- + +## Implementation Checklist + +**Backend** +- [x] Add `github.com/crewjam/saml` to `go.mod` +- [x] Add `SamlConfig` struct to `config/config.go` with Viper bindings +- [x] Add `given_name` / `surname` columns to `users` table migration in `db/db.go` +- [x] Add `UpsertSamlUser()` to `db/users.go` +- [x] Create `middleware/saml.go` — `NewSamlServiceProvider()`, hourly metadata refresh goroutine +- [x] Add `SamlMetadata`, `SamlLogin`, `SamlACS` to `handler/auth.go` +- [x] Add `SamlEnabled` to meta response in `main.go` +- [x] Register SAML routes in `main.go` (public, conditional on `cfg.Auth.Saml.Enabled`) +- [x] Add `--generate-saml-cert` CLI flag +- [x] Update `CONFIGURATION.md` with SAML config options and IdP setup guides + +**Frontend** +- [x] Add `SamlEnabled` to `ApiMeta` in `ui/src/client/models.ts` +- [x] Add SAML token extraction + SSO button to `ui/src/pages/LoginPage.vue` +- [x] Make SAML user profiles read-only in `UserManagementPage.vue` +- [x] Add i18n keys for SSO button and SAML user banner (en-US, de-DE) diff --git a/docs/adr/ADR-003-user-management-ui.md b/docs/adr/ADR-003-user-management-ui.md new file mode 100644 index 0000000..a2c2269 --- /dev/null +++ b/docs/adr/ADR-003-user-management-ui.md @@ -0,0 +1,149 @@ +# ADR-003: User Management UI + +**Status:** Implemented +**Date:** 2026-04-03 +**Deciders:** OpenVox View maintainers +**Depends on:** ADR-001 (backend user CRUD API, auth store, auth-enabled detection) + +--- + +## Context + +ADR-001 introduced local user authentication with a full backend CRUD API for user management (`GET/POST/PUT/DELETE /api/v1/auth/users`). The frontend client methods (`Backend.getUsers()`, `createUser()`, `updateUser()`, `deleteUser()`) and TypeScript models (`UserProfile`, `CreateUserRequest`, `UpdateUserRequest`) also already exist. + +Currently, user management is only possible via CLI (`--create-admin`) or direct API calls with curl. Administrators need a UI to manage users without leaving the browser. + +--- + +## Decision + +Add a **User Management page** at route `/users` within the existing `MainLayout`, following the same patterns used by the CA Overview page (q-table, q-dialog for confirmations, Notify for feedback). The page is only visible in the sidebar when `auth.enabled` is `true` **and the current user is an admin** (`is_admin = true`). + +User management API endpoints are restricted to admin users — the backend returns 403 for non-admin requests. + +--- + +## UI Design + +### Page: `UserManagementPage.vue` + +A single page with: + +1. **Users table** (q-table) showing all users +2. **"Add User" button** in the table header — opens a create dialog +3. **Row actions**: Edit and Delete buttons per row +4. **Self-delete protection**: Delete button disabled on the current user's row + +### Table columns + +| Column | Field | Sortable | Notes | +|---|---|---|---| +| Username | `username` | Yes | Primary identifier | +| Display Name | `display_name` | Yes | | +| Email | `email` | Yes | | +| Auth Source | `auth_source` | Yes | "local" or "saml" | +| Admin | `is_admin` | Yes | Checkbox/badge indicating admin status | +| Created | `created_at` | Yes | Formatted date | +| Actions | — | No | Edit / Delete buttons | + +### Create User Dialog + +Quasar `q-dialog` with a form: + +| Field | Type | Validation | +|---|---|---| +| Username | q-input, text | Required | +| Email | q-input, email | Optional | +| Display Name | q-input, text | Optional | +| Admin | q-toggle | Default: false | +| Password | q-input, password | Required, min 8 chars | +| Confirm Password | q-input, password | Must match Password | + +On submit: `Backend.createUser()` → success notification → reload table. +On 409: show "Username already exists" error. + +### Edit User Dialog + +Same q-dialog, pre-populated with existing values. Username is read-only (not editable after creation). Password fields are optional — leave blank to keep current password. Admin toggle available — but disabled when editing your own account (self-demote guard to prevent lockout). + +For SAML users (`auth_source = 'saml'`): email, display name, and password fields are disabled (managed by IdP). Only the admin toggle is editable. The save button remains visible so admin role changes can be saved. + +On submit: `Backend.updateUser(id, data)` → success notification → reload table. + +### Delete Confirmation + +Quasar `q.dialog()` confirm pattern (same as CA page uses for sign/revoke/clean): + +``` +"Delete user ? This action cannot be undone." +[Cancel] [Delete (red)] +``` + +On confirm: `Backend.deleteUser(id)` → success notification → reload table. +Self-delete: button is disabled with a tooltip "Cannot delete your own account". Backend also enforces this (403). + +--- + +## Architecture Changes + +### Frontend only — no backend changes + +#### New: `ui/src/pages/admin/UserManagementPage.vue` + +Single-file Vue component containing: +- `q-table` with columns definition, pagination, and row template +- Three functions: `loadUsers()`, `openCreateDialog()`, `openEditDialog(user)`, `confirmDelete(user)` +- Create/Edit dialog as inline `q-dialog` with `v-model` toggle (same pattern as other pages) +- Current user ID from `useAuthStore()` to disable self-delete + +#### Update: `ui/src/router/routes.ts` + +Add route under the existing MainLayout children: + +```typescript +{ + name: 'UserManagement', + path: 'users', + component: () => import('pages/admin/UserManagementPage.vue'), +} +``` + +#### Update: `ui/src/layouts/MainLayout.vue` + +Add sidebar menu item, conditionally shown when `auth.authEnabled` is true **and the user is an admin**: + +```vue + + + + + + Users + + +``` + +Placed after the CA menu item in the sidebar. + +--- + +## What Already Exists (no changes needed) + +| Layer | Component | Status | +|---|---|---| +| Backend API | `GET/POST/PUT/DELETE /api/v1/auth/users` | Done (ADR-001) | +| Frontend client | `Backend.getUsers()`, `createUser()`, `updateUser()`, `deleteUser()` | Done (ADR-001) | +| TypeScript models | `UserProfile`, `CreateUserRequest`, `UpdateUserRequest` | Done (ADR-001) | +| Auth store | `useAuthStore()` with user ID for self-delete check | Done (ADR-001) | +| Auth middleware | JWT validation on all user management endpoints | Done (ADR-001) | + +--- + +## Implementation Checklist + +- [x] Create `ui/src/pages/admin/UserManagementPage.vue` +- [x] Add `UserManagement` route to `ui/src/router/routes.ts` +- [x] Add "Users" menu item to `ui/src/layouts/MainLayout.vue` sidebar (conditional on `auth.authEnabled`) +- [x] Build and verify (`yarn build`, `yarn lint`) +- [x] Add test cases to `docs/testing/auth-test-plan.md` +- [x] Add i18n keys for all labels, buttons, and messages (en-US, de-DE) diff --git a/docs/adr/ADR-004-cors-configuration.md b/docs/adr/ADR-004-cors-configuration.md new file mode 100644 index 0000000..24b6246 --- /dev/null +++ b/docs/adr/ADR-004-cors-configuration.md @@ -0,0 +1,146 @@ +# ADR-004: Configurable CORS Policy + +**Status:** Implemented +**Date:** 2026-04-04 +**Deciders:** OpenVox View maintainers +**Depends on:** ADR-001 (authentication, JWT Bearer tokens) + +--- + +## Context + +OpenVox View currently sets a wildcard CORS policy on all API responses: + +```go +c.Header("Access-Control-Allow-Origin", "*") +c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE") +c.Header("Access-Control-Allow-Headers", "Authorization, *") +``` + +This was added to support local development, where the Quasar dev server (`http://localhost:9000`) and the Go backend (`http://localhost:5000`) run on different ports. The browser treats different ports as different origins and blocks cross-origin API responses unless the server explicitly allows it via CORS headers. + +In production, OpenVox View is deployed behind a reverse proxy (e.g., Apache) that serves both the SPA and the API under a single origin (e.g., `https://openvoxview.example.com`). In this setup, all requests are same-origin and CORS headers are unnecessary. + +The wildcard `*` is a security risk: any website can make authenticated API requests to OpenVox View and read the responses if the user has an active session. This was identified as **Vuln 1** in the security review (`docs/security/security-review-2026-04-04.md`). + +--- + +## Decision + +Replace the hardcoded wildcard CORS middleware with a **configurable `cors_origin`** setting. Behavior: + +| `cors_origin` value | Behavior | +|---|---| +| Empty / not set (default) | No CORS headers sent. Same-origin requests work normally. Cross-origin requests are blocked by the browser. | +| Specific origin (e.g., `http://localhost:9000`) | CORS headers set with that exact origin. Only that origin can make cross-origin requests. | + +This keeps the development workflow functional while eliminating the security risk in production. + +--- + +## Configuration + +### `config.yaml` + +```yaml +cors_origin: "" # default: empty = no CORS headers +``` + +Development example: + +```yaml +cors_origin: "http://localhost:9000" +``` + +### Environment variable + +``` +OPENVOXVIEW_CORS_ORIGIN=http://localhost:9000 +``` + +--- + +## Architecture Changes + +### `config/config.go` + +Add field to `Config` struct: + +```go +CorsOrigin string `mapstructure:"cors_origin"` +``` + +Add Viper default and env binding: + +```go +viper.SetDefault("cors_origin", "") +viper.BindEnv("cors_origin", "OPENVOXVIEW_CORS_ORIGIN") +``` + +### `main.go` + +Replace the current `AllowCORS` middleware: + +**Before:** +```go +func AllowCORS(c *gin.Context) { + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE") + c.Header("Access-Control-Allow-Headers", "Authorization, *") + if c.Request.Method == http.MethodOptions { + c.Status(http.StatusNoContent) + return + } + c.Next() +} +``` + +**After:** +```go +func CORSMiddleware(allowedOrigin string) gin.HandlerFunc { + return func(c *gin.Context) { + if allowedOrigin == "" { + c.Next() + return + } + c.Header("Access-Control-Allow-Origin", allowedOrigin) + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE") + c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type") + if c.Request.Method == http.MethodOptions { + c.Status(http.StatusNoContent) + return + } + c.Next() + } +} +``` + +Usage in `main.go`: + +```go +r.Use(CORSMiddleware(cfg.CorsOrigin)) +``` + +Key changes: +- No headers sent when `cors_origin` is empty (production default) +- Explicit origin instead of wildcard `*` +- `Access-Control-Allow-Headers` lists only required headers (`Authorization`, `Content-Type`) instead of wildcard `*` + +--- + +## Security Considerations + +- **Default-secure:** An unconfigured deployment sends no CORS headers, so the browser's same-origin policy is fully enforced. +- **No wildcard:** Even when configured, the origin is explicit — never `*`. This ensures only the specified origin can read API responses. +- **Header allowlist:** Only `Authorization` and `Content-Type` are allowed, not `*`. +- **Startup log:** When `cors_origin` is set, log a message so operators are aware: `CORS: allowing origin `. + +--- + +## Implementation Checklist + +- [x] Add `CorsOrigin` field to `Config` struct in `config/config.go` +- [x] Add Viper default and `OPENVOXVIEW_CORS_ORIGIN` env binding +- [x] Replace `AllowCORS` function with `CORSMiddleware` in `main.go` +- [x] Update `CONFIGURATION.md` with `cors_origin` setting +- [x] Build and verify (`go vet`, `go build`) diff --git a/docs/security/security-review-2026-04-04.md b/docs/security/security-review-2026-04-04.md new file mode 100644 index 0000000..9dae2d3 --- /dev/null +++ b/docs/security/security-review-2026-04-04.md @@ -0,0 +1,69 @@ +# Security Analysis: OpenVox View + +**Date:** 2026-04-04 +**Last Updated:** 2026-04-04 +**Status:** All findings resolved (5 fixed, 1 accepted risk) + +## Vuln 1: Wildcard CORS Allows Cross-Origin Authenticated Requests — `main.go:240-243` + +* **Status: FIXED** +* **Severity: HIGH** +* **Fix:** CORS is now configurable via `cors_origin` config. Defaults to disabled (no CORS headers). Allowed headers restricted to `Authorization, Content-Type`. + +--- + +## Vuln 2: SAML Authentication Passes Tokens in Redirect URL — `handler/auth.go:416` + +* **Status: ACCEPTED RISK (Low)** +* **Original Severity: HIGH** → **Revised: LOW** +* **Rationale:** Tokens are placed in the URL hash fragment (`/ui/?#/login?token=...&refresh=...`), not in query parameters. Fragments have key properties that mitigate the originally reported risks: + - Server access logs: **Not affected** — browsers never send fragments to servers + - Proxy logs: **Not affected** — proxies do not see the fragment + - Referer header: **Not affected** — fragments are stripped from Referer + - Browser history: **Affected** — but requires physical access to the user's machine +* The frontend extracts and clears the tokens from the URL immediately. The remaining risk (local browser history) is acceptable for most deployments. + +--- + +## Vuln 3: No Authorization on User Management — `main.go:213-220` + +* **Status: FIXED** +* **Severity: HIGH** +* **Fix:** Added `AdminRequiredMiddleware()`, `is_admin` field in DB and JWT claims. User management endpoints now gated behind admin check. `--create-admin` CLI sets admin flag. Self-demote guard prevents admins from removing their own admin role. + +--- + +## Vuln 4: IDP-Initiated SAML Enabled — `middleware/saml.go:81` + +* **Status: FIXED** +* **Severity: MEDIUM** +* **Fix:** `AllowIDPInitiated` set to `false`. SAML assertions now require matching `InResponseTo` request ID. + +--- + +## Vuln 5: SAML Cookie Missing Secure Flag — `handler/auth.go:338` + +* **Status: FIXED** +* **Severity: MEDIUM** +* **Fix:** Cookie `Secure` flag now set to `true` on both set and clear operations. + +--- + +## Vuln 6: Password Minimum Length Not Enforced on Update — `handler/auth.go:219-222` + +* **Status: FIXED** +* **Severity: MEDIUM** +* **Fix:** `UpdateUser` handler now validates `len(*req.Password) >= 8` when password is provided. + +--- + +## Summary + +| # | Finding | Severity | Status | +|---|---------|----------|--------| +| 1 | Wildcard CORS with auth headers | HIGH | FIXED | +| 2 | SAML tokens in redirect URL fragment | LOW | ACCEPTED RISK | +| 3 | No authorization on user management | HIGH | FIXED | +| 4 | IDP-initiated SAML allows assertion replay | MEDIUM | FIXED | +| 5 | SAML cookie missing Secure flag | MEDIUM | FIXED | +| 6 | Password min length not enforced on update | MEDIUM | FIXED | diff --git a/docs/testing/auth-test-plan.md b/docs/testing/auth-test-plan.md new file mode 100644 index 0000000..42984d7 --- /dev/null +++ b/docs/testing/auth-test-plan.md @@ -0,0 +1,151 @@ +# Authentication Test Plan (ADR-001, ADR-002, ADR-003, ADR-004) + +Manual test plan for authentication, user management, and security hardening. + +## Authentication Flow + +- [x] **Login with valid credentials** — should redirect to Dashboard +- [x] **Login with wrong password** — should show "Invalid username or password" +- [x] **Login with non-existent user** — should show "Invalid username or password" (same message, no user enumeration) +- [x] **Login with empty fields** — should show validation error +- [x] **Rate limiting** — try 6 rapid failed logins from the same IP, 6th should return "Too many login attempts" + +## Session Persistence + +- [x] **Page reload after login** — should stay authenticated (not redirected to login) +- [x] **Close browser tab and reopen** — should still be logged in (refresh token in localStorage) +- [x] **Wait 15+ minutes** (or set `access_token_ttl_minutes: 1` for testing) — next API call should silently refresh the token without redirecting to login + +## Logout + +- [x] **Click user icon → Logout** — should redirect to login page +- [x] **After logout, press browser back button** — should not access protected content (redirected to login) +- [x] **After logout, reuse old access token via curl** — should get 401 + +## Token Security + +- [x] **Access API without token**: `curl http:///api/v1/view/node_overview` — should return 401 +- [x] **Access API with valid token**: `curl -H "Authorization: Bearer " http:///api/v1/view/node_overview` — should return data +- [x] **Access API with garbage token**: `curl -H "Authorization: Bearer abc123" ...` — should return 401 +- [x] **Public endpoints work without token**: `curl http:///api/v1/version` and `curl http:///api/v1/meta` — should return data + +## User Management API + +- [x] **List users**: `GET /api/v1/auth/users` — should return the admin user +- [x] **Create user**: `POST /api/v1/auth/users` with `{"username":"testuser","password":"test12345678","email":"test@example.com"}` — should return 201 +- [x] **Create duplicate username** — should return 409 Conflict +- [x] **Create user with short password** (< 8 chars) — should return 400 +- [x] **Update user**: `PUT /api/v1/auth/users/2` with `{"display_name":"Test User"}` — should update +- [x] **Delete user**: `DELETE /api/v1/auth/users/2` — should delete +- [x] **Self-delete guard**: try deleting your own user ID — should return 403 "cannot delete your own account" +- [x] **Admin-only guard**: as non-admin user, `GET /api/v1/auth/users` — should return 403 "admin access required" +- [x] **Admin-only guard**: as non-admin user, `POST /api/v1/auth/users` — should return 403 +- [x] **Admin-only guard**: as non-admin user, `PUT /api/v1/auth/users/:id` — should return 403 +- [x] **Admin-only guard**: as non-admin user, `DELETE /api/v1/auth/users/:id` — should return 403 +- [x] **Create user with is_admin**: `POST /api/v1/auth/users` with `{"username":"adminuser","password":"test12345678","is_admin":true}` — should create admin user +- [x] **Update user is_admin**: `PUT /api/v1/auth/users/:id` with `{"is_admin":true}` — should promote user to admin +- [x] **Self-demote guard**: try setting your own `is_admin` to `false` — should return 403 "cannot remove your own admin role" +- [x] **Password min length on update**: `PUT /api/v1/auth/users/:id` with `{"password":"short"}` — should return 400 +- [x] **Login as newly created user** — should work + +## Auth Disabled Mode + +- [x] **Set `auth.enabled: false`**, restart — all pages accessible without login, no user menu shown, no login redirect + +## CLI + +- [x] **`--create-admin`** — creates user interactively, confirm it shows in `GET /api/v1/auth/users` +- [x] **`--create-admin` sets is_admin** — created user should have `is_admin: true` +- [x] **`--create-admin` with mismatched passwords** — should abort with error + +## UI Elements + +- [x] **User menu visible** in top-right toolbar when logged in (account icon) +- [x] **User menu shows username/display name and email** +- [x] **All existing pages still work** after login: Dashboard, Nodes, Facts, Reports, Query, CA (if enabled), predefined views + +## User Management UI (ADR-003) + +### Visibility +- [x] **"Users" menu item visible** in sidebar only when auth is enabled **and user is admin** +- [x] **"Users" menu item hidden** for non-admin users +- [x] **"Users" menu item hidden** when auth is disabled + +### User Table +- [x] **Users page loads** — shows table with all users, columns: Username, Display Name, Email, Auth Source, Admin, Created, Actions +- [x] **Admin column** — shows check icon for admin users, empty for non-admins +- [x] **Refresh button** — reloads the user list + +### Create User +- [x] **Click "Add User"** — opens create dialog with empty fields +- [x] **Admin toggle** — create dialog has admin toggle, defaults to off +- [x] **Submit with valid data** — user created, success notification, table refreshes +- [x] **Submit with admin toggle on** — user created with `is_admin: true` +- [x] **Submit with missing username** — shows "Username is required" error +- [x] **Submit with short password** (< 8 chars) — shows "Password must be at least 8 characters" +- [x] **Submit with mismatched passwords** — shows "Passwords do not match" +- [x] **Submit with duplicate username** — shows "Username already exists" +- [x] **Cancel button** — closes dialog without creating + +### Edit User +- [x] **Click edit icon on a user row** — opens dialog pre-populated with user data +- [x] **Username field is read-only** in edit mode +- [x] **Admin toggle pre-populated** — reflects current is_admin state +- [x] **Admin toggle on own account disabled** — shows warning banner "Cannot remove your own admin role" +- [x] **Change admin toggle on other user** — saves, user promoted/demoted +- [x] **Update display name / email** — saves, success notification, table refreshes +- [x] **Change password** — confirm password field appears, saves new password +- [x] **Leave password blank** — keeps existing password unchanged +- [x] **Cancel button** — closes dialog without saving + +### Delete User +- [x] **Click delete icon** — shows confirmation dialog with username +- [x] **Confirm delete** — user removed, success notification, table refreshes +- [x] **Cancel delete** — dialog closes, user not deleted +- [x] **Delete button disabled on own row** — tooltip shows "Cannot delete your own account" + +### i18n +- [x] **German locale** — all labels, buttons, and messages display in German + +## SAML Authentication (ADR-002) + +### Configuration +- [x] **App Federation Metadata URL** — must use the app-specific URL with `?appid=` parameter +- [x] **Missing SP cert files** — server should fail to start with clear error +- [x] **Missing IdP metadata URL and file** — server should fail to start with clear error +- [x] **Invalid IdP metadata URL** — server should fail to start with clear error + +### SSO Login Flow +- [x] **SSO button visible** on login page when `auth.saml.enabled: true` +- [x] **SSO button hidden** when `auth.saml.enabled: false` +- [x] **Click "Login with SSO"** — redirects to EntraID login +- [x] **Authenticate at EntraID** — redirected back to OpenVox View, logged in, lands on Dashboard +- [x] **SAML user auto-provisioned** — user appears in `GET /api/v1/auth/users` with `auth_source: saml` +- [x] **SAML user profile attributes** — email, display name, given name, surname populated from IdP claims +- [x] **Repeat SAML login** — profile attributes updated (upsert), no duplicate user created +- [x] **SAML user cannot local login** — attempting local login with SAML user's email should fail + +### Session Behavior +- [x] **Token refresh works for SAML users** — after access token expires, silent refresh works +- [x] **Logout works for SAML users** — click Logout, redirected to login page +- [x] **After SAML logout, press back button** — should not access protected content + +### Coexistence with Local Auth +- [x] **Local login still works** when SAML is enabled — both buttons on login page +- [x] **Local user and SAML user can coexist** — different auth_source values +- [x] **Break-glass admin** — local admin account works even if IdP is unreachable + +### SP Metadata +- [x] **`GET /api/v1/auth/saml/metadata`** — returns valid XML with SP entity ID, ACS URL, and certificate + +### CLI +- [x] **`--generate-saml-cert`** — creates `saml-sp.crt` and `saml-sp.key` in current directory + +### User Management UI +- [x] **SAML users visible** in Users table with auth source "saml" +- [x] **SAML user edit dialog** — shows info banner "managed by identity provider" +- [x] **SAML user email/display name disabled** — fields are greyed out, not editable +- [x] **SAML user password fields hidden** — no password fields shown for SAML users +- [x] **SAML user admin toggle editable** — admin toggle works for SAML users +- [x] **SAML user save button visible** — save button shown to allow admin toggle changes +- [x] **SAML users deletable** — can delete a SAML-provisioned user diff --git a/go.mod b/go.mod index 4bfc6d2..3772106 100644 --- a/go.mod +++ b/go.mod @@ -2,14 +2,27 @@ module github.com/sebastianrakel/openvoxview go 1.24 -require github.com/gin-gonic/gin v1.11.0 +require ( + github.com/gin-gonic/gin v1.11.0 + github.com/golang-jwt/jwt/v5 v5.2.2 + modernc.org/sqlite v1.37.1 +) require ( + github.com/beevik/etree v1.5.0 // indirect + github.com/crewjam/saml v0.5.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-yaml v1.18.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jonboulle/clockwork v0.2.2 // indirect + github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.57.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/russellhaering/goxmldsig v1.4.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect @@ -17,6 +30,10 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + modernc.org/libc v1.65.7 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) require ( @@ -40,7 +57,7 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/crypto v0.41.0 // indirect + golang.org/x/crypto v0.41.0 golang.org/x/net v0.43.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.28.0 // indirect diff --git a/go.sum b/go.sum index 79e7540..4ce3b1e 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,20 @@ +github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= +github.com/beevik/etree v1.5.0 h1:iaQZFSDS+3kYZiGoc9uKeOkUY3nYMXOKLl6KIJxiJWs= +github.com/beevik/etree v1.5.0/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/crewjam/saml v0.5.1 h1:g+mfp0CrLuLRZCK793PgJcZeg5dS/0CDwoeAX2zcwNI= +github.com/crewjam/saml v0.5.1/go.mod h1:r0fDkmFe5URDgPrmtH0IYokva6fac3AUdstiPhyEolQ= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -31,19 +39,34 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= 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/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +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/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= +github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -51,16 +74,25 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 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/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE= github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/russellhaering/goxmldsig v1.4.0 h1:8UcDh/xGyQiyrW+Fq5t8f+l2DLB1+zlhYzkPUJ7Qhys= +github.com/russellhaering/goxmldsig v1.4.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= @@ -77,6 +109,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ 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.6.1/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/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= @@ -96,8 +129,14 @@ golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= @@ -105,11 +144,40 @@ golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= +modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= +modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= +modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= +modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs= +modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/handler/auth.go b/handler/auth.go new file mode 100644 index 0000000..b694cf1 --- /dev/null +++ b/handler/auth.go @@ -0,0 +1,454 @@ +package handler + +import ( + "encoding/xml" + "errors" + "fmt" + "log" + "net/http" + "strconv" + "sync" + "time" + + "github.com/crewjam/saml" + "github.com/gin-gonic/gin" + "github.com/sebastianrakel/openvoxview/config" + "github.com/sebastianrakel/openvoxview/db" + "github.com/sebastianrakel/openvoxview/middleware" +) + +type AuthHandler struct { + config *config.Config + database *db.Database + rateLimiter *rateLimiter + samlSP *middleware.SamlSP +} + +func NewAuthHandler(config *config.Config, database *db.Database) *AuthHandler { + return &AuthHandler{ + config: config, + database: database, + rateLimiter: newRateLimiter(), + } +} + +func NewAuthHandlerWithSAML(config *config.Config, database *db.Database, samlSP *middleware.SamlSP) *AuthHandler { + return &AuthHandler{ + config: config, + database: database, + rateLimiter: newRateLimiter(), + samlSP: samlSP, + } +} + +type loginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type refreshRequest struct { + RefreshToken string `json:"refresh_token" binding:"required"` +} + +type logoutRequest struct { + RefreshToken string `json:"refresh_token" binding:"required"` +} + +type loginResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int64 `json:"expires_in"` +} + +type createUserRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required,min=8"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + IsAdmin bool `json:"is_admin"` +} + +type updateUserRequest struct { + Email *string `json:"email"` + DisplayName *string `json:"display_name"` + Password *string `json:"password"` + IsAdmin *bool `json:"is_admin"` +} + +func (h *AuthHandler) Login(c *gin.Context) { + var req loginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, NewErrorResponse(err)) + return + } + + clientIP := c.ClientIP() + if !h.rateLimiter.allow(clientIP) { + c.AbortWithStatusJSON(http.StatusTooManyRequests, NewErrorResponse(errors.New("too many login attempts, try again later"))) + return + } + + user, err := h.database.AuthenticateUser(req.Username, req.Password) + if err != nil { + if errors.Is(err, db.ErrInvalidCredentials) { + c.AbortWithStatusJSON(http.StatusUnauthorized, NewErrorResponse(errors.New("invalid username or password"))) + return + } + c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(err)) + return + } + + tokens, err := h.issueTokenPair(user) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(err)) + return + } + + log.Printf("[AUDIT] User logged in: %s", user.Username) + c.JSON(http.StatusOK, NewSuccessResponse(tokens)) +} + +func (h *AuthHandler) Refresh(c *gin.Context) { + var req refreshRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, NewErrorResponse(err)) + return + } + + rt, err := h.database.ValidateRefreshToken(req.RefreshToken) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, NewErrorResponse(errors.New("invalid or expired refresh token"))) + return + } + + // Revoke old token (rotation) + h.database.RevokeRefreshToken(req.RefreshToken) + + user, err := h.database.GetUserByID(rt.UserID) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, NewErrorResponse(errors.New("user not found"))) + return + } + + tokens, err := h.issueTokenPair(user) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(err)) + return + } + + c.JSON(http.StatusOK, NewSuccessResponse(tokens)) +} + +func (h *AuthHandler) Logout(c *gin.Context) { + var req logoutRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, NewErrorResponse(err)) + return + } + + h.database.RevokeRefreshToken(req.RefreshToken) + + username, _ := c.Get("username") + log.Printf("[AUDIT] User logged out: %v", username) + + c.JSON(http.StatusOK, NewSuccessResponse(nil)) +} + +func (h *AuthHandler) Me(c *gin.Context) { + userIDStr, _ := c.Get("user_id") + userID, err := strconv.ParseInt(userIDStr.(string), 10, 64) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(err)) + return + } + + user, err := h.database.GetUserByID(userID) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, NewErrorResponse(err)) + return + } + + c.JSON(http.StatusOK, NewSuccessResponse(user)) +} + +func (h *AuthHandler) ListUsers(c *gin.Context) { + users, err := h.database.ListUsers() + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(err)) + return + } + c.JSON(http.StatusOK, NewSuccessResponse(users)) +} + +func (h *AuthHandler) CreateUser(c *gin.Context) { + var req createUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, NewErrorResponse(err)) + return + } + + user, err := h.database.CreateUser(req.Username, req.Email, req.DisplayName, req.Password, req.IsAdmin) + if err != nil { + if errors.Is(err, db.ErrUsernameExists) { + c.AbortWithStatusJSON(http.StatusConflict, NewErrorResponse(err)) + return + } + c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(err)) + return + } + + log.Printf("[AUDIT] User created: %s (by %v)", req.Username, c.GetString("username")) + c.JSON(http.StatusCreated, NewSuccessResponse(user)) +} + +func (h *AuthHandler) UpdateUser(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, NewErrorResponse(errors.New("invalid user id"))) + return + } + + var req updateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, NewErrorResponse(err)) + return + } + + // Validate password minimum length when provided + if req.Password != nil && len(*req.Password) < 8 { + c.AbortWithStatusJSON(http.StatusBadRequest, NewErrorResponse(errors.New("password must be at least 8 characters"))) + return + } + + // Self-demote guard: prevent admin from removing their own admin flag + currentUserIDStr, _ := c.Get("user_id") + currentUserID, _ := strconv.ParseInt(currentUserIDStr.(string), 10, 64) + if id == currentUserID && req.IsAdmin != nil && !*req.IsAdmin { + c.AbortWithStatusJSON(http.StatusForbidden, NewErrorResponse(errors.New("cannot remove your own admin role"))) + return + } + + user, err := h.database.UpdateUser(id, req.Email, req.DisplayName, req.Password, req.IsAdmin) + if err != nil { + if errors.Is(err, db.ErrUserNotFound) { + c.AbortWithStatusJSON(http.StatusNotFound, NewErrorResponse(err)) + return + } + c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(err)) + return + } + + log.Printf("[AUDIT] User updated: id=%d (by %v)", id, c.GetString("username")) + c.JSON(http.StatusOK, NewSuccessResponse(user)) +} + +func (h *AuthHandler) DeleteUser(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, NewErrorResponse(errors.New("invalid user id"))) + return + } + + // Prevent self-deletion + currentUserIDStr, _ := c.Get("user_id") + currentUserID, _ := strconv.ParseInt(currentUserIDStr.(string), 10, 64) + if id == currentUserID { + c.AbortWithStatusJSON(http.StatusForbidden, NewErrorResponse(errors.New("cannot delete your own account"))) + return + } + + if err := h.database.DeleteUser(id); err != nil { + if errors.Is(err, db.ErrUserNotFound) { + c.AbortWithStatusJSON(http.StatusNotFound, NewErrorResponse(err)) + return + } + c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(err)) + return + } + + log.Printf("[AUDIT] User deleted: id=%d (by %v)", id, c.GetString("username")) + c.JSON(http.StatusOK, NewSuccessResponse(nil)) +} + +func (h *AuthHandler) issueTokenPair(user *db.User) (*loginResponse, error) { + accessToken, expiresAt, err := middleware.GenerateAccessToken( + user.ID, user.Username, user.Email, user.DisplayName, user.IsAdmin, + h.config.Auth.JwtSecret, h.config.Auth.AccessTokenTTL, + ) + if err != nil { + return nil, err + } + + rawRefresh, hashRefresh, err := db.GenerateRefreshToken() + if err != nil { + return nil, err + } + + refreshExpiry := time.Now().Add(time.Duration(h.config.Auth.RefreshTokenTTL) * 24 * time.Hour) + if err := h.database.StoreRefreshToken(user.ID, hashRefresh, refreshExpiry); err != nil { + return nil, err + } + + expiresIn := expiresAt - time.Now().Unix() + + return &loginResponse{ + AccessToken: accessToken, + RefreshToken: rawRefresh, + ExpiresIn: expiresIn, + }, nil +} + +// SamlMetadata returns the SP metadata XML for IdP registration. +func (h *AuthHandler) SamlMetadata(c *gin.Context) { + if h.samlSP == nil { + c.AbortWithStatusJSON(http.StatusNotFound, NewErrorResponse(errors.New("SAML not configured"))) + return + } + sp := h.samlSP.SP() + metadata := sp.Metadata() + data, err := xml.MarshalIndent(metadata, "", " ") + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(err)) + return + } + c.Data(http.StatusOK, "application/xml", data) +} + +// SamlLogin initiates SAML SSO by redirecting to the IdP. +func (h *AuthHandler) SamlLogin(c *gin.Context) { + if h.samlSP == nil { + c.AbortWithStatusJSON(http.StatusNotFound, NewErrorResponse(errors.New("SAML not configured"))) + return + } + sp := h.samlSP.SP() + + authnRequest, err := sp.MakeAuthenticationRequest( + sp.GetSSOBindingLocation(saml.HTTPRedirectBinding), + saml.HTTPRedirectBinding, + saml.HTTPPostBinding, + ) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(fmt.Errorf("failed to create SAML AuthnRequest: %w", err))) + return + } + + // Store the request ID in a cookie so ACS can validate InResponseTo + c.SetCookie("saml_request_id", authnRequest.ID, 300, "/", "", true, true) + + redirectURL, err := authnRequest.Redirect("", &sp) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(fmt.Errorf("failed to build SAML redirect URL: %w", err))) + return + } + + c.Redirect(http.StatusFound, redirectURL.String()) +} + +// SamlACS handles the SAML Assertion Consumer Service callback. +func (h *AuthHandler) SamlACS(c *gin.Context) { + if h.samlSP == nil { + c.AbortWithStatusJSON(http.StatusNotFound, NewErrorResponse(errors.New("SAML not configured"))) + return + } + + sp := h.samlSP.SP() + + err := c.Request.ParseForm() + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, NewErrorResponse(fmt.Errorf("failed to parse form: %w", err))) + return + } + + // Retrieve the request ID from the cookie set during SamlLogin + var possibleRequestIDs []string + if requestID, err := c.Cookie("saml_request_id"); err == nil && requestID != "" { + possibleRequestIDs = append(possibleRequestIDs, requestID) + } + + // Clear the cookie + c.SetCookie("saml_request_id", "", -1, "/", "", true, true) + + assertion, err := sp.ParseResponse(c.Request, possibleRequestIDs) + if err != nil { + // crewjam/saml hides the real error in InvalidResponseError.PrivateErr + var ire *saml.InvalidResponseError + if errors.As(err, &ire) { + log.Printf("[SAML] ACS validation failed: %v (detail: %v)", err, ire.PrivateErr) + } else { + log.Printf("[SAML] ACS validation failed: %v", err) + } + c.AbortWithStatusJSON(http.StatusUnauthorized, NewErrorResponse(fmt.Errorf("SAML assertion validation failed: %w", err))) + return + } + + // Extract attributes + email := middleware.GetAttribute(assertion, h.config.Auth.Saml.AttrEmail) + givenName := middleware.GetAttribute(assertion, h.config.Auth.Saml.AttrGivenName) + surname := middleware.GetAttribute(assertion, h.config.Auth.Saml.AttrSurname) + displayName := middleware.GetAttribute(assertion, h.config.Auth.Saml.AttrDisplayName) + + if email == "" { + log.Printf("[SAML] ACS: no email attribute in assertion") + c.AbortWithStatusJSON(http.StatusBadRequest, NewErrorResponse(errors.New("SAML assertion missing required email attribute"))) + return + } + + // Upsert user in database + user, err := h.database.UpsertSamlUser(email, givenName, surname, displayName) + if err != nil { + log.Printf("[SAML] ACS: failed to upsert user %s: %v", email, err) + c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(err)) + return + } + + // Issue token pair (same as local login) + tokens, err := h.issueTokenPair(user) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(err)) + return + } + + log.Printf("[AUDIT] SAML user logged in: %s", user.Username) + + // Redirect to frontend with tokens in query params + redirectURL := fmt.Sprintf("/ui/?#/login?token=%s&refresh=%s", tokens.AccessToken, tokens.RefreshToken) + c.Redirect(http.StatusFound, redirectURL) +} + +// Simple in-memory rate limiter: 5 attempts per IP per minute +type rateLimiter struct { + mu sync.Mutex + attempts map[string][]time.Time +} + +func newRateLimiter() *rateLimiter { + return &rateLimiter{ + attempts: make(map[string][]time.Time), + } +} + +func (rl *rateLimiter) allow(ip string) bool { + rl.mu.Lock() + defer rl.mu.Unlock() + + now := time.Now() + window := now.Add(-1 * time.Minute) + + // Clean old entries + valid := make([]time.Time, 0) + for _, t := range rl.attempts[ip] { + if t.After(window) { + valid = append(valid, t) + } + } + + if len(valid) >= 5 { + rl.attempts[ip] = valid + return false + } + + rl.attempts[ip] = append(valid, now) + return true +} diff --git a/main.go b/main.go index a5887ca..c51d9ab 100644 --- a/main.go +++ b/main.go @@ -1,15 +1,29 @@ package main import ( + "bufio" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/pem" "fmt" "io/fs" "log" + "math/big" "net/http" + "os" + "path/filepath" "strings" + "time" "github.com/gin-gonic/gin" "github.com/sebastianrakel/openvoxview/config" + "github.com/sebastianrakel/openvoxview/db" "github.com/sebastianrakel/openvoxview/handler" + "github.com/sebastianrakel/openvoxview/middleware" ) var ( @@ -27,10 +41,72 @@ func main() { panic(err) } + // Handle --create-admin before starting the server + if config.CreateAdmin() { + runCreateAdmin(cfg) + return + } + + // Handle --generate-saml-cert + if config.GenerateSamlCert() { + runGenerateSamlCert() + return + } + log.Printf("LISTEN: %s", cfg.Listen) log.Printf("PORT: %d", cfg.Port) log.Printf("PUPPETDB_ADDRESS: %s", cfg.GetPuppetDbAddress()) log.Printf("TRUSTED_PROXIES: %#v", cfg.TrustedProxies) + if cfg.CorsOrigin != "" { + log.Printf("CORS: allowing origin %s", cfg.CorsOrigin) + } + + // Initialize auth database if auth is enabled + var database *db.Database + var samlSP *middleware.SamlSP + if cfg.Auth.Enabled { + if cfg.Auth.JwtSecret == "" { + cfg.Auth.JwtSecret = generateRandomSecret() + log.Printf("WARNING: No jwt_secret configured. A random secret was generated. Tokens will not survive restarts. Set auth.jwt_secret in your config.") + } + if len(cfg.Auth.JwtSecret) < 32 { + log.Printf("WARNING: jwt_secret is shorter than 32 characters. This is insecure for production use.") + } + + database, err = db.Open(cfg.Auth.DbPath) + if err != nil { + log.Fatalf("Failed to open auth database: %v", err) + } + defer database.Close() + + count, _ := database.UserCount() + if count == 0 { + log.Printf("WARNING: Auth is enabled but no users exist. Use --create-admin to create the first user.") + } + + // Start periodic token cleanup + go func() { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + for range ticker.C { + database.CleanupExpiredTokens() + } + }() + + // Initialize SAML SP if SAML is enabled + if cfg.Auth.Saml.Enabled { + sp, samlErr := middleware.NewSamlServiceProvider(&cfg.Auth.Saml) + if samlErr != nil { + log.Fatalf("Failed to initialize SAML SP: %v", samlErr) + } + samlSP = sp + log.Printf("AUTH: SAML enabled (entity: %s)", cfg.Auth.Saml.SpEntityID) + } + + log.Printf("AUTH: enabled (db: %s)", cfg.Auth.DbPath) + } else { + log.Printf("AUTH: disabled") + } r := gin.Default() @@ -46,7 +122,7 @@ func main() { uiFSSub, _ := fs.Sub(uiFS, "ui/dist/spa") r.StaticFS("ui", http.FS(uiFSSub)) - r.Use(AllowCORS) + r.Use(CORSMiddleware(cfg.CorsOrigin)) if len(cfg.TrustedProxies) > 0 { r.SetTrustedProxies(cfg.TrustedProxies) @@ -54,39 +130,62 @@ func main() { caEnabled := cfg.PuppetCA.Host != "" + // Public auth endpoints (no JWT required) + if cfg.Auth.Enabled { + var authHandler *handler.AuthHandler + if samlSP != nil { + authHandler = handler.NewAuthHandlerWithSAML(cfg, database, samlSP) + } else { + authHandler = handler.NewAuthHandler(cfg, database) + } + r.POST("/api/v1/auth/login", authHandler.Login) + r.POST("/api/v1/auth/refresh", authHandler.Refresh) + + // SAML public endpoints (browser redirects, no token available) + if cfg.Auth.Saml.Enabled { + r.GET("/api/v1/auth/saml/metadata", authHandler.SamlMetadata) + r.GET("/api/v1/auth/saml/login", authHandler.SamlLogin) + r.POST("/api/v1/auth/saml/acs", authHandler.SamlACS) + } + } + + // Public endpoints (no JWT required) + r.GET("/api/v1/version", func(c *gin.Context) { + type versionResponse struct { + Version string + } + c.JSON(http.StatusOK, handler.NewSuccessResponse(versionResponse{Version: VERSION})) + }) + + r.GET("/api/v1/meta", func(c *gin.Context) { + type metaResponse struct { + CaEnabled bool + CaReadOnly bool + UnreportedHours uint64 + StripPathPrefix string + AuthEnabled bool + SamlEnabled bool + } + + response := metaResponse{ + CaEnabled: caEnabled, + CaReadOnly: cfg.PuppetCA.ReadOnly, + UnreportedHours: cfg.UnreportedHours, + StripPathPrefix: cfg.StripPathPrefix, + AuthEnabled: cfg.Auth.Enabled, + SamlEnabled: cfg.Auth.Saml.Enabled, + } + + c.JSON(http.StatusOK, handler.NewSuccessResponse(response)) + }) + pdbHandler := handler.NewPdbHandler(cfg) viewHandler := handler.NewViewHandler(cfg) api := r.Group("/api/v1/") + api.Use(middleware.JWTAuthMiddleware(cfg)) { - api.GET("meta", func(c *gin.Context) { - type metaResponse struct { - CaEnabled bool - CaReadOnly bool - UnreportedHours uint64 - StripPathPrefix string - } - - response := metaResponse{ - CaEnabled: caEnabled, - CaReadOnly: cfg.PuppetCA.ReadOnly, - UnreportedHours: cfg.UnreportedHours, - StripPathPrefix: cfg.StripPathPrefix, - } - c.JSON(http.StatusOK, handler.NewSuccessResponse(response)) - }) - api.GET("version", func(c *gin.Context) { - type versionResponse struct { - Version string - } - - response := versionResponse{ - Version: VERSION, - } - - c.JSON(http.StatusOK, handler.NewSuccessResponse(response)) - }) view := api.Group("view") { view.GET("node_overview", viewHandler.NodesOverview) @@ -104,6 +203,31 @@ func main() { pdb.GET("fact-names", pdbHandler.PdbGetFactNames) pdb.POST("event-counts", pdbHandler.PdbGetEventCounts) } + + // Auth management endpoints (require auth) + if cfg.Auth.Enabled { + var protectedAuthHandler *handler.AuthHandler + if samlSP != nil { + protectedAuthHandler = handler.NewAuthHandlerWithSAML(cfg, database, samlSP) + } else { + protectedAuthHandler = handler.NewAuthHandler(cfg, database) + } + auth := api.Group("auth") + { + auth.POST("logout", protectedAuthHandler.Logout) + auth.GET("me", protectedAuthHandler.Me) + + // Admin-only user management endpoints + admin := auth.Group("") + admin.Use(middleware.AdminRequiredMiddleware()) + { + admin.GET("users", protectedAuthHandler.ListUsers) + admin.POST("users", protectedAuthHandler.CreateUser) + admin.PUT("users/:id", protectedAuthHandler.UpdateUser) + admin.DELETE("users/:id", protectedAuthHandler.DeleteUser) + } + } + } } if caEnabled { @@ -121,15 +245,125 @@ func main() { r.Run(fmt.Sprintf("%s:%d", cfg.Listen, cfg.Port)) } -func AllowCORS(c *gin.Context) { - c.Header("Access-Control-Allow-Origin", "*") - c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE") - c.Header("Access-Control-Allow-Headers", "Authorization, *") +func CORSMiddleware(allowedOrigin string) gin.HandlerFunc { + return func(c *gin.Context) { + if allowedOrigin == "" { + c.Next() + return + } + c.Header("Access-Control-Allow-Origin", allowedOrigin) + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE") + c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type") + if c.Request.Method == http.MethodOptions { + c.Status(http.StatusNoContent) + return + } + c.Next() + } +} + +func runCreateAdmin(cfg *config.Config) { + database, err := db.Open(cfg.Auth.DbPath) + if err != nil { + log.Fatalf("Failed to open database: %v", err) + } + defer database.Close() - if c.Request.Method == http.MethodOptions { - c.Status(http.StatusNoContent) - return + reader := bufio.NewReader(os.Stdin) + + fmt.Print("Username: ") + username, _ := reader.ReadString('\n') + username = strings.TrimSpace(username) + if username == "" { + log.Fatal("Username cannot be empty") + } + + fmt.Print("Email (optional): ") + email, _ := reader.ReadString('\n') + email = strings.TrimSpace(email) + + fmt.Print("Display Name (optional): ") + displayName, _ := reader.ReadString('\n') + displayName = strings.TrimSpace(displayName) + + fmt.Print("Password: ") + password, _ := reader.ReadString('\n') + password = strings.TrimSpace(password) + if len(password) < 8 { + log.Fatal("Password must be at least 8 characters") + } + + fmt.Print("Confirm Password: ") + confirm, _ := reader.ReadString('\n') + confirm = strings.TrimSpace(confirm) + if password != confirm { + log.Fatal("Passwords do not match") + } + + user, err := database.CreateUser(username, email, displayName, password, true) + if err != nil { + log.Fatalf("Failed to create user: %v", err) + } + + fmt.Printf("Admin user created: %s (id: %d, is_admin: true)\n", user.Username, user.ID) +} + +func generateRandomSecret() string { + b := make([]byte, 32) + rand.Read(b) + return hex.EncodeToString(b) +} + +func runGenerateSamlCert() { + outputDir := "." + if len(os.Args) > 2 { + for i, arg := range os.Args { + if arg == "--output-dir" && i+1 < len(os.Args) { + outputDir = os.Args[i+1] + } + } + } + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + log.Fatalf("Failed to generate private key: %v", err) + } + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "OpenVox View SAML SP", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), // 10 years + KeyUsage: x509.KeyUsageDigitalSignature, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + log.Fatalf("Failed to create certificate: %v", err) + } + + certPath := filepath.Join(outputDir, "saml-sp.crt") + certFile, err := os.Create(certPath) + if err != nil { + log.Fatalf("Failed to create cert file: %v", err) + } + pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + certFile.Close() + + keyPath := filepath.Join(outputDir, "saml-sp.key") + keyFile, err := os.OpenFile(keyPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + log.Fatalf("Failed to create key file: %v", err) + } + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + log.Fatalf("Failed to marshal private key: %v", err) } + pem.Encode(keyFile, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + keyFile.Close() - c.Next() + fmt.Printf("Generated: %s\n", certPath) + fmt.Printf("Generated: %s\n", keyPath) } diff --git a/middleware/auth.go b/middleware/auth.go new file mode 100644 index 0000000..a894426 --- /dev/null +++ b/middleware/auth.go @@ -0,0 +1,123 @@ +package middleware + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "github.com/sebastianrakel/openvoxview/config" +) + +type UserClaims struct { + Username string `json:"username"` + Email string `json:"email,omitempty"` + DisplayName string `json:"display_name,omitempty"` + IsAdmin bool `json:"is_admin"` + jwt.RegisteredClaims +} + +func GenerateAccessToken(userID int64, username, email, displayName string, isAdmin bool, secret string, ttlMinutes int) (string, int64, error) { + expiresAt := time.Now().Add(time.Duration(ttlMinutes) * time.Minute) + + claims := UserClaims{ + Username: username, + Email: email, + DisplayName: displayName, + IsAdmin: isAdmin, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: strconv.FormatInt(userID, 10), + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(expiresAt), + Issuer: "openvoxview", + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString([]byte(secret)) + if err != nil { + return "", 0, fmt.Errorf("failed to sign token: %w", err) + } + + return signed, expiresAt.Unix(), nil +} + +func JWTAuthMiddleware(cfg *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + if !cfg.Auth.Enabled { + c.Next() + return + } + + tokenString := extractBearerToken(c) + if tokenString == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, errorResponse("authorization required")) + return + } + + claims, err := validateToken(tokenString, cfg.Auth.JwtSecret) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, errorResponse("invalid or expired token")) + return + } + + c.Set("user_id", claims.Subject) + c.Set("username", claims.Username) + c.Set("is_admin", claims.IsAdmin) + c.Next() + } +} + +func AdminRequiredMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + isAdmin, exists := c.Get("is_admin") + if !exists || !isAdmin.(bool) { + c.AbortWithStatusJSON(http.StatusForbidden, errorResponse("admin access required")) + return + } + c.Next() + } +} + +func errorResponse(msg string) gin.H { + return gin.H{ + "Timestamp": time.Now().Unix(), + "Error": msg, + } +} + +func extractBearerToken(c *gin.Context) string { + auth := c.GetHeader("Authorization") + if auth == "" { + return "" + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") { + return "" + } + return parts[1] +} + +func validateToken(tokenString, secret string) (*UserClaims, error) { + token, err := jwt.ParseWithClaims(tokenString, &UserClaims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(secret), nil + }) + + if err != nil { + return nil, err + } + + claims, ok := token.Claims.(*UserClaims) + if !ok || !token.Valid { + return nil, errors.New("invalid token claims") + } + + return claims, nil +} diff --git a/middleware/saml.go b/middleware/saml.go new file mode 100644 index 0000000..e837e54 --- /dev/null +++ b/middleware/saml.go @@ -0,0 +1,181 @@ +package middleware + +import ( + "crypto" + "crypto/tls" + "crypto/x509" + "encoding/xml" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "sync" + "time" + + "github.com/crewjam/saml" + "github.com/sebastianrakel/openvoxview/config" +) + +// SamlSP wraps the crewjam/saml ServiceProvider with thread-safe metadata refresh. +type SamlSP struct { + mu sync.RWMutex + sp saml.ServiceProvider +} + +// SP returns a copy of the current ServiceProvider (safe for concurrent use). +func (s *SamlSP) SP() saml.ServiceProvider { + s.mu.RLock() + defer s.mu.RUnlock() + return s.sp +} + +func (s *SamlSP) updateIDPMetadata(metadata *saml.EntityDescriptor) { + s.mu.Lock() + defer s.mu.Unlock() + s.sp.IDPMetadata = metadata +} + +// NewSamlServiceProvider creates a SAML SP from the given config. +// It loads the SP certificate, fetches/parses IdP metadata, and returns a ready-to-use SamlSP. +func NewSamlServiceProvider(cfg *config.SamlConfig) (*SamlSP, error) { + // Load SP certificate and key + keyPair, err := tls.LoadX509KeyPair(cfg.SpCertFile, cfg.SpKeyFile) + if err != nil { + return nil, fmt.Errorf("failed to load SAML SP certificate: %w", err) + } + + leaf, err := x509.ParseCertificate(keyPair.Certificate[0]) + if err != nil { + return nil, fmt.Errorf("failed to parse SAML SP certificate: %w", err) + } + + // Parse IdP metadata + idpMetadata, err := fetchIDPMetadata(cfg) + if err != nil { + return nil, fmt.Errorf("failed to load IdP metadata: %w", err) + } + + entityIDURL, err := url.Parse(cfg.SpEntityID) + if err != nil { + return nil, fmt.Errorf("failed to parse SP entity ID URL: %w", err) + } + + acsURL, err := url.Parse(cfg.SpAcsURL) + if err != nil { + return nil, fmt.Errorf("failed to parse SP ACS URL: %w", err) + } + + signer, ok := keyPair.PrivateKey.(crypto.Signer) + if !ok { + return nil, fmt.Errorf("SAML SP private key does not implement crypto.Signer") + } + + sp := saml.ServiceProvider{ + EntityID: entityIDURL.String(), + Key: signer, + Certificate: leaf, + AcsURL: *acsURL, + IDPMetadata: idpMetadata, + AllowIDPInitiated: false, + } + + ssp := &SamlSP{sp: sp} + + // Start background metadata refresh if using a URL + if cfg.IdpMetadataURL != "" { + go ssp.refreshMetadataLoop(cfg.IdpMetadataURL) + } + + return ssp, nil +} + +func fetchIDPMetadata(cfg *config.SamlConfig) (*saml.EntityDescriptor, error) { + if cfg.IdpMetadataURL != "" { + return fetchIDPMetadataFromURL(cfg.IdpMetadataURL) + } + if cfg.IdpMetadataFile != "" { + return loadIDPMetadataFromFile(cfg.IdpMetadataFile) + } + return nil, fmt.Errorf("either idp_metadata_url or idp_metadata_file must be configured") +} + +func fetchIDPMetadataFromURL(metadataURL string) (*saml.EntityDescriptor, error) { + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(metadataURL) + if err != nil { + return nil, fmt.Errorf("failed to fetch IdP metadata from %s: %w", metadataURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("IdP metadata URL returned status %d", resp.StatusCode) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read IdP metadata response: %w", err) + } + + return parseIDPMetadata(data) +} + +func loadIDPMetadataFromFile(path string) (*saml.EntityDescriptor, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read IdP metadata file %s: %w", path, err) + } + return parseIDPMetadata(data) +} + +func parseIDPMetadata(data []byte) (*saml.EntityDescriptor, error) { + // Try parsing as EntityDescriptor first + entity := &saml.EntityDescriptor{} + if err := xml.Unmarshal(data, entity); err == nil && entity.IDPSSODescriptors != nil { + return entity, nil + } + + // Try parsing as EntitiesDescriptor (federation metadata) + entities := &saml.EntitiesDescriptor{} + if err := xml.Unmarshal(data, entities); err != nil { + return nil, fmt.Errorf("failed to parse IdP metadata XML: %w", err) + } + + for i := range entities.EntityDescriptors { + if entities.EntityDescriptors[i].IDPSSODescriptors != nil { + return &entities.EntityDescriptors[i], nil + } + } + + return nil, fmt.Errorf("no IdP entity found in metadata") +} + +func (s *SamlSP) refreshMetadataLoop(metadataURL string) { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + + for range ticker.C { + metadata, err := fetchIDPMetadataFromURL(metadataURL) + if err != nil { + log.Printf("WARNING: Failed to refresh IdP metadata: %v", err) + continue + } + s.updateIDPMetadata(metadata) + log.Printf("SAML: IdP metadata refreshed from %s", metadataURL) + } +} + +// GetAttribute extracts a named attribute value from a SAML assertion. +func GetAttribute(assertion *saml.Assertion, name string) string { + for _, stmt := range assertion.AttributeStatements { + for _, attr := range stmt.Attributes { + if attr.Name == name || attr.FriendlyName == name { + if len(attr.Values) > 0 { + return attr.Values[0].Value + } + } + } + } + return "" +} diff --git a/screenshots/create-user.png b/screenshots/create-user.png new file mode 100644 index 0000000..4f2a88d Binary files /dev/null and b/screenshots/create-user.png differ diff --git a/screenshots/edit-user-warning.png b/screenshots/edit-user-warning.png new file mode 100644 index 0000000..d6e1dc6 Binary files /dev/null and b/screenshots/edit-user-warning.png differ diff --git a/screenshots/login.png b/screenshots/login.png new file mode 100644 index 0000000..44fc810 Binary files /dev/null and b/screenshots/login.png differ diff --git a/screenshots/manage-users.png b/screenshots/manage-users.png new file mode 100644 index 0000000..5cd0f09 Binary files /dev/null and b/screenshots/manage-users.png differ diff --git a/ui/src/boot/axios.ts b/ui/src/boot/axios.ts index fb2be2a..44a78ce 100644 --- a/ui/src/boot/axios.ts +++ b/ui/src/boot/axios.ts @@ -1,7 +1,8 @@ import { defineBoot } from '#q-app/wrappers'; -import axios, {type AxiosError, type AxiosInstance} from 'axios'; +import axios, { type AxiosError, type AxiosInstance, type InternalAxiosRequestConfig } from 'axios'; import { Notify } from 'quasar'; -import {type ErrorResponse} from "src/client/models"; +import { type ErrorResponse } from 'src/client/models'; +import { useAuthStore } from 'stores/auth'; declare module 'vue' { interface ComponentCustomProperties { @@ -10,37 +11,116 @@ declare module 'vue' { } } +const api = axios.create({ baseURL: process.env.VUE_APP_BACKEND_BASE_ADDRESS || '' }); -const api = axios.create({ baseURL: process.env.VUE_APP_BACKEND_BASE_ADDRESS || ''}); - -export default defineBoot(({ app }) => { - // for use inside Vue files (Options API) through this.$axios and this.$api - api.interceptors.response.use(function (response) { - // Any status code that lie within the range of 2xx cause this function to trigger - // Do something with response data - return response; - }, function (error: AxiosError) { - if (error.response && error.status != 400) { - console.log('Cached Error: ', error.response.data.Error); - Notify.create({ - message: error.response.data.Error ?? error.message, - color: 'negative', - multiLine: true, - closeBtn: true, - }) +let isRefreshing = false; +let failedQueue: Array<{ + resolve: (config: InternalAxiosRequestConfig) => void; + reject: (error: unknown) => void; +}> = []; + +function processQueue(error: unknown) { + failedQueue.forEach((prom) => { + if (error) { + prom.reject(error); } + }); + failedQueue = []; +} - return Promise.reject(error); +export default defineBoot(({ app, router }) => { + // Request interceptor: inject Authorization header + api.interceptors.request.use((config) => { + const auth = useAuthStore(); + if (auth.accessToken) { + config.headers.Authorization = `Bearer ${auth.accessToken}`; + } + return config; }); + // Response interceptor: handle errors and token refresh + api.interceptors.response.use( + function (response) { + return response; + }, + async function (error: AxiosError) { + const originalRequest = error.config; - app.config.globalProperties.$axios = axios; - // ^ ^ ^ this will allow you to use this.$axios (for Vue Options API form) - // so you won't necessarily have to import axios in each vue file + // Handle 401 with silent token refresh + if ( + error.response?.status === 401 && + originalRequest && + !originalRequest.url?.includes('/auth/login') && + !originalRequest.url?.includes('/auth/refresh') + ) { + const auth = useAuthStore(); + + if (!auth.refreshToken) { + auth.clearAuth(); + void router.push({ name: 'Login' }); + return Promise.reject(error); + } + + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ + resolve: () => { + // Re-add the updated auth header + const authStore = useAuthStore(); + if (originalRequest.headers && authStore.accessToken) { + originalRequest.headers.Authorization = `Bearer ${authStore.accessToken}`; + } + resolve(api(originalRequest)); + }, + reject, + }); + }); + } + isRefreshing = true; + + try { + const { default: Backend } = await import('src/client/backend'); + const res = await Backend.refreshToken(auth.refreshToken); + auth.setAuth(res.data.Data); + + // Retry queued requests + failedQueue.forEach((prom) => { + prom.resolve(originalRequest); + }); + failedQueue = []; + + // Retry original request + if (originalRequest.headers) { + originalRequest.headers.Authorization = `Bearer ${auth.accessToken}`; + } + return api(originalRequest); + } catch (refreshError: unknown) { + processQueue(refreshError); + auth.clearAuth(); + void router.push({ name: 'Login' }); + return Promise.reject(refreshError instanceof Error ? refreshError : new Error('Token refresh failed')); + } finally { + isRefreshing = false; + } + } + + // Show notification for non-400, non-401 errors + if (error.response && error.response.status !== 400 && error.response.status !== 401) { + Notify.create({ + message: error.response.data.Error ?? error.message, + color: 'negative', + multiLine: true, + closeBtn: true, + }); + } + + return Promise.reject(error); + }, + ); + + app.config.globalProperties.$axios = axios; app.config.globalProperties.$api = api; - // ^ ^ ^ this will allow you to use this.$api (for Vue Options API form) - // so you can easily perform requests against your app's API }); export { api }; diff --git a/ui/src/client/backend.ts b/ui/src/client/backend.ts index 037b83c..a74d1ac 100644 --- a/ui/src/client/backend.ts +++ b/ui/src/client/backend.ts @@ -1,6 +1,6 @@ import { api } from 'boot/axios'; import type { AxiosPromise } from 'axios'; -import type { ApiMeta, ApiVersion, BaseResponse } from 'src/client/models'; +import type { ApiMeta, ApiVersion, BaseResponse, LoginResponse, UserProfile, CreateUserRequest, UpdateUserRequest } from 'src/client/models'; import type PqlQuery from 'src/puppet/query-builder'; import type { ApiPredefinedView, @@ -91,6 +91,38 @@ class Backend { cleanCertificate(name: string): AxiosPromise> { return api.delete(`/api/v1/ca/status/${name}`); } + + login(username: string, password: string): AxiosPromise> { + return api.post('/api/v1/auth/login', { username, password }); + } + + refreshToken(token: string): AxiosPromise> { + return api.post('/api/v1/auth/refresh', { refresh_token: token }); + } + + logout(refreshToken: string): AxiosPromise> { + return api.post('/api/v1/auth/logout', { refresh_token: refreshToken }); + } + + getMe(): AxiosPromise> { + return api.get('/api/v1/auth/me'); + } + + getUsers(): AxiosPromise> { + return api.get('/api/v1/auth/users'); + } + + createUser(data: CreateUserRequest): AxiosPromise> { + return api.post('/api/v1/auth/users', data); + } + + updateUser(id: number, data: UpdateUserRequest): AxiosPromise> { + return api.put(`/api/v1/auth/users/${id}`, data); + } + + deleteUser(id: number): AxiosPromise> { + return api.delete(`/api/v1/auth/users/${id}`); + } } export default new Backend(); diff --git a/ui/src/client/models.ts b/ui/src/client/models.ts index f788a92..2a4a299 100644 --- a/ui/src/client/models.ts +++ b/ui/src/client/models.ts @@ -11,8 +11,44 @@ export interface ApiMeta { CaReadOnly: boolean UnreportedHours: number StripPathPrefix: string + AuthEnabled: boolean + SamlEnabled: boolean } export interface ApiVersion { Version: string; } + +export interface LoginResponse { + access_token: string + refresh_token: string + expires_in: number +} + +export interface UserProfile { + id: number + username: string + email: string + display_name: string + given_name: string + surname: string + auth_source: string + is_admin: boolean + created_at: string + updated_at: string +} + +export interface CreateUserRequest { + username: string + password: string + email?: string + display_name?: string + is_admin?: boolean +} + +export interface UpdateUserRequest { + email?: string + display_name?: string + password?: string + is_admin?: boolean +} diff --git a/ui/src/i18n/langs/de-DE.json b/ui/src/i18n/langs/de-DE.json index a82cffb..53696ef 100644 --- a/ui/src/i18n/langs/de-DE.json +++ b/ui/src/i18n/langs/de-DE.json @@ -81,5 +81,40 @@ "LABEL_CONFIRM_REVOKE_MESSAGE": "Sind Sie sicher, dass Sie das Zertifikat für {certname} widerrufen möchten?", "LABEL_CLEAN": "Bereinigen", "LABEL_CONFIRM_CLEAN_TITLE": "Zertifikat bereinigen?", - "LABEL_CONFIRM_CLEAN_MESSAGE": "Sind Sie sicher, dass Sie das Zertifikat für {certname} bereinigen (löschen) möchten?" + "LABEL_CONFIRM_CLEAN_MESSAGE": "Sind Sie sicher, dass Sie das Zertifikat für {certname} bereinigen (löschen) möchten?", + + "MENU_USERS": "Benutzer", + "LABEL_USERNAME": "Benutzername", + "LABEL_DISPLAY_NAME": "Anzeigename", + "LABEL_EMAIL": "E-Mail", + "LABEL_AUTH_SOURCE": "Authentifizierungsquelle", + "LABEL_CREATED": "Erstellt", + "LABEL_PASSWORD": "Passwort", + "LABEL_NEW_PASSWORD": "Neues Passwort", + "LABEL_CONFIRM_PASSWORD": "Passwort bestätigen", + "LABEL_PASSWORD_HINT_EDIT": "Leer lassen, um das aktuelle Passwort beizubehalten", + "LABEL_PASSWORD_MIN_LENGTH": "Passwort muss mindestens 8 Zeichen lang sein", + "LABEL_PASSWORDS_NO_MATCH": "Passwörter stimmen nicht überein", + "LABEL_USERNAME_REQUIRED": "Benutzername ist erforderlich", + "LABEL_USERNAME_EXISTS": "Benutzername existiert bereits", + "LABEL_CREATE_USER": "Benutzer erstellen", + "LABEL_EDIT_USER": "Benutzer bearbeiten", + "LABEL_DELETE": "Löschen", + "LABEL_CANNOT_DELETE_SELF": "Eigenes Konto kann nicht gelöscht werden", + "LABEL_CONFIRM_DELETE_USER_TITLE": "Benutzer löschen?", + "LABEL_CONFIRM_DELETE_USER_MESSAGE": "Sind Sie sicher, dass Sie den Benutzer {username} löschen möchten? Dies kann nicht rückgängig gemacht werden.", + "BTN_ADD_USER": "Benutzer hinzufügen", + "BTN_EDIT": "Bearbeiten", + "BTN_SAVE": "Speichern", + "BTN_CREATE": "Erstellen", + "NOTIFICATION_USER_CREATED": "Benutzer erfolgreich erstellt", + "NOTIFICATION_USER_UPDATED": "Benutzer erfolgreich aktualisiert", + "NOTIFICATION_USER_DELETED": "Benutzer erfolgreich gelöscht", + + "LABEL_ADMIN": "Admin", + "LABEL_CANNOT_DEMOTE_SELF": "Sie können Ihre eigene Admin-Rolle nicht entfernen", + + "BTN_LOGIN_SSO": "Anmelden mit SSO", + "LABEL_LOGIN_OR": "oder", + "LABEL_SAML_USER_MANAGED_BY_IDP": "Dieser Benutzer wird vom Identity Provider verwaltet. Profildaten werden bei jeder Anmeldung automatisch aktualisiert." } diff --git a/ui/src/i18n/langs/en-US.json b/ui/src/i18n/langs/en-US.json index 79534b0..2f7fe34 100644 --- a/ui/src/i18n/langs/en-US.json +++ b/ui/src/i18n/langs/en-US.json @@ -81,5 +81,40 @@ "LABEL_CONFIRM_REVOKE_MESSAGE": "Are you sure you want to revoke the certificate for {certname}?", "LABEL_CLEAN": "Clean", "LABEL_CONFIRM_CLEAN_TITLE": "Clean certificate?", - "LABEL_CONFIRM_CLEAN_MESSAGE": "Are you sure you want to clean (delete) the certificate for {certname}?" + "LABEL_CONFIRM_CLEAN_MESSAGE": "Are you sure you want to clean (delete) the certificate for {certname}?", + + "MENU_USERS": "Users", + "LABEL_USERNAME": "Username", + "LABEL_DISPLAY_NAME": "Display Name", + "LABEL_EMAIL": "Email", + "LABEL_AUTH_SOURCE": "Auth Source", + "LABEL_CREATED": "Created", + "LABEL_PASSWORD": "Password", + "LABEL_NEW_PASSWORD": "New Password", + "LABEL_CONFIRM_PASSWORD": "Confirm Password", + "LABEL_PASSWORD_HINT_EDIT": "Leave blank to keep current password", + "LABEL_PASSWORD_MIN_LENGTH": "Password must be at least 8 characters", + "LABEL_PASSWORDS_NO_MATCH": "Passwords do not match", + "LABEL_USERNAME_REQUIRED": "Username is required", + "LABEL_USERNAME_EXISTS": "Username already exists", + "LABEL_CREATE_USER": "Create User", + "LABEL_EDIT_USER": "Edit User", + "LABEL_DELETE": "Delete", + "LABEL_CANNOT_DELETE_SELF": "Cannot delete your own account", + "LABEL_CONFIRM_DELETE_USER_TITLE": "Delete user?", + "LABEL_CONFIRM_DELETE_USER_MESSAGE": "Are you sure you want to delete the user {username}? This cannot be undone.", + "BTN_ADD_USER": "Add User", + "BTN_EDIT": "Edit", + "BTN_SAVE": "Save", + "BTN_CREATE": "Create", + "NOTIFICATION_USER_CREATED": "User created successfully", + "NOTIFICATION_USER_UPDATED": "User updated successfully", + "NOTIFICATION_USER_DELETED": "User deleted successfully", + + "LABEL_ADMIN": "Admin", + "LABEL_CANNOT_DEMOTE_SELF": "You cannot remove your own admin role", + + "BTN_LOGIN_SSO": "Login with SSO", + "LABEL_LOGIN_OR": "or", + "LABEL_SAML_USER_MANAGED_BY_IDP": "This user is managed by the identity provider. Profile details are updated automatically on each login." } diff --git a/ui/src/layouts/AuthLayout.vue b/ui/src/layouts/AuthLayout.vue new file mode 100644 index 0000000..bf35919 --- /dev/null +++ b/ui/src/layouts/AuthLayout.vue @@ -0,0 +1,7 @@ + diff --git a/ui/src/layouts/MainLayout.vue b/ui/src/layouts/MainLayout.vue index 455fc73..bede1a6 100644 --- a/ui/src/layouts/MainLayout.vue +++ b/ui/src/layouts/MainLayout.vue @@ -23,7 +23,35 @@ v-model="settings.darkMode" color="positive" /> -
{{ version }}
+
{{ version }}
+ + + + + + {{ auth.displayName || auth.username }} + + + + {{ auth.email }} + + + + + + + + Logout + + + + @@ -107,6 +135,16 @@ {{ $t('MENU_CA') }} + + + + + + + + {{ $t('MENU_USERS') }} + + @@ -119,8 +157,10 @@ + + diff --git a/ui/src/pages/admin/UserManagementPage.vue b/ui/src/pages/admin/UserManagementPage.vue new file mode 100644 index 0000000..d50b491 --- /dev/null +++ b/ui/src/pages/admin/UserManagementPage.vue @@ -0,0 +1,357 @@ + + + diff --git a/ui/src/router/index.ts b/ui/src/router/index.ts index 28ca9e8..eded240 100644 --- a/ui/src/router/index.ts +++ b/ui/src/router/index.ts @@ -7,6 +7,7 @@ import { } from 'vue-router'; import routes from './routes'; +import { useAuthStore } from 'stores/auth'; /* * If not building with SSR mode, you can @@ -32,5 +33,21 @@ export default defineRouter(function (/* { store, ssrContext } */) { history: createHistory(process.env.VUE_ROUTER_BASE), }); + Router.beforeEach((to) => { + const auth = useAuthStore(); + // If auth is not enabled, allow all routes + if (auth.authEnabled === false) { + return; + } + // Allow public routes + if (to.meta?.public) { + return; + } + // Redirect to login if not authenticated + if (!auth.isAuthenticated) { + return { name: 'Login' }; + } + }); + return Router; }); diff --git a/ui/src/router/routes.ts b/ui/src/router/routes.ts index e10ade1..4316c1a 100644 --- a/ui/src/router/routes.ts +++ b/ui/src/router/routes.ts @@ -1,6 +1,18 @@ import { type RouteRecordRaw } from 'vue-router'; const routes: RouteRecordRaw[] = [ + { + path: '/login', + component: () => import('layouts/AuthLayout.vue'), + children: [ + { + name: 'Login', + path: '', + component: () => import('pages/LoginPage.vue'), + meta: { public: true }, + }, + ], + }, { path: '/', redirect: { name: 'Dashboard' }, @@ -56,6 +68,11 @@ const routes: RouteRecordRaw[] = [ path: 'ca', component: () => import('pages/ca/CAOverviewPage.vue'), }, + { + name: 'UserManagement', + path: 'users', + component: () => import('pages/admin/UserManagementPage.vue'), + }, ], }, ]; diff --git a/ui/src/stores/auth.ts b/ui/src/stores/auth.ts new file mode 100644 index 0000000..a3abec4 --- /dev/null +++ b/ui/src/stores/auth.ts @@ -0,0 +1,81 @@ +import { defineStore } from 'pinia'; +import { computed, ref } from 'vue'; +import type { LoginResponse } from 'src/client/models'; + +export const useAuthStore = defineStore( + 'auth', + () => { + const accessToken = ref(null); + const refreshToken = ref(null); + const username = ref(null); + const email = ref(null); + const displayName = ref(null); + const expiresAt = ref(null); + const authEnabled = ref(null); + const isAdmin = ref(false); + + const isAuthenticated = computed(() => { + if (authEnabled.value === false) return true; + return ( + !!accessToken.value && + !!expiresAt.value && + Date.now() < expiresAt.value * 1000 + ); + }); + + const needsRefresh = computed(() => { + if (!accessToken.value || !expiresAt.value) return false; + // Token expires within 60 seconds + return Date.now() > (expiresAt.value - 60) * 1000; + }); + + function setAuth(data: LoginResponse) { + accessToken.value = data.access_token; + refreshToken.value = data.refresh_token; + expiresAt.value = Math.floor(Date.now() / 1000) + data.expires_in; + + // Decode username from JWT payload + try { + const parts = data.access_token.split('.'); + const payload = JSON.parse(atob(parts[1] ?? '')); + username.value = payload.username || null; + email.value = payload.email || null; + displayName.value = payload.display_name || null; + isAdmin.value = payload.is_admin === true; + } catch { + // If decoding fails, keep existing values + } + } + + function clearAuth() { + accessToken.value = null; + refreshToken.value = null; + username.value = null; + email.value = null; + displayName.value = null; + expiresAt.value = null; + isAdmin.value = false; + } + + function setAuthEnabled(enabled: boolean) { + authEnabled.value = enabled; + } + + return { + accessToken, + refreshToken, + username, + email, + displayName, + expiresAt, + authEnabled, + isAdmin, + isAuthenticated, + needsRefresh, + setAuth, + clearAuth, + setAuthEnabled, + }; + }, + { persist: true }, +);