From 787caf8958538bb2cd7bb8fdd0068bf59be12c1c Mon Sep 17 00:00:00 2001 From: Knienartowicz Date: Mon, 1 Jun 2026 09:06:53 +0200 Subject: [PATCH] fix(auth): bind accounts to auth provider local/LDAP/OIDC (#148) Add auth_provider attribute (local/ldap/oidc) to User model across SQLite and PostgreSQL with automatic migration (existing accounts default to local). Rewrite login to be provider-bound: LDAP-backed accounts never fall through to local password verification, OIDC accounts reject password login, and LDAP/OIDC provisioning stores an unusable random local password instead of the provider password. LDAP/OIDC accounts always re-apply provider role mapping on every login so a matching local account can no longer override AD-mapped permissions. Guard handleUpdateUser against setting a local password on non-local accounts. Propagate auth_provider through the Node.js sync layer and panel API, add a Provider column with badges in the users table, hide local password reset for provider-managed accounts, and add EN/PL i18n keys. This commit was made possible thanks to Insolve. --- betterdesk-server/api/auth_handlers.go | 115 ++++++++++++++++++++----- betterdesk-server/api/oidc_handlers.go | 14 ++- betterdesk-server/db/database.go | 11 +++ betterdesk-server/db/postgres.go | 35 +++++--- betterdesk-server/db/sqlite.go | 30 ++++--- web-nodejs/lang/en.json | 6 ++ web-nodejs/lang/pl.json | 6 ++ web-nodejs/public/css/users.css | 27 ++++++ web-nodejs/public/js/users.js | 29 ++++++- web-nodejs/routes/users.routes.js | 1 + web-nodejs/services/dbAdapter.js | 10 ++- web-nodejs/services/userSync.js | 14 +-- web-nodejs/views/users.ejs | 1 + 13 files changed, 245 insertions(+), 54 deletions(-) diff --git a/betterdesk-server/api/auth_handlers.go b/betterdesk-server/api/auth_handlers.go index d69a7177..26519d5c 100644 --- a/betterdesk-server/api/auth_handlers.go +++ b/betterdesk-server/api/auth_handlers.go @@ -210,31 +210,66 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { return } - // Try LDAP authentication if enabled - var ldapResult *auth.LDAPResult - if s.ldapProvider != nil && s.ldapProvider.IsEnabled() { - ldapResult, err = s.ldapProvider.Authenticate(body.Username, body.Password) - if err != nil { - log.Printf("[LDAP] Auth error for %s: %v", body.Username, err) - // LDAP error is non-fatal — fall through to local auth + // Determine the authentication provider bound to this account. An account + // is authoritatively tied to a single provider (Issue #148): accounts + // provisioned via LDAP/OIDC can ONLY authenticate through that provider, + // and local accounts can ONLY authenticate with their local password. + // This eliminates the dual-password hole where a same-name AD account and + // local account could each log in with their own password, and guarantees + // that LDAP group→role mapping is always re-applied on LDAP logins. + provider := db.AuthProviderLocal + if user != nil && user.AuthProvider != "" { + provider = user.AuthProvider + } + + // OIDC-bound accounts must authenticate through single sign-on, never with + // a password on this endpoint. + if user != nil && provider == db.AuthProviderOIDC { + if s.auditLog != nil { + s.auditLog.Log(audit.ActionAuthLoginFailed, s.remoteIP(r), body.Username, map[string]string{"reason": "oidc_account_password_login"}) + } + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "This account uses single sign-on. Please log in with your identity provider."}) + return + } + + ldapEnabled := s.ldapProvider != nil && s.ldapProvider.IsEnabled() + + // Authenticate through LDAP only when the account is LDAP-bound, or when + // it is an unknown user and LDAP is enabled (first-time auto-provisioning). + // A local account is NEVER probed against LDAP, even on a username clash. + if ldapEnabled && (user == nil || provider == db.AuthProviderLDAP) { + ldapResult, ldapErr := s.ldapProvider.Authenticate(body.Username, body.Password) + if ldapErr != nil { + log.Printf("[LDAP] Auth error for %s: %v", body.Username, ldapErr) ldapResult = nil } + if ldapResult != nil && ldapResult.Authenticated { - // LDAP auth succeeded — auto-provision local user if needed + role := ldapResult.Role + if role == "" { + role = auth.RoleViewer + } + if user == nil { - hash, hashErr := auth.HashPassword(body.Password) + // Auto-provision an LDAP-bound account. The local password hash + // is a random, unusable value — the AD password is NEVER stored + // as a local credential, so it cannot be replayed via the local + // password path. + randomSecret, randErr := auth.GenerateRandomString(32) + if randErr != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Internal error"}) + return + } + unusable, hashErr := auth.HashPassword(randomSecret) if hashErr != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Internal error"}) return } - role := ldapResult.Role - if role == "" { - role = auth.RoleViewer - } newUser := &db.User{ Username: body.Username, - PasswordHash: hash, + PasswordHash: unusable, Role: role, + AuthProvider: db.AuthProviderLDAP, } if createErr := s.db.CreateUser(newUser); createErr != nil { log.Printf("[LDAP] Failed to auto-create user %s: %v", body.Username, createErr) @@ -248,20 +283,28 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { } log.Printf("[LDAP] Auto-provisioned user %s with role %s", body.Username, role) } else { - // Update role from LDAP group mapping if changed + // Existing LDAP-bound account — always re-apply the AD group→role + // mapping so permission changes in AD take effect on next login. + changed := false if ldapResult.Role != "" && ldapResult.Role != user.Role { user.Role = ldapResult.Role - _ = s.db.UpdateUser(user) + changed = true log.Printf("[LDAP] Updated role for %s to %s", body.Username, ldapResult.Role) } + if user.AuthProvider != db.AuthProviderLDAP { + user.AuthProvider = db.AuthProviderLDAP + changed = true + } + if changed { + _ = s.db.UpdateUser(user) + } } if s.auditLog != nil { s.auditLog.Log(audit.ActionAuthLogin, s.remoteIP(r), user.Username, map[string]string{"method": "ldap"}) } - // LDAP users skip TOTP (TOTP is managed locally, not via LDAP) - // If user has TOTP enabled locally, still require it + // TOTP is managed locally; if enabled, still require it for LDAP users. if user.TOTPEnabled { partialToken, tokenErr := s.jwtManager.GenerateWithTTL(user.Username, "__2fa_pending__", 5*time.Minute) if tokenErr != nil { @@ -288,10 +331,23 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { }) return } + + // LDAP authentication failed. An LDAP-bound account must NOT fall back + // to local password verification — reject immediately. + if user != nil && provider == db.AuthProviderLDAP { + if s.auditLog != nil { + s.auditLog.Log(audit.ActionAuthLoginFailed, s.remoteIP(r), body.Username, map[string]string{"method": "ldap"}) + } + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Invalid credentials"}) + return + } + // Unknown user with failed LDAP — fall through to the local check below, + // which returns 401 because user == nil. } - // Local password verification - if user == nil || !auth.VerifyPassword(user.PasswordHash, body.Password) { + // Local password verification — only for local accounts. LDAP-bound and + // unknown users are rejected here. + if user == nil || provider != db.AuthProviderLocal || !auth.VerifyPassword(user.PasswordHash, body.Password) { if s.auditLog != nil { s.auditLog.Log(audit.ActionAuthLoginFailed, s.remoteIP(r), body.Username, nil) } @@ -492,6 +548,7 @@ func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) { ID int64 `json:"id"` Username string `json:"username"` Role string `json:"role"` + AuthProvider string `json:"auth_provider"` TOTPEnabled bool `json:"totp_enabled"` IsServerAdmin bool `json:"is_server_admin"` CreatedAt string `json:"created_at"` @@ -500,9 +557,14 @@ func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) { result := make([]userView, len(users)) for i, u := range users { + provider := u.AuthProvider + if provider == "" { + provider = db.AuthProviderLocal + } result[i] = userView{ ID: u.ID, Username: u.Username, Role: u.Role, - TOTPEnabled: u.TOTPEnabled, IsServerAdmin: u.IsServerAdmin, + AuthProvider: provider, + TOTPEnabled: u.TOTPEnabled, IsServerAdmin: u.IsServerAdmin, CreatedAt: u.CreatedAt, LastLogin: u.LastLogin, } } @@ -662,6 +724,7 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) { Username: body.Username, PasswordHash: hash, Role: body.Role, + AuthProvider: db.AuthProviderLocal, } if err := s.db.CreateUser(user); err != nil { writeJSON(w, http.StatusConflict, map[string]string{"error": "Username already exists or DB error"}) @@ -702,6 +765,16 @@ func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) { } if body.Password != "" { + // Issue #148: LDAP/OIDC accounts have no usable local password. Setting + // one here would re-open the dual-authentication hole, so reject it. + provider := user.AuthProvider + if provider == "" { + provider = db.AuthProviderLocal + } + if provider != db.AuthProviderLocal { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Cannot set a local password on an LDAP/OIDC account"}) + return + } hash, err := auth.HashPassword(body.Password) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Password hash failed"}) diff --git a/betterdesk-server/api/oidc_handlers.go b/betterdesk-server/api/oidc_handlers.go index 9722ef97..757b2aa4 100644 --- a/betterdesk-server/api/oidc_handlers.go +++ b/betterdesk-server/api/oidc_handlers.go @@ -360,6 +360,7 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) { Username: result.Username, PasswordHash: hash, Role: role, + AuthProvider: db.AuthProviderOIDC, } if createErr := s.db.CreateUser(newUser); createErr != nil { log.Printf("[OIDC] Failed to create user %s: %v", result.Username, createErr) @@ -375,12 +376,21 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) { log.Printf("[OIDC] Auto-provisioned user %s with role %s", result.Username, role) } else { - // Update role from OIDC group mapping if changed + // Update role from OIDC group mapping if changed, and ensure the + // account is bound to the OIDC provider (Issue #148). + changed := false if result.Role != "" && result.Role != user.Role { user.Role = result.Role - _ = s.db.UpdateUser(user) + changed = true log.Printf("[OIDC] Updated role for %s to %s", result.Username, result.Role) } + if user.AuthProvider != db.AuthProviderOIDC { + user.AuthProvider = db.AuthProviderOIDC + changed = true + } + if changed { + _ = s.db.UpdateUser(user) + } } // Audit log diff --git a/betterdesk-server/db/database.go b/betterdesk-server/db/database.go index b2b11c2d..ca88c9a6 100644 --- a/betterdesk-server/db/database.go +++ b/betterdesk-server/db/database.go @@ -39,12 +39,23 @@ type ServerConfig struct { } // User represents an API user account. +// Authentication provider values for User.AuthProvider. Determines how an +// account is allowed to authenticate. A user bound to a non-local provider +// can ONLY authenticate via that provider — password login is rejected even +// when a username collides with a local account (Issue #148). +const ( + AuthProviderLocal = "local" // local password (PBKDF2/bcrypt) + AuthProviderLDAP = "ldap" // LDAP / Active Directory + AuthProviderOIDC = "oidc" // OpenID Connect / OAuth2 SSO +) + type User struct { ID int64 `json:"id"` Username string `json:"username"` PasswordHash string `json:"-"` Role string `json:"role"` // admin, operator, viewer IsServerAdmin bool `json:"is_server_admin"` // Phase 3: separate server admin flag + AuthProvider string `json:"auth_provider"` // local, ldap, oidc (Issue #148) TOTPSecret string `json:"-"` TOTPEnabled bool `json:"totp_enabled"` TOTPRecoveryCodes string `json:"-"` // JSON array of bcrypt-hashed recovery codes (H4) diff --git a/betterdesk-server/db/postgres.go b/betterdesk-server/db/postgres.go index a37cbded..cea3685e 100644 --- a/betterdesk-server/db/postgres.go +++ b/betterdesk-server/db/postgres.go @@ -118,6 +118,7 @@ func (pg *PostgresDB) Migrate() error { username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'viewer', + auth_provider TEXT NOT NULL DEFAULT 'local', totp_secret TEXT NOT NULL DEFAULT '', totp_enabled BOOLEAN NOT NULL DEFAULT FALSE, totp_recovery_codes TEXT DEFAULT NULL, @@ -406,6 +407,8 @@ func (pg *PostgresDB) Migrate() error { `ALTER TABLE peers ADD COLUMN IF NOT EXISTS display_name TEXT NOT NULL DEFAULT ''`, // users: server admin flag (RBAC Phase 52) `ALTER TABLE users ADD COLUMN IF NOT EXISTS is_server_admin BOOLEAN NOT NULL DEFAULT FALSE`, + // users: authentication provider (Issue #148) + `ALTER TABLE users ADD COLUMN IF NOT EXISTS auth_provider TEXT NOT NULL DEFAULT 'local'`, // org_users: server_user_id for linking existing users (Issue #106) `ALTER TABLE org_users ADD COLUMN IF NOT EXISTS server_user_id BIGINT NOT NULL DEFAULT 0`, } @@ -909,10 +912,13 @@ func (pg *PostgresDB) ListConfigByPrefix(prefix string) ([]ServerConfig, error) // CreateUser inserts a new user and sets u.ID to the generated primary key. func (pg *PostgresDB) CreateUser(u *User) error { + if u.AuthProvider == "" { + u.AuthProvider = AuthProviderLocal + } err := pg.pool.QueryRow(pg.ctx, - `INSERT INTO users (username, password_hash, role, totp_secret, totp_enabled) - VALUES ($1, $2, $3, $4, $5) RETURNING id`, - u.Username, u.PasswordHash, u.Role, u.TOTPSecret, u.TOTPEnabled, + `INSERT INTO users (username, password_hash, role, auth_provider, totp_secret, totp_enabled) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, + u.Username, u.PasswordHash, u.Role, u.AuthProvider, u.TOTPSecret, u.TOTPEnabled, ).Scan(&u.ID) if err != nil { return fmt.Errorf("db: CreateUser: %w", err) @@ -928,9 +934,10 @@ func scanUser(row pgx.Row) (*User, error) { var createdAt *time.Time var lastLogin *time.Time var recoveryCodes *string + var authProvider *string err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, - &u.TOTPSecret, &u.TOTPEnabled, &createdAt, &lastLogin, &u.IsServerAdmin, &recoveryCodes) + &u.TOTPSecret, &u.TOTPEnabled, &createdAt, &lastLogin, &u.IsServerAdmin, &recoveryCodes, &authProvider) if err != nil { return nil, err } @@ -944,6 +951,11 @@ func scanUser(row pgx.Row) (*User, error) { if recoveryCodes != nil { u.TOTPRecoveryCodes = *recoveryCodes } + if authProvider != nil && *authProvider != "" { + u.AuthProvider = *authProvider + } else { + u.AuthProvider = AuthProviderLocal + } return u, nil } @@ -952,7 +964,7 @@ func scanUser(row pgx.Row) (*User, error) { func (pg *PostgresDB) GetUser(username string) (*User, error) { row := pg.pool.QueryRow(pg.ctx, `SELECT id, username, password_hash, role, totp_secret, totp_enabled, - created_at, last_login, COALESCE(is_server_admin, FALSE), totp_recovery_codes FROM users WHERE username = $1`, username) + created_at, last_login, COALESCE(is_server_admin, FALSE), totp_recovery_codes, COALESCE(auth_provider, 'local') FROM users WHERE username = $1`, username) u, err := scanUser(row) if err == pgx.ErrNoRows { return nil, nil @@ -964,7 +976,7 @@ func (pg *PostgresDB) GetUser(username string) (*User, error) { func (pg *PostgresDB) GetUserByID(id int64) (*User, error) { row := pg.pool.QueryRow(pg.ctx, `SELECT id, username, password_hash, role, totp_secret, totp_enabled, - created_at, last_login, COALESCE(is_server_admin, FALSE), totp_recovery_codes FROM users WHERE id = $1`, id) + created_at, last_login, COALESCE(is_server_admin, FALSE), totp_recovery_codes, COALESCE(auth_provider, 'local') FROM users WHERE id = $1`, id) u, err := scanUser(row) if err == pgx.ErrNoRows { return nil, nil @@ -976,7 +988,7 @@ func (pg *PostgresDB) GetUserByID(id int64) (*User, error) { func (pg *PostgresDB) ListUsers() ([]*User, error) { rows, err := pg.pool.Query(pg.ctx, `SELECT id, username, password_hash, role, totp_secret, totp_enabled, - created_at, last_login, COALESCE(is_server_admin, FALSE), totp_recovery_codes FROM users ORDER BY id`) + created_at, last_login, COALESCE(is_server_admin, FALSE), totp_recovery_codes, COALESCE(auth_provider, 'local') FROM users ORDER BY id`) if err != nil { return nil, fmt.Errorf("db: ListUsers: %w", err) } @@ -1001,10 +1013,13 @@ func (pg *PostgresDB) UpdateUser(u *User) error { } else { recoveryCodes = u.TOTPRecoveryCodes } + if u.AuthProvider == "" { + u.AuthProvider = AuthProviderLocal + } _, err := pg.pool.Exec(pg.ctx, - `UPDATE users SET password_hash = $1, role = $2, totp_secret = $3, totp_enabled = $4, is_server_admin = $5, totp_recovery_codes = $6 - WHERE id = $7`, - u.PasswordHash, u.Role, u.TOTPSecret, u.TOTPEnabled, u.IsServerAdmin, recoveryCodes, u.ID) + `UPDATE users SET password_hash = $1, role = $2, totp_secret = $3, totp_enabled = $4, is_server_admin = $5, totp_recovery_codes = $6, auth_provider = $7 + WHERE id = $8`, + u.PasswordHash, u.Role, u.TOTPSecret, u.TOTPEnabled, u.IsServerAdmin, recoveryCodes, u.AuthProvider, u.ID) return err } diff --git a/betterdesk-server/db/sqlite.go b/betterdesk-server/db/sqlite.go index d8c02be1..9c66c01e 100644 --- a/betterdesk-server/db/sqlite.go +++ b/betterdesk-server/db/sqlite.go @@ -92,6 +92,7 @@ func (s *SQLiteDB) Migrate() error { username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'viewer', + auth_provider TEXT NOT NULL DEFAULT 'local', totp_secret TEXT DEFAULT '', totp_enabled INTEGER DEFAULT 0, totp_recovery_codes TEXT DEFAULT NULL, @@ -390,6 +391,7 @@ func (s *SQLiteDB) Migrate() error { {"peers", "display_name", `ALTER TABLE peers ADD COLUMN display_name TEXT DEFAULT ''`}, // users: is_server_admin flag (RBAC Phase 52) {"users", "is_server_admin", `ALTER TABLE users ADD COLUMN is_server_admin INTEGER DEFAULT 0`}, + {"users", "auth_provider", `ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'local'`}, // org_users: server_user_id for linking existing users (Issue #106) {"org_users", "server_user_id", `ALTER TABLE org_users ADD COLUMN server_user_id INTEGER DEFAULT 0`}, } @@ -1047,9 +1049,12 @@ func formatTime(t time.Time) string { func (s *SQLiteDB) CreateUser(u *User) error { s.mu.Lock() defer s.mu.Unlock() - res, err := s.db.Exec(`INSERT INTO users (username, password_hash, role, totp_secret, totp_enabled) - VALUES (?, ?, ?, ?, ?)`, - u.Username, u.PasswordHash, u.Role, u.TOTPSecret, u.TOTPEnabled) + if u.AuthProvider == "" { + u.AuthProvider = AuthProviderLocal + } + res, err := s.db.Exec(`INSERT INTO users (username, password_hash, role, auth_provider, totp_secret, totp_enabled) + VALUES (?, ?, ?, ?, ?, ?)`, + u.Username, u.PasswordHash, u.Role, u.AuthProvider, u.TOTPSecret, u.TOTPEnabled) if err != nil { return fmt.Errorf("db: CreateUser: %w", err) } @@ -1063,9 +1068,9 @@ func (s *SQLiteDB) GetUser(username string) (*User, error) { defer s.mu.RUnlock() u := &User{} err := s.db.QueryRow(`SELECT id, username, password_hash, role, COALESCE(is_server_admin, 0), - totp_secret, totp_enabled, COALESCE(totp_recovery_codes, ''), created_at, last_login FROM users WHERE username = ?`, username).Scan( + COALESCE(auth_provider, 'local'), totp_secret, totp_enabled, COALESCE(totp_recovery_codes, ''), created_at, last_login FROM users WHERE username = ?`, username).Scan( &u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.IsServerAdmin, - &u.TOTPSecret, &u.TOTPEnabled, &u.TOTPRecoveryCodes, &u.CreatedAt, &u.LastLogin) + &u.AuthProvider, &u.TOTPSecret, &u.TOTPEnabled, &u.TOTPRecoveryCodes, &u.CreatedAt, &u.LastLogin) if err == sql.ErrNoRows { return nil, nil } @@ -1078,9 +1083,9 @@ func (s *SQLiteDB) GetUserByID(id int64) (*User, error) { defer s.mu.RUnlock() u := &User{} err := s.db.QueryRow(`SELECT id, username, password_hash, role, COALESCE(is_server_admin, 0), - totp_secret, totp_enabled, COALESCE(totp_recovery_codes, ''), created_at, last_login FROM users WHERE id = ?`, id).Scan( + COALESCE(auth_provider, 'local'), totp_secret, totp_enabled, COALESCE(totp_recovery_codes, ''), created_at, last_login FROM users WHERE id = ?`, id).Scan( &u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.IsServerAdmin, - &u.TOTPSecret, &u.TOTPEnabled, &u.TOTPRecoveryCodes, &u.CreatedAt, &u.LastLogin) + &u.AuthProvider, &u.TOTPSecret, &u.TOTPEnabled, &u.TOTPRecoveryCodes, &u.CreatedAt, &u.LastLogin) if err == sql.ErrNoRows { return nil, nil } @@ -1092,7 +1097,7 @@ func (s *SQLiteDB) ListUsers() ([]*User, error) { s.mu.RLock() defer s.mu.RUnlock() rows, err := s.db.Query(`SELECT id, username, password_hash, role, COALESCE(is_server_admin, 0), - totp_secret, totp_enabled, COALESCE(totp_recovery_codes, ''), created_at, last_login FROM users ORDER BY id`) + COALESCE(auth_provider, 'local'), totp_secret, totp_enabled, COALESCE(totp_recovery_codes, ''), created_at, last_login FROM users ORDER BY id`) if err != nil { return nil, fmt.Errorf("db: ListUsers: %w", err) } @@ -1101,7 +1106,7 @@ func (s *SQLiteDB) ListUsers() ([]*User, error) { for rows.Next() { u := &User{} if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.IsServerAdmin, - &u.TOTPSecret, &u.TOTPEnabled, &u.TOTPRecoveryCodes, &u.CreatedAt, &u.LastLogin); err != nil { + &u.AuthProvider, &u.TOTPSecret, &u.TOTPEnabled, &u.TOTPRecoveryCodes, &u.CreatedAt, &u.LastLogin); err != nil { return nil, err } users = append(users, u) @@ -1113,9 +1118,12 @@ func (s *SQLiteDB) ListUsers() ([]*User, error) { func (s *SQLiteDB) UpdateUser(u *User) error { s.mu.Lock() defer s.mu.Unlock() - _, err := s.db.Exec(`UPDATE users SET password_hash=?, role=?, is_server_admin=?, + if u.AuthProvider == "" { + u.AuthProvider = AuthProviderLocal + } + _, err := s.db.Exec(`UPDATE users SET password_hash=?, role=?, is_server_admin=?, auth_provider=?, totp_secret=?, totp_enabled=?, totp_recovery_codes=? WHERE id=?`, - u.PasswordHash, u.Role, u.IsServerAdmin, u.TOTPSecret, u.TOTPEnabled, u.TOTPRecoveryCodes, u.ID) + u.PasswordHash, u.Role, u.IsServerAdmin, u.AuthProvider, u.TOTPSecret, u.TOTPEnabled, u.TOTPRecoveryCodes, u.ID) return err } diff --git a/web-nodejs/lang/en.json b/web-nodejs/lang/en.json index 742a5713..46dbaac8 100644 --- a/web-nodejs/lang/en.json +++ b/web-nodejs/lang/en.json @@ -743,6 +743,12 @@ "org_membership_section": "Organization Membership", "org_membership_hint": "A user can belong to multiple organizations. The organization role only controls permissions inside that organization and is independent of the server role above.", "column_organizations": "Organizations", + "provider": "Provider", + "provider_local": "Local", + "provider_ldap": "LDAP/AD", + "provider_oidc": "SSO", + "password_managed_by_provider": "Managed by identity provider", + "provider_managed_hint": "This account is managed by an external identity provider (LDAP/AD or SSO). The password and role are controlled by the provider and cannot be changed here.", "no_orgs_short": "—", "org_count_one": "{count} organization", "org_count_many": "{count} organizations", diff --git a/web-nodejs/lang/pl.json b/web-nodejs/lang/pl.json index b8e4c91a..b3af7162 100644 --- a/web-nodejs/lang/pl.json +++ b/web-nodejs/lang/pl.json @@ -744,6 +744,12 @@ "org_membership_section": "Członkostwo w organizacjach", "org_membership_hint": "Użytkownik może należeć do wielu organizacji. Rola w organizacji obowiązuje tylko wewnątrz tej organizacji i jest niezależna od roli serwerowej powyżej.", "column_organizations": "Organizacje", + "provider": "Dostawca", + "provider_local": "Lokalny", + "provider_ldap": "LDAP/AD", + "provider_oidc": "SSO", + "password_managed_by_provider": "Zarządzane przez dostawcę tożsamości", + "provider_managed_hint": "To konto jest zarządzane przez zewnętrznego dostawcę tożsamości (LDAP/AD lub SSO). Hasło i rola są kontrolowane przez dostawcę i nie można ich tu zmienić.", "no_orgs_short": "—", "org_count_one": "{count} organizacja", "org_count_many": "{count} organizacji", diff --git a/web-nodejs/public/css/users.css b/web-nodejs/public/css/users.css index 40619eda..509c2a0c 100644 --- a/web-nodejs/public/css/users.css +++ b/web-nodejs/public/css/users.css @@ -279,6 +279,33 @@ color: var(--text-secondary, #8b949e); } +/* Authentication provider badge (Issue #148) */ +.provider-badge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: var(--radius-full); + font-size: var(--font-size-xs, 12px); + font-weight: var(--font-weight-medium); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.provider-badge.provider-local { + background: var(--bg-tertiary, #21262d); + color: var(--text-secondary, #8b949e); +} + +.provider-badge.provider-ldap { + background: rgba(14, 165, 233, 0.15); + color: #0ea5e9; +} + +.provider-badge.provider-oidc { + background: rgba(168, 85, 247, 0.15); + color: #a855f7; +} + /* Inline org badges in the users table column */ .user-orgs-cell { display: flex; diff --git a/web-nodejs/public/js/users.js b/web-nodejs/public/js/users.js index 5f115920..1fd592dc 100644 --- a/web-nodejs/public/js/users.js +++ b/web-nodejs/public/js/users.js @@ -320,6 +320,9 @@ }; const roleIcon = roleIcons[user.role] || 'person'; const roleLabelKey = 'users.role_' + user.role; + const provider = (user.auth_provider || 'local').toLowerCase(); + const providerLabel = _('users.provider_' + provider) || provider; + const isLocal = provider === 'local'; return ` @@ -338,6 +341,11 @@ ${_(roleLabelKey)} + + + ${Utils.escapeHtml(providerLabel)} + +
@@ -350,9 +358,9 @@ - + ` : ''} @@ -462,6 +470,23 @@ } if (roleSelect) roleSelect.value = user.role; if (passwordInput) passwordInput.placeholder = _('users.password_leave_empty'); + // LDAP/OIDC accounts are managed by the identity provider: + // password cannot be set locally and the role is provider-mapped. + const provider = (user.auth_provider || 'local').toLowerCase(); + if (provider !== 'local') { + if (passwordInput) { + passwordInput.value = ''; + passwordInput.disabled = true; + passwordInput.placeholder = _('users.password_managed_by_provider'); + } + const passwordGroup = passwordInput ? passwordInput.closest('.form-group') : null; + if (passwordGroup) { + const hint = document.createElement('span'); + hint.className = 'form-hint'; + hint.textContent = _('users.provider_managed_hint'); + passwordGroup.appendChild(hint); + } + } renderUserGroupCheckboxes(user.user_groups || []); } }); diff --git a/web-nodejs/routes/users.routes.js b/web-nodejs/routes/users.routes.js index c4e88c03..52ffaf50 100644 --- a/web-nodejs/routes/users.routes.js +++ b/web-nodejs/routes/users.routes.js @@ -161,6 +161,7 @@ router.get('/api/users', requireAuth, requirePermission('user.view'), async (req id: u.id, username: u.username, role: u.role, + auth_provider: u.auth_provider || 'local', created_at: u.created_at, last_login: u.last_login, user_groups: await getUserGroupGuids(u.id) diff --git a/web-nodejs/services/dbAdapter.js b/web-nodejs/services/dbAdapter.js index fc5692c6..09ae0d4c 100644 --- a/web-nodejs/services/dbAdapter.js +++ b/web-nodejs/services/dbAdapter.js @@ -234,6 +234,7 @@ function createSqliteAdapter(config) { username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, role TEXT DEFAULT 'admin', + auth_provider TEXT DEFAULT 'local', created_at TEXT DEFAULT (datetime('now')), last_login TEXT, preferred_language TEXT DEFAULT NULL, @@ -453,6 +454,7 @@ function createSqliteAdapter(config) { // Migration: Add missing columns to existing users table (for upgrades from older versions) const userColsMigration = [ { name: 'last_login', sql: 'TEXT' }, + { name: 'auth_provider', sql: "TEXT DEFAULT 'local'" }, { name: 'preferred_language', sql: 'TEXT DEFAULT NULL' }, { name: 'totp_secret', sql: 'TEXT DEFAULT NULL' }, { name: 'totp_enabled', sql: 'INTEGER DEFAULT 0' }, @@ -1177,7 +1179,7 @@ function createSqliteAdapter(config) { return (openAuth().prepare('SELECT COUNT(*) as c FROM users').get().c) > 0; }, async getAllUsers() { - return openAuth().prepare('SELECT id, username, role, created_at, last_login, preferred_language, totp_enabled FROM users ORDER BY id').all(); + return openAuth().prepare('SELECT id, username, role, auth_provider, created_at, last_login, preferred_language, totp_enabled FROM users ORDER BY id').all(); }, async updateUserRole(id, role) { openAuth().prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id); @@ -2939,6 +2941,7 @@ function createPostgresAdapter() { username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, role TEXT DEFAULT 'admin', + auth_provider TEXT DEFAULT 'local', created_at TIMESTAMPTZ DEFAULT NOW(), last_login TIMESTAMPTZ, preferred_language TEXT DEFAULT NULL, @@ -3574,6 +3577,9 @@ function createPostgresAdapter() { if (!existingCols.has('last_login')) { await q('ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login TIMESTAMPTZ'); } + if (!existingCols.has('auth_provider')) { + await q("ALTER TABLE users ADD COLUMN IF NOT EXISTS auth_provider TEXT DEFAULT 'local'"); + } if (!existingCols.has('preferred_language')) { await q('ALTER TABLE users ADD COLUMN IF NOT EXISTS preferred_language TEXT DEFAULT NULL'); } @@ -3897,7 +3903,7 @@ function createPostgresAdapter() { async updateUserPassword(id, passwordHash) { await q('UPDATE users SET password_hash = $1 WHERE id = $2', [passwordHash, id]); }, async touchLastLogin(id) { await q('UPDATE users SET last_login = NOW() WHERE id = $1', [id]); }, async hasUsers() { return +(await one('SELECT COUNT(*) as c FROM users')).c > 0; }, - async getAllUsers() { return all('SELECT id, username, role, created_at, last_login, preferred_language, totp_enabled FROM users ORDER BY id'); }, + async getAllUsers() { return all('SELECT id, username, role, auth_provider, created_at, last_login, preferred_language, totp_enabled FROM users ORDER BY id'); }, async updateUserRole(id, role) { await q('UPDATE users SET role = $1 WHERE id = $2', [role, id]); }, async updateUserLanguage(id, lang) { await q('UPDATE users SET preferred_language = $1 WHERE id = $2', [lang, id]); }, async updateUserProfile(id, fields) { diff --git a/web-nodejs/services/userSync.js b/web-nodejs/services/userSync.js index bccb400c..824e11da 100644 --- a/web-nodejs/services/userSync.js +++ b/web-nodejs/services/userSync.js @@ -88,6 +88,7 @@ function readGoUsersFromSqlite() { 'username', 'password_hash', cols.has('role') ? "COALESCE(role, 'viewer') AS role" : "'viewer' AS role", + cols.has('auth_provider') ? "COALESCE(auth_provider, 'local') AS auth_provider" : "'local' AS auth_provider", cols.has('totp_secret') ? "COALESCE(totp_secret, '') AS totp_secret" : "'' AS totp_secret", cols.has('totp_enabled') ? 'COALESCE(totp_enabled, 0) AS totp_enabled' : '0 AS totp_enabled', cols.has('created_at') ? 'created_at' : "datetime('now') AS created_at", @@ -306,12 +307,12 @@ async function backfillFromGo() { } const insertWithId = authDb.prepare(` - INSERT INTO users (id, username, password_hash, role, created_at, last_login, totp_secret, totp_enabled) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO users (id, username, password_hash, role, auth_provider, created_at, last_login, totp_secret, totp_enabled) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `); const insertWithoutId = authDb.prepare(` - INSERT INTO users (username, password_hash, role, created_at, last_login, totp_secret, totp_enabled) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO users (username, password_hash, role, auth_provider, created_at, last_login, totp_secret, totp_enabled) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) `); let imported = 0; @@ -319,6 +320,7 @@ async function backfillFromGo() { const username = String(user.username || '').trim(); const passwordHash = String(user.password_hash || '').trim(); const role = normalizeRole(user.role); + const authProvider = String(user.auth_provider || 'local').trim() || 'local'; const createdAt = user.created_at || new Date().toISOString(); const lastLogin = user.last_login || null; const totpSecret = user.totp_secret || null; @@ -327,10 +329,10 @@ async function backfillFromGo() { try { if (Number.isInteger(goId) && goId > 0 && !localIds.has(goId)) { - insertWithId.run(goId, username, passwordHash, role, createdAt, lastLogin, totpSecret, totpEnabled); + insertWithId.run(goId, username, passwordHash, role, authProvider, createdAt, lastLogin, totpSecret, totpEnabled); localIds.add(goId); } else { - insertWithoutId.run(username, passwordHash, role, createdAt, lastLogin, totpSecret, totpEnabled); + insertWithoutId.run(username, passwordHash, role, authProvider, createdAt, lastLogin, totpSecret, totpEnabled); } localByUsername.add(normalizeUsername(username)); imported++; diff --git a/web-nodejs/views/users.ejs b/web-nodejs/views/users.ejs index 7dd5be90..45c50454 100644 --- a/web-nodejs/views/users.ejs +++ b/web-nodejs/views/users.ejs @@ -58,6 +58,7 @@ ${_('users.username')} ${_('users.role')} + ${_('users.provider')} ${_('users.column_organizations')} ${_('users.created')} ${_('users.last_login')}