mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 01:27:11 +00:00
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.
This commit is contained in:
@@ -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"})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 `
|
||||
<tr data-id="${user.id}">
|
||||
<td>
|
||||
@@ -338,6 +341,11 @@
|
||||
${_(roleLabelKey)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="provider-badge provider-${provider}" title="${Utils.escapeHtml(providerLabel)}">
|
||||
${Utils.escapeHtml(providerLabel)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="user-orgs-cell" data-user-id="${user.id}" data-username="${Utils.escapeHtml(user.username)}">
|
||||
<span class="skeleton skeleton-text" style="width: 80px; height: 14px;"></span>
|
||||
@@ -350,9 +358,9 @@
|
||||
<button class="action-btn" title="${_('users.organizations')}" data-action="organizations" data-id="${user.id}" data-username="${Utils.escapeHtml(user.username)}">
|
||||
<span class="material-icons">business</span>
|
||||
</button>
|
||||
<button class="action-btn" title="${_('users.reset_password')}" data-action="reset-password" data-id="${user.id}">
|
||||
${isLocal ? `<button class="action-btn" title="${_('users.reset_password')}" data-action="reset-password" data-id="${user.id}">
|
||||
<span class="material-icons">lock_reset</span>
|
||||
</button>
|
||||
</button>` : ''}
|
||||
<button class="action-btn" title="${_('users.edit')}" data-action="edit" data-id="${user.id}">
|
||||
<span class="material-icons">edit</span>
|
||||
</button>
|
||||
@@ -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 || []);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
<tr>
|
||||
<th>${_('users.username')}</th>
|
||||
<th>${_('users.role')}</th>
|
||||
<th>${_('users.provider')}</th>
|
||||
<th>${_('users.column_organizations')}</th>
|
||||
<th>${_('users.created')}</th>
|
||||
<th>${_('users.last_login')}</th>
|
||||
|
||||
Reference in New Issue
Block a user