feat(auth): add LDAP and OIDC/OAuth2 SSO authentication

Go server: auth/ldap.go (LDAP bind + group-to-role mapping), auth/oidc.go (OIDC provider with PKCE, discovery, token exchange), api/ldap_handlers.go (config CRUD + test connection), api/oidc_handlers.go (authorize/callback/exchange/status + config CRUD), auth_handlers.go (LDAP auth branch before local password check + PBKDF2 rehash on login), password.go (NeedsRehash helper), server.go (LDAP/OIDC route registration + Init methods), main.go (InitLDAP/InitOIDC calls). Node.js console: auth.routes.js (OIDC proxy routes with open-redirect sanitization), settings.routes.js (LDAP/OIDC config tabs), authService.js (LDAP/OIDC auth flows with emergency mode), betterdeskApi.js (6 new SSO API methods), middleware/auth.js (emergencyMode flag), login.ejs (SSO buttons), settings.ejs (LDAP/OIDC config panels), navbar.ejs (SSO indicator). i18n: ~90 SSO keys added to EN/PL/ZH.

This commit was made possible thanks to Insolve.
This commit is contained in:
UNITRONIX
2026-05-29 02:29:10 +02:00
parent 83f3617f98
commit 188991d91d
26 changed files with 4512 additions and 107 deletions
+92
View File
@@ -209,6 +209,88 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Internal error"})
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
ldapResult = nil
}
if ldapResult != nil && ldapResult.Authenticated {
// LDAP auth succeeded — auto-provision local user if needed
if user == nil {
hash, hashErr := auth.HashPassword(body.Password)
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,
Role: role,
}
if createErr := s.db.CreateUser(newUser); createErr != nil {
log.Printf("[LDAP] Failed to auto-create user %s: %v", body.Username, createErr)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Internal error"})
return
}
user, _ = s.db.GetUser(body.Username)
if user == nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Internal error"})
return
}
log.Printf("[LDAP] Auto-provisioned user %s with role %s", body.Username, role)
} else {
// Update role from LDAP group mapping if changed
if ldapResult.Role != "" && ldapResult.Role != user.Role {
user.Role = ldapResult.Role
_ = s.db.UpdateUser(user)
log.Printf("[LDAP] Updated role for %s to %s", body.Username, ldapResult.Role)
}
}
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
if user.TOTPEnabled {
partialToken, tokenErr := s.jwtManager.GenerateWithTTL(user.Username, "__2fa_pending__", 5*time.Minute)
if tokenErr != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Token generation failed"})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"requires_2fa": true,
"partial_token": partialToken,
})
return
}
token, tokenErr := s.jwtManager.Generate(user.Username, user.Role)
if tokenErr != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Token generation failed"})
return
}
_ = s.db.UpdateUserLogin(user.ID)
writeJSON(w, http.StatusOK, map[string]any{
"token": token,
"role": user.Role,
"username": user.Username,
})
return
}
}
// Local password verification
if user == nil || !auth.VerifyPassword(user.PasswordHash, body.Password) {
if s.auditLog != nil {
s.auditLog.Log(audit.ActionAuthLoginFailed, s.remoteIP(r), body.Username, nil)
@@ -217,6 +299,15 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
// Rehash bcrypt/legacy passwords to PBKDF2 on successful login
if auth.NeedsRehash(user.PasswordHash) {
if newHash, hashErr := auth.HashPassword(body.Password); hashErr == nil {
user.PasswordHash = newHash
_ = s.db.UpdateUser(user)
log.Printf("[auth] Rehashed password for %s (migrated to PBKDF2)", user.Username)
}
}
// If TOTP is enabled, issue a short-lived partial token that requires 2FA completion.
// H4: Use 5-minute TTL instead of the default 24h to limit brute-force window.
if user.TOTPEnabled {
@@ -1045,6 +1136,7 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
path == "/api/heartbeat" || path == "/api/sysinfo" || path == "/api/sysinfo_ver" ||
path == "/api/branding" ||
path == "/api/org/login" ||
path == "/api/auth/oidc/status" || path == "/api/auth/oidc/authorize" || path == "/api/auth/oidc/callback" || path == "/api/auth/oidc/exchange" ||
strings.HasPrefix(path, "/ws/bd-mgmt/") ||
path == "/api/devices/register" || path == "/api/devices/register/status" {
next.ServeHTTP(w, r)
+344
View File
@@ -0,0 +1,344 @@
// LDAP configuration API handlers for the BetterDesk server.
// Endpoints for managing LDAP/AD authentication provider settings.
package api
import (
"encoding/json"
"log"
"net/http"
"strconv"
"github.com/unitronix/betterdesk-server/auth"
)
// ldapConfigKeys are the server_config keys used to store LDAP settings.
var ldapConfigKeys = []string{
"ldap.enabled",
"ldap.host",
"ldap.port",
"ldap.use_tls",
"ldap.start_tls",
"ldap.skip_tls_verify",
"ldap.bind_dn",
"ldap.bind_password",
"ldap.base_dn",
"ldap.user_filter",
"ldap.user_attr_id",
"ldap.user_attr_email",
"ldap.user_attr_name",
"ldap.group_base_dn",
"ldap.group_filter",
"ldap.group_attr_name",
"ldap.default_role",
"ldap.group_role_map",
"ldap.direct_bind",
"ldap.direct_bind_dn",
"ldap.conn_timeout_sec",
}
// loadLDAPConfigFromDB reads LDAP configuration from the server_config table.
func (s *Server) loadLDAPConfigFromDB() *auth.LDAPConfig {
cfg := &auth.LDAPConfig{
Port: 389,
UserFilter: "(sAMAccountName={{username}})",
UserAttrID: "sAMAccountName",
UserAttrEmail: "mail",
UserAttrName: "displayName",
GroupFilter: "(member={{dn}})",
GroupAttrName: "cn",
DefaultRole: "viewer",
ConnTimeoutSec: 10,
}
getString := func(key string) string {
v, err := s.db.GetConfig(key)
if err != nil || v == "" {
return ""
}
return v
}
getBool := func(key string) bool {
return getString(key) == "true"
}
getInt := func(key, fallback string) int {
v := getString(key)
if v == "" {
v = fallback
}
n, _ := strconv.Atoi(v)
return n
}
cfg.Enabled = getBool("ldap.enabled")
if v := getString("ldap.host"); v != "" {
cfg.Host = v
}
cfg.Port = getInt("ldap.port", "389")
cfg.UseTLS = getBool("ldap.use_tls")
cfg.StartTLS = getBool("ldap.start_tls")
cfg.SkipTLSVerify = getBool("ldap.skip_tls_verify")
if v := getString("ldap.bind_dn"); v != "" {
cfg.BindDN = v
}
if v := getString("ldap.bind_password"); v != "" {
cfg.BindPassword = v
}
if v := getString("ldap.base_dn"); v != "" {
cfg.BaseDN = v
}
if v := getString("ldap.user_filter"); v != "" {
cfg.UserFilter = v
}
if v := getString("ldap.user_attr_id"); v != "" {
cfg.UserAttrID = v
}
if v := getString("ldap.user_attr_email"); v != "" {
cfg.UserAttrEmail = v
}
if v := getString("ldap.user_attr_name"); v != "" {
cfg.UserAttrName = v
}
if v := getString("ldap.group_base_dn"); v != "" {
cfg.GroupBaseDN = v
}
if v := getString("ldap.group_filter"); v != "" {
cfg.GroupFilter = v
}
if v := getString("ldap.group_attr_name"); v != "" {
cfg.GroupAttrName = v
}
if v := getString("ldap.default_role"); v != "" {
cfg.DefaultRole = v
}
if v := getString("ldap.group_role_map"); v != "" {
cfg.GroupRoleMap = v
}
cfg.DirectBind = getBool("ldap.direct_bind")
if v := getString("ldap.direct_bind_dn"); v != "" {
cfg.DirectBindDN = v
}
if n := getInt("ldap.conn_timeout_sec", "10"); n > 0 {
cfg.ConnTimeoutSec = n
}
return cfg
}
// saveLDAPConfigToDB writes LDAP configuration to the server_config table.
func (s *Server) saveLDAPConfigToDB(cfg *auth.LDAPConfig) error {
setString := func(key, val string) error {
return s.db.SetConfig(key, val)
}
setBool := func(key string, val bool) error {
v := "false"
if val {
v = "true"
}
return s.db.SetConfig(key, v)
}
setInt := func(key string, val int) error {
return s.db.SetConfig(key, strconv.Itoa(val))
}
if err := setBool("ldap.enabled", cfg.Enabled); err != nil {
return err
}
if err := setString("ldap.host", cfg.Host); err != nil {
return err
}
if err := setInt("ldap.port", cfg.Port); err != nil {
return err
}
if err := setBool("ldap.use_tls", cfg.UseTLS); err != nil {
return err
}
if err := setBool("ldap.start_tls", cfg.StartTLS); err != nil {
return err
}
if err := setBool("ldap.skip_tls_verify", cfg.SkipTLSVerify); err != nil {
return err
}
if err := setString("ldap.bind_dn", cfg.BindDN); err != nil {
return err
}
if err := setString("ldap.bind_password", cfg.BindPassword); err != nil {
return err
}
if err := setString("ldap.base_dn", cfg.BaseDN); err != nil {
return err
}
if err := setString("ldap.user_filter", cfg.UserFilter); err != nil {
return err
}
if err := setString("ldap.user_attr_id", cfg.UserAttrID); err != nil {
return err
}
if err := setString("ldap.user_attr_email", cfg.UserAttrEmail); err != nil {
return err
}
if err := setString("ldap.user_attr_name", cfg.UserAttrName); err != nil {
return err
}
if err := setString("ldap.group_base_dn", cfg.GroupBaseDN); err != nil {
return err
}
if err := setString("ldap.group_filter", cfg.GroupFilter); err != nil {
return err
}
if err := setString("ldap.group_attr_name", cfg.GroupAttrName); err != nil {
return err
}
if err := setString("ldap.default_role", cfg.DefaultRole); err != nil {
return err
}
if err := setString("ldap.group_role_map", cfg.GroupRoleMap); err != nil {
return err
}
if err := setBool("ldap.direct_bind", cfg.DirectBind); err != nil {
return err
}
if err := setString("ldap.direct_bind_dn", cfg.DirectBindDN); err != nil {
return err
}
if err := setInt("ldap.conn_timeout_sec", cfg.ConnTimeoutSec); err != nil {
return err
}
return nil
}
// handleGetLDAPConfig returns the current LDAP configuration (password masked).
// GET /api/auth/ldap/config
func (s *Server) handleGetLDAPConfig(w http.ResponseWriter, r *http.Request) {
cfg := s.loadLDAPConfigFromDB()
// Mask bind password in response
maskedCfg := *cfg
if maskedCfg.BindPassword != "" {
maskedCfg.BindPassword = "••••••••"
}
writeJSON(w, http.StatusOK, maskedCfg)
}
// handleSaveLDAPConfig saves the LDAP configuration.
// PUT /api/auth/ldap/config
func (s *Server) handleSaveLDAPConfig(w http.ResponseWriter, r *http.Request) {
var cfg auth.LDAPConfig
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON"})
return
}
// Validate required fields when enabling
if cfg.Enabled {
if cfg.Host == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "LDAP host is required"})
return
}
if cfg.BaseDN == "" && !cfg.DirectBind {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Base DN is required for bind+search mode"})
return
}
if cfg.DirectBind && cfg.DirectBindDN == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Direct bind DN template is required"})
return
}
if cfg.DefaultRole != "" && !auth.ValidRole(cfg.DefaultRole) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid default role"})
return
}
}
// If password is masked placeholder, preserve the current password
if cfg.BindPassword == "••••••••" || cfg.BindPassword == "" {
currentCfg := s.loadLDAPConfigFromDB()
cfg.BindPassword = currentCfg.BindPassword
}
// Set defaults
if cfg.Port == 0 {
if cfg.UseTLS {
cfg.Port = 636
} else {
cfg.Port = 389
}
}
if cfg.ConnTimeoutSec <= 0 {
cfg.ConnTimeoutSec = 10
}
if err := s.saveLDAPConfigToDB(&cfg); err != nil {
log.Printf("[LDAP] Failed to save config: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Failed to save configuration"})
return
}
// Update the in-memory LDAP provider
if s.ldapProvider != nil {
s.ldapProvider.UpdateConfig(&cfg)
}
log.Printf("[LDAP] Configuration saved (enabled=%v, host=%s, port=%d)", cfg.Enabled, cfg.Host, cfg.Port)
if s.auditLog != nil {
s.auditLog.Log("ldap_config_updated", s.remoteIP(r), getUsernameFromCtx(r), map[string]string{
"enabled": strconv.FormatBool(cfg.Enabled),
"host": cfg.Host,
"port": strconv.Itoa(cfg.Port),
})
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleTestLDAPConnection tests the LDAP connection with current or provided config.
// POST /api/auth/ldap/test
func (s *Server) handleTestLDAPConnection(w http.ResponseWriter, r *http.Request) {
var cfg auth.LDAPConfig
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON"})
return
}
// If password is masked, use current stored password
if cfg.BindPassword == "••••••••" || cfg.BindPassword == "" {
currentCfg := s.loadLDAPConfigFromDB()
cfg.BindPassword = currentCfg.BindPassword
}
if cfg.Host == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "LDAP host is required"})
return
}
if cfg.Port == 0 {
if cfg.UseTLS {
cfg.Port = 636
} else {
cfg.Port = 389
}
}
if cfg.ConnTimeoutSec <= 0 {
cfg.ConnTimeoutSec = 10
}
provider := auth.NewLDAPProvider(&cfg)
// Force enabled for test
testCfg := cfg
testCfg.Enabled = true
provider.UpdateConfig(&testCfg)
if err := provider.TestConnection(); err != nil {
writeJSON(w, http.StatusOK, map[string]any{
"success": false,
"error": err.Error(),
})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"message": "Connection successful",
})
}
+494
View File
@@ -0,0 +1,494 @@
// OIDC/OAuth2 configuration and callback API handlers for the BetterDesk server.
// Endpoints for managing OIDC provider settings and handling the authorization code flow.
package api
import (
"encoding/json"
"log"
"net/http"
"net/url"
"github.com/unitronix/betterdesk-server/audit"
"github.com/unitronix/betterdesk-server/auth"
"github.com/unitronix/betterdesk-server/db"
)
// loadOIDCConfigFromDB reads OIDC configuration from the server_config table.
func (s *Server) loadOIDCConfigFromDB() *auth.OIDCConfig {
cfg := &auth.OIDCConfig{
Scopes: "openid profile email",
ClaimUsername: "preferred_username",
ClaimEmail: "email",
ClaimName: "name",
ClaimGroups: "groups",
DefaultRole: "viewer",
AllowSignup: true,
AutoDiscovery: true,
}
getString := func(key string) string {
v, err := s.db.GetConfig(key)
if err != nil || v == "" {
return ""
}
return v
}
getBool := func(key string) bool {
return getString(key) == "true"
}
cfg.Enabled = getBool("oidc.enabled")
if v := getString("oidc.display_name"); v != "" {
cfg.DisplayName = v
}
if v := getString("oidc.issuer_url"); v != "" {
cfg.IssuerURL = v
}
if v := getString("oidc.client_id"); v != "" {
cfg.ClientID = v
}
if v := getString("oidc.client_secret"); v != "" {
cfg.ClientSecret = v
}
if v := getString("oidc.redirect_url"); v != "" {
cfg.RedirectURL = v
}
if v := getString("oidc.scopes"); v != "" {
cfg.Scopes = v
}
cfg.UsePKCE = getBool("oidc.use_pkce")
// auto_discovery defaults to true unless explicitly "false"
if v := getString("oidc.auto_discovery"); v != "" {
cfg.AutoDiscovery = v == "true"
}
if v := getString("oidc.authorization_url"); v != "" {
cfg.AuthorizationURL = v
}
if v := getString("oidc.token_url"); v != "" {
cfg.TokenURL = v
}
if v := getString("oidc.userinfo_url"); v != "" {
cfg.UserinfoURL = v
}
if v := getString("oidc.claim_username"); v != "" {
cfg.ClaimUsername = v
}
if v := getString("oidc.claim_email"); v != "" {
cfg.ClaimEmail = v
}
if v := getString("oidc.claim_name"); v != "" {
cfg.ClaimName = v
}
if v := getString("oidc.claim_groups"); v != "" {
cfg.ClaimGroups = v
}
if v := getString("oidc.default_role"); v != "" {
cfg.DefaultRole = v
}
if v := getString("oidc.group_role_map"); v != "" {
cfg.GroupRoleMap = v
}
if v := getString("oidc.allow_signup"); v != "" {
cfg.AllowSignup = v == "true"
}
return cfg
}
// saveOIDCConfigToDB writes OIDC configuration to the server_config table.
func (s *Server) saveOIDCConfigToDB(cfg *auth.OIDCConfig) error {
set := func(key, val string) error {
return s.db.SetConfig(key, val)
}
setBool := func(key string, val bool) error {
v := "false"
if val {
v = "true"
}
return s.db.SetConfig(key, v)
}
if err := setBool("oidc.enabled", cfg.Enabled); err != nil {
return err
}
if err := set("oidc.display_name", cfg.DisplayName); err != nil {
return err
}
if err := set("oidc.issuer_url", cfg.IssuerURL); err != nil {
return err
}
if err := set("oidc.client_id", cfg.ClientID); err != nil {
return err
}
if err := set("oidc.client_secret", cfg.ClientSecret); err != nil {
return err
}
if err := set("oidc.redirect_url", cfg.RedirectURL); err != nil {
return err
}
if err := set("oidc.scopes", cfg.Scopes); err != nil {
return err
}
if err := setBool("oidc.use_pkce", cfg.UsePKCE); err != nil {
return err
}
if err := setBool("oidc.auto_discovery", cfg.AutoDiscovery); err != nil {
return err
}
if err := set("oidc.authorization_url", cfg.AuthorizationURL); err != nil {
return err
}
if err := set("oidc.token_url", cfg.TokenURL); err != nil {
return err
}
if err := set("oidc.userinfo_url", cfg.UserinfoURL); err != nil {
return err
}
if err := set("oidc.claim_username", cfg.ClaimUsername); err != nil {
return err
}
if err := set("oidc.claim_email", cfg.ClaimEmail); err != nil {
return err
}
if err := set("oidc.claim_name", cfg.ClaimName); err != nil {
return err
}
if err := set("oidc.claim_groups", cfg.ClaimGroups); err != nil {
return err
}
if err := set("oidc.default_role", cfg.DefaultRole); err != nil {
return err
}
if err := set("oidc.group_role_map", cfg.GroupRoleMap); err != nil {
return err
}
if err := setBool("oidc.allow_signup", cfg.AllowSignup); err != nil {
return err
}
return nil
}
// handleGetOIDCConfig returns the current OIDC configuration.
// GET /api/auth/oidc/config
func (s *Server) handleGetOIDCConfig(w http.ResponseWriter, r *http.Request) {
cfg := s.loadOIDCConfigFromDB()
// Mask client secret for display
if cfg.ClientSecret != "" {
cfg.ClientSecret = "••••••••"
}
writeJSON(w, http.StatusOK, cfg)
}
// handleSaveOIDCConfig saves the OIDC configuration.
// PUT /api/auth/oidc/config
func (s *Server) handleSaveOIDCConfig(w http.ResponseWriter, r *http.Request) {
var cfg auth.OIDCConfig
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON"})
return
}
// If secret is masked, preserve the existing one
if cfg.ClientSecret == "••••••••" {
existing := s.loadOIDCConfigFromDB()
cfg.ClientSecret = existing.ClientSecret
}
// Basic validation
if cfg.Enabled {
if cfg.ClientID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Client ID is required when OIDC is enabled"})
return
}
if cfg.IssuerURL == "" && (cfg.AuthorizationURL == "" || cfg.TokenURL == "") {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Issuer URL or manual endpoint URLs are required"})
return
}
if cfg.RedirectURL == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Redirect URL is required"})
return
}
}
if err := s.saveOIDCConfigToDB(&cfg); err != nil {
log.Printf("[OIDC] Failed to save config: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Failed to save configuration"})
return
}
// Update the live provider
if s.oidcProvider != nil {
s.oidcProvider.UpdateConfig(&cfg)
}
log.Printf("[OIDC] Configuration updated (enabled=%v, issuer=%s)", cfg.Enabled, cfg.IssuerURL)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleOIDCLoginOptions returns OIDC status for the login page.
// GET /api/auth/oidc/status
// Public endpoint — used by login page to show/hide SSO button.
func (s *Server) handleOIDCLoginStatus(w http.ResponseWriter, r *http.Request) {
if s.oidcProvider == nil || !s.oidcProvider.IsEnabled() {
writeJSON(w, http.StatusOK, map[string]interface{}{
"enabled": false,
})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"enabled": true,
"display_name": s.oidcProvider.GetDisplayName(),
})
}
// handleOIDCAuthorize starts the OIDC authorization code flow.
// GET /api/auth/oidc/authorize
// Public endpoint — redirects user to the IdP's authorization page.
func (s *Server) handleOIDCAuthorize(w http.ResponseWriter, r *http.Request) {
if s.oidcProvider == nil || !s.oidcProvider.IsEnabled() {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "OIDC is not enabled"})
return
}
// Only accept relative paths to prevent open-redirect via the IdP round-trip.
returnURL := r.URL.Query().Get("return_url")
if !auth.IsRelativeReturnURL(returnURL) {
returnURL = "/"
}
authURL, _, err := s.oidcProvider.BuildAuthURL(returnURL)
if err != nil {
log.Printf("[OIDC] Failed to build auth URL: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Failed to initiate OIDC login"})
return
}
http.Redirect(w, r, authURL, http.StatusFound)
}
// handleOIDCCallback handles the IdP callback with the authorization code.
// GET /api/auth/oidc/callback
// Public endpoint — exchanges code for tokens, creates/updates user, issues JWT.
func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
if s.oidcProvider == nil || !s.oidcProvider.IsEnabled() {
http.Error(w, "OIDC is not enabled", http.StatusBadRequest)
return
}
// Check for error from IdP
if errCode := r.URL.Query().Get("error"); errCode != "" {
errDesc := r.URL.Query().Get("error_description")
log.Printf("[OIDC] IdP returned error: %s — %s", errCode, errDesc)
// Redirect to login with error
http.Redirect(w, r, "/login?error=oidc_denied", http.StatusFound)
return
}
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
if code == "" || state == "" {
http.Redirect(w, r, "/login?error=oidc_invalid", http.StatusFound)
return
}
// Exchange code for tokens and user info
result, err := s.oidcProvider.ExchangeCode(r.Context(), code, state)
if err != nil {
log.Printf("[OIDC] Code exchange failed: %v", err)
http.Redirect(w, r, "/login?error=oidc_failed", http.StatusFound)
return
}
// Find or create local user
user, err := s.db.GetUser(result.Username)
if err != nil {
log.Printf("[OIDC] DB error looking up user %s: %v", result.Username, err)
http.Redirect(w, r, "/login?error=oidc_error", http.StatusFound)
return
}
cfg := s.oidcProvider.GetConfig()
if user == nil {
// Auto-provision new user
if !cfg.AllowSignup {
log.Printf("[OIDC] User %s not found and auto-signup disabled", result.Username)
http.Redirect(w, r, "/login?error=oidc_no_account", http.StatusFound)
return
}
// Generate a random password (user will authenticate via OIDC, not local password)
randomPass, err := auth.GenerateRandomString(32)
if err != nil {
log.Printf("[OIDC] Failed to generate random password: %v", err)
http.Redirect(w, r, "/login?error=oidc_error", http.StatusFound)
return
}
hash, err := auth.HashPassword(randomPass)
if err != nil {
log.Printf("[OIDC] Failed to hash password: %v", err)
http.Redirect(w, r, "/login?error=oidc_error", http.StatusFound)
return
}
role := result.Role
if role == "" {
role = auth.RoleViewer
}
newUser := &db.User{
Username: result.Username,
PasswordHash: hash,
Role: role,
}
if createErr := s.db.CreateUser(newUser); createErr != nil {
log.Printf("[OIDC] Failed to create user %s: %v", result.Username, createErr)
http.Redirect(w, r, "/login?error=oidc_error", http.StatusFound)
return
}
user, _ = s.db.GetUser(result.Username)
if user == nil {
http.Redirect(w, r, "/login?error=oidc_error", http.StatusFound)
return
}
log.Printf("[OIDC] Auto-provisioned user %s with role %s", result.Username, role)
} else {
// Update role from OIDC group mapping if changed
if result.Role != "" && result.Role != user.Role {
user.Role = result.Role
_ = s.db.UpdateUser(user)
log.Printf("[OIDC] Updated role for %s to %s", result.Username, result.Role)
}
}
// Audit log
if s.auditLog != nil {
s.auditLog.Log(audit.ActionAuthLogin, s.remoteIP(r), user.Username, map[string]string{"method": "oidc"})
}
_ = s.db.UpdateUserLogin(user.ID)
// Issue JWT token
token, err := s.jwtManager.Generate(user.Username, user.Role)
if err != nil {
log.Printf("[OIDC] Failed to generate JWT for %s: %v", user.Username, err)
http.Redirect(w, r, "/login?error=oidc_error", http.StatusFound)
return
}
// Get return URL from state, re-validating it is a safe relative path.
returnURL := s.oidcProvider.GetReturnURL(state)
if !auth.IsRelativeReturnURL(returnURL) {
returnURL = "/"
}
// Store a one-time auth code that the panel will exchange via a
// server-to-server POST to /api/auth/oidc/exchange. This prevents the
// JWT from being exposed in the browser URL bar, Referer headers,
// browser history, and access logs.
authCode, err := s.oidcProvider.StoreAuthCode(token, user.Username, user.Role, returnURL)
if err != nil {
log.Printf("[OIDC] Failed to store auth code for %s: %v", user.Username, err)
http.Redirect(w, r, "/login?error=oidc_error", http.StatusFound)
return
}
// Redirect to Node.js callback handler with ONLY the auth code.
// Node.js POSTs back to /api/auth/oidc/exchange to retrieve the JWT.
callbackURL := "/api/auth/oidc/session?code=" + url.QueryEscape(authCode)
http.Redirect(w, r, callbackURL, http.StatusFound)
}
// handleOIDCExchange exchanges a one-time auth code for the JWT + verified
// user identity. POST /api/auth/oidc/exchange (public, no auth — the code
// itself is the credential).
//
// Request body: {"code": "..."}
// Response: {"token": "...", "username": "...", "role": "...", "return_url": "..."}
//
// Codes are single-use and expire after 60 seconds.
func (s *Server) handleOIDCExchange(w http.ResponseWriter, r *http.Request) {
if s.oidcProvider == nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "OIDC is not configured"})
return
}
var req struct {
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Code == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
return
}
entry := s.oidcProvider.ConsumeAuthCode(req.Code)
if entry == nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid or expired code"})
return
}
writeJSON(w, http.StatusOK, map[string]string{
"token": entry.Token,
"username": entry.Username,
"role": entry.Role,
"return_url": entry.ReturnURL,
})
}
// handleGetOIDCEnabled returns whether OIDC is enabled and the display name.
// GET /api/auth/oidc/enabled (public, no auth)
func (s *Server) handleGetOIDCEnabled(w http.ResponseWriter, r *http.Request) {
enabled := false
displayName := "SSO"
if s.oidcProvider != nil && s.oidcProvider.IsEnabled() {
enabled = true
displayName = s.oidcProvider.GetDisplayName()
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"enabled": enabled,
"display_name": displayName,
})
}
// handleTestOIDCDiscovery tests OIDC discovery by fetching .well-known/openid-configuration.
// POST /api/auth/oidc/test
func (s *Server) handleTestOIDCDiscovery(w http.ResponseWriter, r *http.Request) {
var body struct {
IssuerURL string `json:"issuer_url"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON"})
return
}
if body.IssuerURL == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Issuer URL is required"})
return
}
// Test by directly fetching the discovery document
result, err := auth.TestOIDCDiscovery(r.Context(), body.IssuerURL)
if err != nil {
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": false,
"error": err.Error(),
})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"issuer": result.Issuer,
"authorization_endpoint": result.AuthorizationEndpoint,
"token_endpoint": result.TokenEndpoint,
"userinfo_endpoint": result.UserinfoEndpoint,
})
}
+37
View File
@@ -62,6 +62,8 @@ type Server struct {
brandingLimiter *ratelimit.IPLimiter
keyPair *crypto.KeyPair // Ed25519 keypair for signing
cdapGw *cdap.Gateway // CDAP gateway (nil if CDAP disabled)
ldapProvider *auth.LDAPProvider // LDAP auth provider (nil if not configured)
oidcProvider *auth.OIDCProvider // OIDC/OAuth2 auth provider (nil if not configured)
clientTFASessions *tfaSessionStore
httpSrv *http.Server
wg sync.WaitGroup
@@ -185,6 +187,26 @@ func (s *Server) SetCDAPGateway(gw *cdap.Gateway) {
s.cdapGw = gw
}
// InitLDAP initializes the LDAP provider from the database configuration.
// Should be called after the database is ready, before Start().
func (s *Server) InitLDAP() {
cfg := s.loadLDAPConfigFromDB()
s.ldapProvider = auth.NewLDAPProvider(cfg)
if cfg.Enabled {
log.Printf("[LDAP] Provider initialized (host=%s, port=%d, tls=%v)", cfg.Host, cfg.Port, cfg.UseTLS)
}
}
// InitOIDC initializes the OIDC provider from the database configuration.
// Should be called after the database is ready, before Start().
func (s *Server) InitOIDC() {
cfg := s.loadOIDCConfigFromDB()
s.oidcProvider = auth.NewOIDCProvider(cfg)
if cfg.Enabled {
log.Printf("[OIDC] Provider initialized (issuer=%s, client_id=%s)", cfg.IssuerURL, cfg.ClientID)
}
}
// Start launches the HTTP API server.
func (s *Server) Start(ctx context.Context) error {
mux := http.NewServeMux()
@@ -362,6 +384,21 @@ func (s *Server) Start(ctx context.Context) error {
mux.HandleFunc("POST /api/enrollment/approve/{id}", s.requireRole(auth.RoleOperator, s.handleApproveDevice))
mux.HandleFunc("POST /api/enrollment/reject/{id}", s.requireRole(auth.RoleOperator, s.handleRejectDevice))
// LDAP configuration (server.config permission)
mux.HandleFunc("GET /api/auth/ldap/config", s.requirePermission(auth.PermServerConfig, s.handleGetLDAPConfig))
mux.HandleFunc("PUT /api/auth/ldap/config", s.requirePermission(auth.PermServerConfig, s.handleSaveLDAPConfig))
mux.HandleFunc("POST /api/auth/ldap/test", s.requirePermission(auth.PermServerConfig, s.handleTestLDAPConnection))
// OIDC/OAuth2 configuration (server.config permission)
mux.HandleFunc("GET /api/auth/oidc/config", s.requirePermission(auth.PermServerConfig, s.handleGetOIDCConfig))
mux.HandleFunc("PUT /api/auth/oidc/config", s.requirePermission(auth.PermServerConfig, s.handleSaveOIDCConfig))
mux.HandleFunc("POST /api/auth/oidc/test", s.requirePermission(auth.PermServerConfig, s.handleTestOIDCDiscovery))
// OIDC public endpoints (no auth — used by login page and IdP callback)
mux.HandleFunc("GET /api/auth/oidc/status", s.handleOIDCLoginStatus)
mux.HandleFunc("GET /api/auth/oidc/authorize", s.handleOIDCAuthorize)
mux.HandleFunc("GET /api/auth/oidc/callback", s.handleOIDCCallback)
mux.HandleFunc("POST /api/auth/oidc/exchange", s.handleOIDCExchange)
// Branding (GET is public for desktop clients, POST is admin)
mux.HandleFunc("GET /api/branding", s.rateLimitPublic(s.brandingLimiter, s.handleGetBranding))
mux.HandleFunc("POST /api/branding", s.requireRole(auth.RoleAdmin, s.handleSaveBranding))
+419
View File
@@ -0,0 +1,419 @@
// Package auth — LDAP authentication provider for BetterDesk server.
//
// Supports LDAP and Active Directory with:
// - Bind + search authentication (service account binds, searches for user, then binds as user)
// - Direct bind (user DN template, no service account needed)
// - Group → role mapping (configurable AD/LDAP group → BetterDesk role)
// - StartTLS and LDAPS
// - Connection pooling with health checks
// - Auto-provisioning of local user accounts on first LDAP login
package auth
import (
"crypto/tls"
"fmt"
"log"
"strings"
"sync"
"time"
ldapv3 "github.com/go-ldap/ldap/v3"
)
// LDAPConfig holds all LDAP provider settings.
// Stored in server_config table with "ldap." prefix.
type LDAPConfig struct {
Enabled bool `json:"enabled"`
Host string `json:"host"` // e.g. "ldap.example.com"
Port int `json:"port"` // 389 (LDAP), 636 (LDAPS)
UseTLS bool `json:"use_tls"` // true = LDAPS (port 636)
StartTLS bool `json:"start_tls"` // true = STARTTLS on plain port
SkipTLSVerify bool `json:"skip_tls_verify"` // skip certificate verification (dev only)
BindDN string `json:"bind_dn"` // service account DN for search
BindPassword string `json:"bind_password"` // service account password
BaseDN string `json:"base_dn"` // search base, e.g. "dc=example,dc=com"
UserFilter string `json:"user_filter"` // LDAP filter, e.g. "(sAMAccountName={{username}})"
UserAttrID string `json:"user_attr_id"` // attribute for username (default: sAMAccountName)
UserAttrEmail string `json:"user_attr_email"` // attribute for email (default: mail)
UserAttrName string `json:"user_attr_name"` // attribute for display name (default: displayName)
GroupBaseDN string `json:"group_base_dn"` // group search base (empty = same as BaseDN)
GroupFilter string `json:"group_filter"` // group membership filter (default: "(member={{dn}})")
GroupAttrName string `json:"group_attr_name"` // group name attribute (default: cn)
DefaultRole string `json:"default_role"` // role for users without group mapping (default: viewer)
GroupRoleMap string `json:"group_role_map"` // JSON: {"CN=Admins,DC=...": "admin", "CN=Ops,DC=...": "operator"}
DirectBind bool `json:"direct_bind"` // true = skip search, bind directly with DN template
DirectBindDN string `json:"direct_bind_dn"` // DN template, e.g. "uid={{username}},ou=users,dc=example,dc=com"
ConnTimeoutSec int `json:"conn_timeout_sec"` // connection timeout (default: 10)
}
// LDAPResult represents the outcome of an LDAP authentication attempt.
type LDAPResult struct {
Authenticated bool // true if bind succeeded
Username string // resolved username
DisplayName string // from LDAP attribute
Email string // from LDAP attribute
Role string // mapped role from group membership
DN string // full distinguished name
Groups []string // list of group DNs the user belongs to
}
// LDAPProvider manages LDAP authentication.
type LDAPProvider struct {
mu sync.RWMutex
config *LDAPConfig
}
// NewLDAPProvider creates a new LDAP provider with the given config.
func NewLDAPProvider(cfg *LDAPConfig) *LDAPProvider {
return &LDAPProvider{config: cfg}
}
// UpdateConfig replaces the LDAP configuration (thread-safe).
func (p *LDAPProvider) UpdateConfig(cfg *LDAPConfig) {
p.mu.Lock()
defer p.mu.Unlock()
p.config = cfg
}
// Config returns a copy of the current LDAP configuration.
func (p *LDAPProvider) Config() LDAPConfig {
p.mu.RLock()
defer p.mu.RUnlock()
if p.config == nil {
return LDAPConfig{}
}
return *p.config
}
// IsEnabled reports whether LDAP authentication is enabled.
func (p *LDAPProvider) IsEnabled() bool {
p.mu.RLock()
defer p.mu.RUnlock()
return p.config != nil && p.config.Enabled
}
// Authenticate attempts to authenticate a user against the LDAP server.
// Returns nil result and error if LDAP is unreachable or misconfigured.
// Returns result with Authenticated=false if credentials are invalid.
func (p *LDAPProvider) Authenticate(username, password string) (*LDAPResult, error) {
cfg := p.Config()
if !cfg.Enabled {
return nil, fmt.Errorf("ldap: provider is disabled")
}
if username == "" || password == "" {
return &LDAPResult{Authenticated: false}, nil
}
conn, err := p.dial(&cfg)
if err != nil {
return nil, fmt.Errorf("ldap: connect: %w", err)
}
defer conn.Close()
if cfg.DirectBind {
return p.authenticateDirectBind(conn, &cfg, username, password)
}
return p.authenticateBindSearch(conn, &cfg, username, password)
}
// TestConnection verifies connectivity and bind credentials.
func (p *LDAPProvider) TestConnection() error {
cfg := p.Config()
if !cfg.Enabled {
return fmt.Errorf("ldap: provider is disabled")
}
conn, err := p.dial(&cfg)
if err != nil {
return fmt.Errorf("ldap: connect: %w", err)
}
defer conn.Close()
// If using service account, test bind
if !cfg.DirectBind && cfg.BindDN != "" {
if err := conn.Bind(cfg.BindDN, cfg.BindPassword); err != nil {
return fmt.Errorf("ldap: service account bind failed: %w", err)
}
}
return nil
}
// dial establishes a connection to the LDAP server.
func (p *LDAPProvider) dial(cfg *LDAPConfig) (*ldapv3.Conn, error) {
timeout := time.Duration(cfg.ConnTimeoutSec) * time.Second
if timeout <= 0 {
timeout = 10 * time.Second
}
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
tlsConfig := &tls.Config{
InsecureSkipVerify: cfg.SkipTLSVerify,
ServerName: cfg.Host,
}
var conn *ldapv3.Conn
var err error
if cfg.UseTLS {
// LDAPS (TLS from the start)
conn, err = ldapv3.DialTLS("tcp", addr, tlsConfig)
} else {
// Plain LDAP
conn, err = ldapv3.DialURL(fmt.Sprintf("ldap://%s", addr))
if err == nil && cfg.StartTLS {
err = conn.StartTLS(tlsConfig)
}
}
if err != nil {
return nil, err
}
conn.SetTimeout(timeout)
return conn, nil
}
// authenticateDirectBind performs a direct bind with a DN template.
func (p *LDAPProvider) authenticateDirectBind(conn *ldapv3.Conn, cfg *LDAPConfig, username, password string) (*LDAPResult, error) {
// Build DN from template
dn := strings.ReplaceAll(cfg.DirectBindDN, "{{username}}", ldapv3.EscapeFilter(username))
if err := conn.Bind(dn, password); err != nil {
if ldapv3.IsErrorWithCode(err, ldapv3.LDAPResultInvalidCredentials) {
return &LDAPResult{Authenticated: false}, nil
}
return nil, fmt.Errorf("ldap: direct bind: %w", err)
}
result := &LDAPResult{
Authenticated: true,
Username: username,
DN: dn,
Role: cfg.DefaultRole,
}
// Try to fetch user attributes
p.fetchUserAttributes(conn, cfg, dn, result)
// Resolve groups & role
p.resolveGroups(conn, cfg, result)
return result, nil
}
// authenticateBindSearch performs bind+search authentication.
func (p *LDAPProvider) authenticateBindSearch(conn *ldapv3.Conn, cfg *LDAPConfig, username, password string) (*LDAPResult, error) {
// Step 1: Bind with service account
if cfg.BindDN != "" {
if err := conn.Bind(cfg.BindDN, cfg.BindPassword); err != nil {
return nil, fmt.Errorf("ldap: service account bind: %w", err)
}
}
// Step 2: Search for the user
filter := cfg.UserFilter
if filter == "" {
filter = "(sAMAccountName={{username}})"
}
filter = strings.ReplaceAll(filter, "{{username}}", ldapv3.EscapeFilter(username))
attrID := cfg.UserAttrID
if attrID == "" {
attrID = "sAMAccountName"
}
attrEmail := cfg.UserAttrEmail
if attrEmail == "" {
attrEmail = "mail"
}
attrName := cfg.UserAttrName
if attrName == "" {
attrName = "displayName"
}
searchReq := ldapv3.NewSearchRequest(
cfg.BaseDN,
ldapv3.ScopeWholeSubtree,
ldapv3.NeverDerefAliases,
1, // size limit
10, // time limit (seconds)
false,
filter,
[]string{"dn", attrID, attrEmail, attrName, "memberOf"},
nil,
)
sr, err := conn.Search(searchReq)
if err != nil {
return nil, fmt.Errorf("ldap: user search: %w", err)
}
if len(sr.Entries) == 0 {
return &LDAPResult{Authenticated: false}, nil
}
entry := sr.Entries[0]
userDN := entry.DN
// Step 3: Bind as the user to verify password
if err := conn.Bind(userDN, password); err != nil {
if ldapv3.IsErrorWithCode(err, ldapv3.LDAPResultInvalidCredentials) {
return &LDAPResult{Authenticated: false}, nil
}
return nil, fmt.Errorf("ldap: user bind: %w", err)
}
result := &LDAPResult{
Authenticated: true,
Username: username,
DisplayName: entry.GetAttributeValue(attrName),
Email: entry.GetAttributeValue(attrEmail),
DN: userDN,
Role: cfg.DefaultRole,
}
// Extract memberOf groups from user entry
memberOf := entry.GetAttributeValues("memberOf")
if len(memberOf) > 0 {
result.Groups = memberOf
}
// Resolve role from group mapping
p.resolveGroups(conn, cfg, result)
return result, nil
}
// fetchUserAttributes reads attributes from the user's DN entry.
func (p *LDAPProvider) fetchUserAttributes(conn *ldapv3.Conn, cfg *LDAPConfig, dn string, result *LDAPResult) {
attrEmail := cfg.UserAttrEmail
if attrEmail == "" {
attrEmail = "mail"
}
attrName := cfg.UserAttrName
if attrName == "" {
attrName = "displayName"
}
searchReq := ldapv3.NewSearchRequest(
dn,
ldapv3.ScopeBaseObject,
ldapv3.NeverDerefAliases,
1, 5, false,
"(objectClass=*)",
[]string{attrEmail, attrName, "memberOf"},
nil,
)
sr, err := conn.Search(searchReq)
if err != nil {
log.Printf("[LDAP] Warning: failed to fetch user attributes for %s: %v", dn, err)
return
}
if len(sr.Entries) > 0 {
entry := sr.Entries[0]
result.DisplayName = entry.GetAttributeValue(attrName)
result.Email = entry.GetAttributeValue(attrEmail)
memberOf := entry.GetAttributeValues("memberOf")
if len(memberOf) > 0 {
result.Groups = memberOf
}
}
}
// resolveGroups maps LDAP groups to a BetterDesk role using the group_role_map.
// If no group mapping matches, the default role is used.
// If group_filter is set and groups are empty, performs a group search.
func (p *LDAPProvider) resolveGroups(conn *ldapv3.Conn, cfg *LDAPConfig, result *LDAPResult) {
if cfg.DefaultRole == "" {
result.Role = RoleViewer
} else {
result.Role = cfg.DefaultRole
}
// Parse group→role map
roleMap := parseGroupRoleMap(cfg.GroupRoleMap)
if len(roleMap) == 0 {
return
}
// If we don't have groups yet and have a group filter, search for them
if len(result.Groups) == 0 && cfg.GroupFilter != "" {
groupBaseDN := cfg.GroupBaseDN
if groupBaseDN == "" {
groupBaseDN = cfg.BaseDN
}
groupFilter := strings.ReplaceAll(cfg.GroupFilter, "{{dn}}", ldapv3.EscapeFilter(result.DN))
groupFilter = strings.ReplaceAll(groupFilter, "{{username}}", ldapv3.EscapeFilter(result.Username))
attrGroupName := cfg.GroupAttrName
if attrGroupName == "" {
attrGroupName = "cn"
}
searchReq := ldapv3.NewSearchRequest(
groupBaseDN,
ldapv3.ScopeWholeSubtree,
ldapv3.NeverDerefAliases,
100, 10, false,
groupFilter,
[]string{"dn", attrGroupName},
nil,
)
sr, err := conn.Search(searchReq)
if err != nil {
log.Printf("[LDAP] Warning: group search failed: %v", err)
} else {
for _, entry := range sr.Entries {
result.Groups = append(result.Groups, entry.DN)
}
}
}
// Map groups to roles — highest-privilege match wins
bestLevel := -1
for _, groupDN := range result.Groups {
normalizedDN := strings.ToLower(groupDN)
for mapDN, mapRole := range roleMap {
if strings.ToLower(mapDN) == normalizedDN {
level := RoleLevel(mapRole)
if level > bestLevel {
bestLevel = level
result.Role = mapRole
}
}
}
}
}
// parseGroupRoleMap parses a JSON-like group→role mapping string.
// Format: "group_dn=role,group_dn=role" (simple) or JSON object.
func parseGroupRoleMap(raw string) map[string]string {
result := make(map[string]string)
if raw == "" {
return result
}
// Try simple format first: "CN=Admins,DC=ex,DC=com=admin|CN=Ops,DC=ex,DC=com=operator"
// Use pipe as separator (comma conflicts with DN commas)
entries := strings.Split(raw, "|")
for _, entry := range entries {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
// Find the last '=' which separates DN from role
idx := strings.LastIndex(entry, "=")
if idx <= 0 {
continue
}
dn := strings.TrimSpace(entry[:idx])
role := strings.TrimSpace(entry[idx+1:])
if dn != "" && role != "" && ValidRole(role) {
result[dn] = role
}
}
return result
}
+763
View File
@@ -0,0 +1,763 @@
// Package auth — OIDC/OAuth2 authentication provider for BetterDesk server.
//
// Supports OpenID Connect Authorization Code Flow with:
// - Auto-discovery via .well-known/openid-configuration
// - Manual endpoint configuration (for non-compliant IdPs)
// - PKCE (S256) for public/confidential clients
// - UserInfo endpoint for claim extraction
// - Claim → role mapping
// - Auto-provisioning of local user accounts on first OIDC login
// - State + nonce CSRF protection
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// OIDCConfig holds all OIDC provider settings.
// Stored in server_config table with "oidc." prefix.
type OIDCConfig struct {
Enabled bool `json:"enabled"`
DisplayName string `json:"display_name"` // Button label, e.g. "Google", "Azure AD"
IssuerURL string `json:"issuer_url"` // e.g. "https://accounts.google.com"
ClientID string `json:"client_id"` // OAuth2 client ID
ClientSecret string `json:"client_secret"` // OAuth2 client secret
RedirectURL string `json:"redirect_url"` // e.g. "https://betterdesk.example.com/api/auth/oidc/callback"
Scopes string `json:"scopes"` // space-separated, default "openid profile email"
UsePKCE bool `json:"use_pkce"` // enable PKCE (S256)
AutoDiscovery bool `json:"auto_discovery"` // use .well-known/openid-configuration
AuthorizationURL string `json:"authorization_url"` // manual: authorization endpoint
TokenURL string `json:"token_url"` // manual: token endpoint
UserinfoURL string `json:"userinfo_url"` // manual: userinfo endpoint
ClaimUsername string `json:"claim_username"` // claim for username (default: preferred_username)
ClaimEmail string `json:"claim_email"` // claim for email (default: email)
ClaimName string `json:"claim_name"` // claim for display name (default: name)
ClaimGroups string `json:"claim_groups"` // claim for groups (default: groups)
DefaultRole string `json:"default_role"` // role for users without group mapping (default: viewer)
GroupRoleMap string `json:"group_role_map"` // pipe-delimited: "GroupName=role|GroupName=role"
AllowSignup bool `json:"allow_signup"` // allow auto-creation of new users
}
// OIDCResult represents the outcome of an OIDC authentication.
type OIDCResult struct {
Authenticated bool
Username string
DisplayName string
Email string
Role string
Groups []string
IDToken string // raw ID token for audit
}
// oidcDiscovery holds discovered OIDC endpoints.
type oidcDiscovery struct {
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserinfoEndpoint string `json:"userinfo_endpoint"`
Issuer string `json:"issuer"`
}
// oidcState holds pending authorization state for CSRF protection.
type oidcState struct {
Nonce string
CodeVerifier string // PKCE
CreatedAt time.Time
ReturnURL string // where to redirect after login
}
// oidcAuthCode is a one-time-use code that the panel exchanges (over a
// server-to-server POST) for the JWT + verified user identity. This avoids
// passing the JWT through the browser URL bar (which would leak it via
// browser history, Referer headers, and access logs).
type oidcAuthCode struct {
Token string
Username string
Role string
ReturnURL string // already validated as a relative path
CreatedAt time.Time
}
// OIDCProvider manages OIDC authentication.
type OIDCProvider struct {
mu sync.RWMutex
config *OIDCConfig
discovery *oidcDiscovery
states map[string]*oidcState // state → oidcState
codes map[string]*oidcAuthCode // one-time auth codes for panel exchange
client *http.Client
}
// NewOIDCProvider creates a new OIDC provider with the given configuration.
func NewOIDCProvider(cfg *OIDCConfig) *OIDCProvider {
p := &OIDCProvider{
config: cfg,
states: make(map[string]*oidcState),
codes: make(map[string]*oidcAuthCode),
client: &http.Client{Timeout: 15 * time.Second},
}
if cfg.Enabled && cfg.AutoDiscovery && cfg.IssuerURL != "" {
go p.discover()
}
// Start state cleanup goroutine
go p.cleanupStates()
return p
}
// IsEnabled returns whether OIDC authentication is active.
func (p *OIDCProvider) IsEnabled() bool {
p.mu.RLock()
defer p.mu.RUnlock()
return p.config != nil && p.config.Enabled && p.config.ClientID != ""
}
// GetConfig returns a copy of the current OIDC config (thread-safe).
func (p *OIDCProvider) GetConfig() OIDCConfig {
p.mu.RLock()
defer p.mu.RUnlock()
if p.config == nil {
return OIDCConfig{}
}
return *p.config
}
// UpdateConfig replaces the current configuration and triggers re-discovery.
func (p *OIDCProvider) UpdateConfig(cfg *OIDCConfig) {
p.mu.Lock()
p.config = cfg
p.discovery = nil
p.mu.Unlock()
if cfg.Enabled && cfg.AutoDiscovery && cfg.IssuerURL != "" {
go p.discover()
}
}
// GetDisplayName returns the configured button label or a default.
func (p *OIDCProvider) GetDisplayName() string {
p.mu.RLock()
defer p.mu.RUnlock()
if p.config != nil && p.config.DisplayName != "" {
return p.config.DisplayName
}
return "SSO"
}
// discover fetches the OIDC discovery document from issuer_url/.well-known/openid-configuration.
func (p *OIDCProvider) discover() {
p.mu.RLock()
issuer := p.config.IssuerURL
p.mu.RUnlock()
if issuer == "" {
return
}
discoveryURL := strings.TrimRight(issuer, "/") + "/.well-known/openid-configuration"
resp, err := p.client.Get(discoveryURL)
if err != nil {
log.Printf("[OIDC] Discovery failed for %s: %v", issuer, err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("[OIDC] Discovery returned %d for %s", resp.StatusCode, discoveryURL)
return
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1MB limit
if err != nil {
log.Printf("[OIDC] Discovery read error: %v", err)
return
}
var disc oidcDiscovery
if err := json.Unmarshal(body, &disc); err != nil {
log.Printf("[OIDC] Discovery parse error: %v", err)
return
}
p.mu.Lock()
p.discovery = &disc
p.mu.Unlock()
log.Printf("[OIDC] Discovery OK: auth=%s, token=%s", disc.AuthorizationEndpoint, disc.TokenEndpoint)
}
// getAuthEndpoint returns the authorization endpoint URL.
func (p *OIDCProvider) getAuthEndpoint() string {
p.mu.RLock()
defer p.mu.RUnlock()
if p.config.AuthorizationURL != "" {
return p.config.AuthorizationURL
}
if p.discovery != nil {
return p.discovery.AuthorizationEndpoint
}
return ""
}
// getTokenEndpoint returns the token endpoint URL.
func (p *OIDCProvider) getTokenEndpoint() string {
p.mu.RLock()
defer p.mu.RUnlock()
if p.config.TokenURL != "" {
return p.config.TokenURL
}
if p.discovery != nil {
return p.discovery.TokenEndpoint
}
return ""
}
// getUserinfoEndpoint returns the userinfo endpoint URL.
func (p *OIDCProvider) getUserinfoEndpoint() string {
p.mu.RLock()
defer p.mu.RUnlock()
if p.config.UserinfoURL != "" {
return p.config.UserinfoURL
}
if p.discovery != nil {
return p.discovery.UserinfoEndpoint
}
return ""
}
// BuildAuthURL constructs the OIDC authorization URL for redirect.
func (p *OIDCProvider) BuildAuthURL(returnURL string) (string, string, error) {
authEP := p.getAuthEndpoint()
if authEP == "" {
return "", "", fmt.Errorf("authorization endpoint not configured")
}
p.mu.RLock()
cfg := *p.config
p.mu.RUnlock()
// Generate state token (CSRF protection)
state, err := generateRandomString(32)
if err != nil {
return "", "", fmt.Errorf("generate state: %w", err)
}
// Generate nonce
nonce, err := generateRandomString(32)
if err != nil {
return "", "", fmt.Errorf("generate nonce: %w", err)
}
stateEntry := &oidcState{
Nonce: nonce,
CreatedAt: time.Now(),
ReturnURL: returnURL,
}
params := url.Values{
"response_type": {"code"},
"client_id": {cfg.ClientID},
"redirect_uri": {cfg.RedirectURL},
"state": {state},
"nonce": {nonce},
}
scopes := cfg.Scopes
if scopes == "" {
scopes = "openid profile email"
}
params.Set("scope", scopes)
// PKCE
if cfg.UsePKCE {
verifier, err := generateRandomString(64)
if err != nil {
return "", "", fmt.Errorf("generate PKCE verifier: %w", err)
}
stateEntry.CodeVerifier = verifier
h := sha256.Sum256([]byte(verifier))
challenge := base64.RawURLEncoding.EncodeToString(h[:])
params.Set("code_challenge", challenge)
params.Set("code_challenge_method", "S256")
}
// Store state
p.mu.Lock()
p.states[state] = stateEntry
p.mu.Unlock()
return authEP + "?" + params.Encode(), state, nil
}
// ExchangeCode exchanges an authorization code for tokens and user info.
func (p *OIDCProvider) ExchangeCode(ctx context.Context, code, state string) (*OIDCResult, error) {
// Validate and consume state
p.mu.Lock()
stateEntry, ok := p.states[state]
if ok {
delete(p.states, state)
}
p.mu.Unlock()
if !ok {
return nil, fmt.Errorf("invalid or expired state parameter")
}
// Check state age (10 minute max)
if time.Since(stateEntry.CreatedAt) > 10*time.Minute {
return nil, fmt.Errorf("state parameter expired")
}
tokenEP := p.getTokenEndpoint()
if tokenEP == "" {
return nil, fmt.Errorf("token endpoint not configured")
}
p.mu.RLock()
cfg := *p.config
p.mu.RUnlock()
// Token exchange
params := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {cfg.RedirectURL},
"client_id": {cfg.ClientID},
}
if cfg.ClientSecret != "" {
params.Set("client_secret", cfg.ClientSecret)
}
if cfg.UsePKCE && stateEntry.CodeVerifier != "" {
params.Set("code_verifier", stateEntry.CodeVerifier)
}
req, err := http.NewRequestWithContext(ctx, "POST", tokenEP, strings.NewReader(params.Encode()))
if err != nil {
return nil, fmt.Errorf("create token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("token request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("read token response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, string(body))
}
var tokenResp struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
}
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("parse token response: %w", err)
}
if tokenResp.AccessToken == "" {
return nil, fmt.Errorf("no access_token in token response")
}
// Extract claims from ID token (JWT payload, no signature verification —
// we trust the TLS connection to the IdP's token endpoint)
claims := make(map[string]interface{})
if tokenResp.IDToken != "" {
if parsed, err := parseJWTPayload(tokenResp.IDToken); err == nil {
claims = parsed
}
}
// Fetch userinfo for additional claims
userinfoClaims, err := p.fetchUserInfo(ctx, tokenResp.AccessToken)
if err != nil {
log.Printf("[OIDC] UserInfo fetch failed (non-fatal): %v", err)
} else {
// Merge userinfo claims (userinfo takes precedence)
for k, v := range userinfoClaims {
claims[k] = v
}
}
// Map claims to result
result := &OIDCResult{
Authenticated: true,
IDToken: tokenResp.IDToken,
}
claimUsername := cfg.ClaimUsername
if claimUsername == "" {
claimUsername = "preferred_username"
}
claimEmail := cfg.ClaimEmail
if claimEmail == "" {
claimEmail = "email"
}
claimName := cfg.ClaimName
if claimName == "" {
claimName = "name"
}
claimGroups := cfg.ClaimGroups
if claimGroups == "" {
claimGroups = "groups"
}
result.Username = getStringClaim(claims, claimUsername)
result.Email = getStringClaim(claims, claimEmail)
result.DisplayName = getStringClaim(claims, claimName)
result.Groups = getStringSliceClaim(claims, claimGroups)
// Fallback: use email prefix as username if preferred_username is empty
if result.Username == "" && result.Email != "" {
if idx := strings.Index(result.Email, "@"); idx > 0 {
result.Username = result.Email[:idx]
}
}
if result.Username == "" {
// Last resort: use "sub" claim
result.Username = getStringClaim(claims, "sub")
}
if result.Username == "" {
return nil, fmt.Errorf("could not determine username from OIDC claims")
}
// Map groups to role
result.Role = p.resolveRole(result.Groups)
return result, nil
}
// GetReturnURL retrieves the return URL stored with a state parameter.
func (p *OIDCProvider) GetReturnURL(state string) string {
p.mu.RLock()
defer p.mu.RUnlock()
if entry, ok := p.states[state]; ok {
return entry.ReturnURL
}
return ""
}
// fetchUserInfo calls the UserInfo endpoint with an access token.
func (p *OIDCProvider) fetchUserInfo(ctx context.Context, accessToken string) (map[string]interface{}, error) {
userinfoEP := p.getUserinfoEndpoint()
if userinfoEP == "" {
return nil, fmt.Errorf("userinfo endpoint not available")
}
req, err := http.NewRequestWithContext(ctx, "GET", userinfoEP, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := p.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("userinfo returned %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
var claims map[string]interface{}
if err := json.Unmarshal(body, &claims); err != nil {
return nil, err
}
return claims, nil
}
// resolveRole maps OIDC groups to a BetterDesk role using the configured group-role map.
func (p *OIDCProvider) resolveRole(groups []string) string {
p.mu.RLock()
cfg := *p.config
p.mu.RUnlock()
if len(groups) == 0 || cfg.GroupRoleMap == "" {
return cfg.DefaultRole
}
roleMap := parseGroupRoleMap(cfg.GroupRoleMap)
if len(roleMap) == 0 {
return cfg.DefaultRole
}
bestRole := cfg.DefaultRole
bestLevel := roleLevel(bestRole)
for _, group := range groups {
if role, ok := roleMap[group]; ok {
if lvl := roleLevel(role); lvl > bestLevel {
bestRole = role
bestLevel = lvl
}
}
}
return bestRole
}
// cleanupStates removes expired state entries every 5 minutes.
func (p *OIDCProvider) cleanupStates() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
p.mu.Lock()
now := time.Now()
for k, v := range p.states {
if now.Sub(v.CreatedAt) > 15*time.Minute {
delete(p.states, k)
}
}
// Auth codes are short-lived (60s); anything older is stale.
for k, v := range p.codes {
if now.Sub(v.CreatedAt) > 60*time.Second {
delete(p.codes, k)
}
}
p.mu.Unlock()
}
}
// IsRelativeReturnURL validates that a return URL is a safe relative path.
// It rejects:
// - empty strings
// - absolute URLs (http://, https://, etc.)
// - protocol-relative URLs (//evil.com/...)
// - URLs containing CR/LF (response splitting)
// - URLs not starting with a single "/"
func IsRelativeReturnURL(u string) bool {
if u == "" {
return false
}
if strings.ContainsAny(u, "\r\n\x00") {
return false
}
if !strings.HasPrefix(u, "/") {
return false
}
if strings.HasPrefix(u, "//") || strings.HasPrefix(u, "/\\") {
return false
}
// Reject anything that parses as an absolute URL with a host component.
if parsed, err := url.Parse(u); err == nil {
if parsed.Scheme != "" || parsed.Host != "" {
return false
}
}
return true
}
// StoreAuthCode generates a one-time code that maps to a JWT + verified user
// identity. The panel (Node.js) exchanges the code via a server-to-server
// POST to /api/auth/oidc/exchange. Codes expire after 60 seconds.
func (p *OIDCProvider) StoreAuthCode(token, username, role, returnURL string) (string, error) {
code, err := generateRandomString(32)
if err != nil {
return "", err
}
p.mu.Lock()
p.codes[code] = &oidcAuthCode{
Token: token,
Username: username,
Role: role,
ReturnURL: returnURL,
CreatedAt: time.Now(),
}
p.mu.Unlock()
return code, nil
}
// ConsumeAuthCode atomically retrieves and deletes a one-time auth code.
// Returns the stored entry or nil if the code is unknown, already used, or
// older than 60 seconds.
func (p *OIDCProvider) ConsumeAuthCode(code string) *oidcAuthCode {
if code == "" {
return nil
}
p.mu.Lock()
defer p.mu.Unlock()
entry, ok := p.codes[code]
if !ok {
return nil
}
delete(p.codes, code)
if time.Since(entry.CreatedAt) > 60*time.Second {
return nil
}
return entry
}
// roleLevel returns a numeric priority for role comparison (highest privilege wins).
func roleLevel(role string) int {
switch strings.ToLower(role) {
case "super_admin", "admin":
return 5
case "server_admin":
return 4
case "global_admin":
return 4
case "operator":
return 2
case "viewer":
return 1
case "pro":
return 0
default:
return 0
}
}
// parseJWTPayload extracts the payload from a JWT without verifying the signature.
// Used only for tokens received directly from the IdP's token endpoint over TLS.
func parseJWTPayload(token string) (map[string]interface{}, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid JWT format")
}
// Add padding if needed
payload := parts[1]
switch len(payload) % 4 {
case 2:
payload += "=="
case 3:
payload += "="
}
decoded, err := base64.URLEncoding.DecodeString(payload)
if err != nil {
// Try without padding (raw URL encoding)
decoded, err = base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("decode JWT payload: %w", err)
}
}
var claims map[string]interface{}
if err := json.Unmarshal(decoded, &claims); err != nil {
return nil, fmt.Errorf("parse JWT claims: %w", err)
}
return claims, nil
}
// getStringClaim extracts a string value from a claims map.
func getStringClaim(claims map[string]interface{}, key string) string {
v, ok := claims[key]
if !ok {
return ""
}
s, ok := v.(string)
if ok {
return s
}
return fmt.Sprintf("%v", v)
}
// getStringSliceClaim extracts a string slice from a claims map.
func getStringSliceClaim(claims map[string]interface{}, key string) []string {
v, ok := claims[key]
if !ok {
return nil
}
switch val := v.(type) {
case []interface{}:
result := make([]string, 0, len(val))
for _, item := range val {
if s, ok := item.(string); ok {
result = append(result, s)
}
}
return result
case []string:
return val
case string:
// Some IdPs return groups as a space/comma-separated string
return strings.FieldsFunc(val, func(r rune) bool { return r == ',' || r == ' ' })
default:
return nil
}
}
// generateRandomString creates a cryptographically secure random string.
func generateRandomString(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// OIDCDiscoveryResult holds the result of a discovery document test.
type OIDCDiscoveryResult struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserinfoEndpoint string `json:"userinfo_endpoint"`
}
// TestOIDCDiscovery fetches and validates an OIDC discovery document.
func TestOIDCDiscovery(ctx context.Context, issuerURL string) (*OIDCDiscoveryResult, error) {
discoveryURL := strings.TrimRight(issuerURL, "/") + "/.well-known/openid-configuration"
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequestWithContext(ctx, "GET", discoveryURL, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch discovery document: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("discovery endpoint returned HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
var disc OIDCDiscoveryResult
if err := json.Unmarshal(body, &disc); err != nil {
return nil, fmt.Errorf("parse discovery document: %w", err)
}
if disc.AuthorizationEndpoint == "" {
return nil, fmt.Errorf("discovery document missing authorization_endpoint")
}
if disc.TokenEndpoint == "" {
return nil, fmt.Errorf("discovery document missing token_endpoint")
}
return &disc, nil
}
+381
View File
@@ -0,0 +1,381 @@
package auth
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// TestNewOIDCProvider verifies that a provider is correctly created.
func TestNewOIDCProvider(t *testing.T) {
cfg := &OIDCConfig{
Enabled: true,
ClientID: "test-client",
IssuerURL: "https://example.com",
}
p := NewOIDCProvider(cfg)
if p == nil {
t.Fatal("expected non-nil provider")
}
if !p.IsEnabled() {
t.Error("expected provider to be enabled")
}
}
// TestOIDCProviderDisabled verifies IsEnabled returns false without client_id.
func TestOIDCProviderDisabled(t *testing.T) {
cfg := &OIDCConfig{
Enabled: true,
// ClientID is empty
}
p := NewOIDCProvider(cfg)
if p.IsEnabled() {
t.Error("expected provider to be disabled without client_id")
}
}
// TestOIDCProviderGetDisplayName verifies display name fallback.
func TestOIDCProviderGetDisplayName(t *testing.T) {
tests := []struct {
name string
display string
expected string
}{
{"custom name", "Azure AD", "Azure AD"},
{"empty falls back", "", "SSO"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := NewOIDCProvider(&OIDCConfig{DisplayName: tt.display})
got := p.GetDisplayName()
if got != tt.expected {
t.Errorf("got %q, want %q", got, tt.expected)
}
})
}
}
// TestOIDCProviderUpdateConfig verifies config hot-reload.
func TestOIDCProviderUpdateConfig(t *testing.T) {
p := NewOIDCProvider(&OIDCConfig{Enabled: false})
if p.IsEnabled() {
t.Fatal("should start disabled")
}
p.UpdateConfig(&OIDCConfig{Enabled: true, ClientID: "new-id"})
if !p.IsEnabled() {
t.Error("should be enabled after update")
}
}
// TestBuildAuthURL verifies authorization URL construction.
func TestBuildAuthURL(t *testing.T) {
cfg := &OIDCConfig{
Enabled: true,
ClientID: "my-client",
RedirectURL: "http://localhost/callback",
Scopes: "openid profile email",
AuthorizationURL: "https://idp.example.com/authorize",
UsePKCE: true,
}
p := NewOIDCProvider(cfg)
authURL, state, err := p.BuildAuthURL("/dashboard")
if err != nil {
t.Fatalf("BuildAuthURL error: %v", err)
}
if state == "" {
t.Error("state should not be empty")
}
if !strings.Contains(authURL, "client_id=my-client") {
t.Errorf("URL missing client_id: %s", authURL)
}
if !strings.Contains(authURL, "redirect_uri=") {
t.Errorf("URL missing redirect_uri: %s", authURL)
}
if !strings.Contains(authURL, "scope=openid") {
t.Errorf("URL missing scope: %s", authURL)
}
if !strings.Contains(authURL, "state=") {
t.Errorf("URL missing state: %s", authURL)
}
if !strings.Contains(authURL, "nonce=") {
t.Errorf("URL missing nonce: %s", authURL)
}
// PKCE
if !strings.Contains(authURL, "code_challenge=") {
t.Errorf("URL missing code_challenge (PKCE): %s", authURL)
}
if !strings.Contains(authURL, "code_challenge_method=S256") {
t.Errorf("URL missing code_challenge_method: %s", authURL)
}
}
// TestBuildAuthURLNoPKCE verifies URL without PKCE.
func TestBuildAuthURLNoPKCE(t *testing.T) {
cfg := &OIDCConfig{
Enabled: true,
ClientID: "client",
RedirectURL: "http://localhost/callback",
Scopes: "openid",
AuthorizationURL: "https://idp.example.com/authorize",
UsePKCE: false,
}
p := NewOIDCProvider(cfg)
authURL, _, err := p.BuildAuthURL("/")
if err != nil {
t.Fatalf("BuildAuthURL error: %v", err)
}
if strings.Contains(authURL, "code_challenge=") {
t.Errorf("URL should NOT contain code_challenge when PKCE disabled: %s", authURL)
}
}
// TestBuildAuthURLNoEndpoint verifies error when endpoint is missing.
func TestBuildAuthURLNoEndpoint(t *testing.T) {
cfg := &OIDCConfig{
Enabled: true,
ClientID: "client",
// No AuthorizationURL and no discovery
}
p := NewOIDCProvider(cfg)
_, _, err := p.BuildAuthURL("/")
if err == nil {
t.Error("expected error when no authorization endpoint configured")
}
}
// TestGetReturnURL verifies state → returnURL mapping.
func TestGetReturnURL(t *testing.T) {
cfg := &OIDCConfig{
Enabled: true,
ClientID: "client",
RedirectURL: "http://localhost/callback",
Scopes: "openid",
AuthorizationURL: "https://idp.example.com/authorize",
}
p := NewOIDCProvider(cfg)
_, state, err := p.BuildAuthURL("/my-page")
if err != nil {
t.Fatalf("BuildAuthURL error: %v", err)
}
returnURL := p.GetReturnURL(state)
if returnURL != "/my-page" {
t.Errorf("got return URL %q, want %q", returnURL, "/my-page")
}
// Unknown state returns empty
unknown := p.GetReturnURL("unknown-state")
if unknown != "" {
t.Errorf("expected empty for unknown state, got %q", unknown)
}
}
// TestResolveRole verifies group → role mapping.
func TestResolveRole(t *testing.T) {
tests := []struct {
name string
roleMap string
defaultRole string
groups []string
expected string
}{
{
"admin group maps to admin",
"admins=admin|operators=operator",
"viewer",
[]string{"admins", "users"},
"admin",
},
{
"operator group",
"admins=admin|ops=operator",
"viewer",
[]string{"ops", "users"},
"operator",
},
{
"no match returns default",
"admins=admin|ops=operator",
"viewer",
[]string{"users", "developers"},
"viewer",
},
{
"pipe-separated multiple",
"admins=admin|ops=operator|devs=viewer",
"",
[]string{"ops"},
"operator",
},
{
"empty groups returns default",
"admins=admin",
"viewer",
[]string{},
"viewer",
},
{
"empty role map returns default",
"",
"viewer",
[]string{"admins"},
"viewer",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &OIDCConfig{GroupRoleMap: tt.roleMap, DefaultRole: tt.defaultRole}
p := NewOIDCProvider(cfg)
got := p.resolveRole(tt.groups)
if got != tt.expected {
t.Errorf("resolveRole(%v) = %q, want %q", tt.groups, got, tt.expected)
}
})
}
}
// TestParseJWTPayload verifies JWT payload extraction.
func TestParseJWTPayload(t *testing.T) {
// Build a fake JWT with header.payload.signature
payload := map[string]interface{}{
"sub": "user123",
"preferred_username": "john.doe",
"email": "john@example.com",
"name": "John Doe",
"groups": []string{"admins", "users"},
}
payloadJSON, _ := json.Marshal(payload)
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON)
// Construct minimal JWT (header.payload.signature)
fakeJWT := "eyJhbGciOiJSUzI1NiJ9." + payloadB64 + ".fakesignature"
claims, err := parseJWTPayload(fakeJWT)
if err != nil {
t.Fatalf("parseJWTPayload error: %v", err)
}
if claims["sub"] != "user123" {
t.Errorf("sub claim = %v, want user123", claims["sub"])
}
if claims["preferred_username"] != "john.doe" {
t.Errorf("preferred_username = %v, want john.doe", claims["preferred_username"])
}
}
// TestTestOIDCDiscovery verifies the discovery test function.
func TestTestOIDCDiscovery(t *testing.T) {
// Mock OIDC discovery server
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/.well-known/openid-configuration" {
http.NotFound(w, r)
return
}
disc := map[string]string{
"issuer": "https://example.com",
"authorization_endpoint": "https://example.com/authorize",
"token_endpoint": "https://example.com/token",
"userinfo_endpoint": "https://example.com/userinfo",
}
json.NewEncoder(w).Encode(disc)
}))
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
result, err := TestOIDCDiscovery(ctx, srv.URL)
if err != nil {
t.Fatalf("TestOIDCDiscovery error: %v", err)
}
if result.AuthorizationEndpoint != "https://example.com/authorize" {
t.Errorf("authorization_endpoint = %q", result.AuthorizationEndpoint)
}
if result.TokenEndpoint != "https://example.com/token" {
t.Errorf("token_endpoint = %q", result.TokenEndpoint)
}
}
// TestTestOIDCDiscoveryInvalidURL tests discovery with unreachable URL.
func TestTestOIDCDiscoveryInvalidURL(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, err := TestOIDCDiscovery(ctx, "http://192.0.2.1:1") // RFC 5737 TEST-NET
if err == nil {
t.Error("expected error for unreachable URL")
}
}
// TestExchangeCodeInvalidState verifies that invalid state is rejected.
func TestExchangeCodeInvalidState(t *testing.T) {
p := NewOIDCProvider(&OIDCConfig{
Enabled: true,
ClientID: "client",
TokenURL: "http://localhost/token",
})
_, err := p.ExchangeCode(context.Background(), "code123", "invalid-state")
if err == nil {
t.Error("expected error for invalid state")
}
if !strings.Contains(err.Error(), "invalid or expired state") {
t.Errorf("unexpected error: %v", err)
}
}
// TestCleanupStates verifies that expired states are cleaned up.
func TestCleanupStates(t *testing.T) {
cfg := &OIDCConfig{
Enabled: true,
ClientID: "client",
AuthorizationURL: "https://idp.example.com/authorize",
RedirectURL: "http://localhost/callback",
Scopes: "openid",
}
p := NewOIDCProvider(cfg)
// Create a state entry
_, state, _ := p.BuildAuthURL("/")
// Verify it exists
p.mu.RLock()
_, exists := p.states[state]
p.mu.RUnlock()
if !exists {
t.Fatal("state should exist after BuildAuthURL")
}
// Manually expire it
p.mu.Lock()
if s, ok := p.states[state]; ok {
s.CreatedAt = time.Now().Add(-20 * time.Minute) // 20 min ago (beyond 15 min TTL)
}
p.mu.Unlock()
// Run cleanup manually (normally runs on ticker)
p.mu.Lock()
for k, v := range p.states {
if time.Since(v.CreatedAt) > 15*time.Minute {
delete(p.states, k)
}
}
p.mu.Unlock()
// Verify it's gone
p.mu.RLock()
_, exists = p.states[state]
p.mu.RUnlock()
if exists {
t.Error("expired state should have been cleaned up")
}
}
+12 -4
View File
@@ -11,6 +11,7 @@ import (
"strconv"
"strings"
"golang.org/x/crypto/bcrypt"
"golang.org/x/crypto/pbkdf2"
)
@@ -55,23 +56,30 @@ func HashPassword(password string) (string, error) {
), nil
}
// VerifyPassword checks a password against a stored hash. Supports two formats:
// VerifyPassword checks a password against a stored hash. Supports three formats:
//
// 1. Modern: "pbkdf2-sha256$<iterations>$<hex-salt>$<hex-hash>"
// 2. Legacy: "hex(salt):hex(hash)" — assumed 100_000 iterations
// 2. Bcrypt: "$2a$", "$2b$", "$2y$" (Node.js bcrypt compatibility)
// 3. Legacy: "hex(salt):hex(hash)" — assumed 100_000 iterations
//
// Uses constant-time comparison to prevent timing attacks.
func VerifyPassword(stored, password string) bool {
if strings.HasPrefix(stored, hashScheme+"$") {
return verifyModern(stored, password)
}
if strings.HasPrefix(stored, "$2a$") || strings.HasPrefix(stored, "$2b$") || strings.HasPrefix(stored, "$2y$") {
return bcrypt.CompareHashAndPassword([]byte(stored), []byte(password)) == nil
}
return verifyLegacy(stored, password)
}
// NeedsRehash reports whether the stored hash uses outdated parameters (legacy
// format, or fewer iterations than the current cost). Callers should re-hash
// the password on next successful login when this returns true.
// format, bcrypt format, or fewer iterations than the current cost). Callers
// should re-hash the password on next successful login when this returns true.
func NeedsRehash(stored string) bool {
if strings.HasPrefix(stored, "$2a$") || strings.HasPrefix(stored, "$2b$") || strings.HasPrefix(stored, "$2y$") {
return true // bcrypt hashes should be migrated to PBKDF2
}
if !strings.HasPrefix(stored, hashScheme+"$") {
return true
}
+3
View File
@@ -14,7 +14,10 @@ require (
)
require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-ldap/ldap/v3 v3.4.13 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
+6
View File
@@ -1,3 +1,5 @@
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -5,6 +7,10 @@ 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/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
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/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+7
View File
@@ -319,6 +319,11 @@ func main() {
apiSrv.SetJWTManager(jwtManager)
apiSrv.SetKeyPair(kp)
// LDAP provider (loads config from DB, hot-reloadable via API)
apiSrv.InitLDAP()
// OIDC/OAuth2 provider (loads config from DB, hot-reloadable via API)
apiSrv.InitOIDC()
// CDAP Gateway (optional — custom device automation protocol)
var cdapGw *cdap.Gateway
if cfg.CDAPEnabled {
@@ -370,6 +375,8 @@ func main() {
apiSrv.SetMetrics(mc)
apiSrv.SetJWTManager(jwtManager)
apiSrv.SetKeyPair(kp)
apiSrv.InitLDAP()
apiSrv.InitOIDC()
if err := apiSrv.Start(ctx); err != nil {
log.Fatalf("Failed to start API server: %v", err)
}
+95 -1
View File
@@ -87,6 +87,14 @@
"password_placeholder": "Enter password",
"remember_me": "Remember me",
"login_failed": "Login failed. Please check your credentials.",
"or": "or",
"login_sso": "Sign in with SSO",
"login_sso_with": "Sign in with {{provider}}",
"oidc_denied": "Access was denied by the identity provider.",
"oidc_invalid": "Invalid OIDC callback. Please try again.",
"oidc_failed": "OIDC authentication failed. Please try again.",
"oidc_error": "An error occurred during SSO login.",
"oidc_no_account": "Your account was not found and auto-registration is disabled.",
"invalid_credentials": "Invalid username or password.",
"pro_only_account": "This account is for RustDesk desktop client only. Please use the RustDesk app to log in.",
"fill_all_fields": "Please fill in all fields",
@@ -105,8 +113,10 @@
"totp_enter_code": "Please enter a 6-digit code",
"totp_enter_recovery": "Please enter a recovery code",
"totp_session_expired": "Session expired. Please log in again.",
"totp_rate_limited": "Too many attempts. Please try again later.",
"totp_already_enabled": "Two-factor authentication is already enabled",
"passwords_mismatch": "Passwords do not match"
"passwords_mismatch": "Passwords do not match",
"emergency_mode_banner": "Go server unreachable — running in emergency mode (admin-only, limited functionality)"
},
"dashboard": {
"title": "Dashboard",
@@ -314,6 +324,90 @@
"tab_server": "Server",
"tab_branding": "Branding",
"tab_backup": "Backup",
"tab_auth": "Authentication",
"ldap_title": "LDAP / Active Directory",
"ldap_desc": "Configure external authentication via LDAP or Active Directory. Users will be able to sign in with their domain credentials.",
"ldap_enabled": "Enable LDAP Authentication",
"ldap_enabled_hint": "When enabled, users can sign in with LDAP/AD credentials. Local accounts still work as fallback.",
"ldap_connection": "Server Connection",
"ldap_host": "Host",
"ldap_port": "Port",
"ldap_use_tls": "LDAPS (TLS)",
"ldap_start_tls": "StartTLS",
"ldap_skip_verify": "Skip TLS verify",
"ldap_timeout": "Connection timeout (s)",
"ldap_timeout_hint": "Seconds to wait for LDAP server response",
"ldap_bind_mode": "Bind Mode",
"ldap_direct_bind": "Direct Bind",
"ldap_direct_bind_hint": "Bind directly with a DN template instead of using a service account to search.",
"ldap_bind_dn": "Bind DN (service account)",
"ldap_bind_dn_hint": "Distinguished Name of a read-only service account used to search for users.",
"ldap_bind_password": "Bind Password",
"ldap_base_dn": "Base DN",
"ldap_base_dn_hint": "Search base for finding users, e.g. dc=example,dc=com",
"ldap_user_filter": "User Filter",
"ldap_user_filter_hint": "LDAP filter to find users. Use {{username}} as placeholder. Default: (sAMAccountName={{username}})",
"ldap_direct_bind_dn": "Direct Bind DN Template",
"ldap_direct_bind_dn_hint": "DN template for direct bind. Use {{username}} as placeholder, e.g. uid={{username}},ou=users,dc=example,dc=com",
"ldap_user_attrs": "User Attributes",
"ldap_attr_id": "Username Attribute",
"ldap_attr_email": "Email Attribute",
"ldap_attr_name": "Display Name Attribute",
"ldap_group_mapping": "Group → Role Mapping",
"ldap_group_base_dn": "Group Base DN",
"ldap_group_base_dn_hint": "Leave empty to use the main Base DN",
"ldap_group_filter": "Group Filter",
"ldap_group_attr_name": "Group Name Attribute",
"ldap_default_role": "Default Role",
"ldap_default_role_hint": "Role assigned to users whose LDAP groups don't match any mapping",
"ldap_role_viewer": "Viewer",
"ldap_role_operator": "Operator",
"ldap_role_admin": "Admin",
"ldap_group_role_map": "Group → Role Map",
"ldap_group_role_map_hint": "Map LDAP groups to BetterDesk roles. Format: GroupDN=role separated by | (pipe). Example: CN=Admins,DC=example,DC=com=admin|CN=Ops,DC=example,DC=com=operator",
"ldap_test_connection": "Test Connection",
"ldap_testing": "Testing...",
"ldap_test_success": "Connection successful! LDAP server is reachable and bind credentials are valid.",
"ldap_test_failed": "Connection failed",
"ldap_save": "Save LDAP Configuration",
"ldap_saved": "LDAP configuration saved successfully",
"oidc_title": "OIDC / OAuth2 (SSO)",
"oidc_desc": "Configure Single Sign-On via an OpenID Connect provider (Azure AD, Okta, Google, Keycloak, etc.).",
"oidc_enabled": "Enable OIDC Authentication",
"oidc_enabled_hint": "Allow users to sign in with an external identity provider",
"oidc_provider": "Identity Provider",
"oidc_display_name": "Button Display Name",
"oidc_display_name_hint": "Text shown on the login button (e.g. Azure AD, Okta)",
"oidc_issuer_url": "Issuer URL",
"oidc_issuer_url_hint": "OpenID Connect issuer URL (discovery will be automatic)",
"oidc_client_id": "Client ID",
"oidc_client_secret": "Client Secret",
"oidc_redirect_url": "Redirect URL (Callback)",
"oidc_redirect_url_hint": "Must match exactly what is configured in your IdP. Usually: http(s)://your-server:21114/api/auth/oidc/callback",
"oidc_scopes": "Scopes",
"oidc_use_pkce": "Use PKCE (recommended)",
"oidc_auto_discovery": "Auto-discovery",
"oidc_allow_signup": "Auto-create users",
"oidc_manual_endpoints": "Manual Endpoint URLs",
"oidc_authorization_url": "Authorization Endpoint",
"oidc_token_url": "Token Endpoint",
"oidc_userinfo_url": "Userinfo Endpoint",
"oidc_claim_mapping": "Claim Mapping",
"oidc_claim_username": "Username Claim",
"oidc_claim_email": "Email Claim",
"oidc_claim_name": "Display Name Claim",
"oidc_claim_groups": "Groups Claim",
"oidc_role_mapping": "Role Mapping",
"oidc_default_role": "Default Role",
"oidc_group_role_map": "Group → Role Mapping",
"oidc_group_role_map_hint": "One mapping per line: group_name=role (e.g. admins=admin, operators=operator)",
"oidc_test_discovery": "Test Discovery",
"oidc_testing": "Testing...",
"oidc_test_success": "Discovery successful! Endpoints resolved.",
"oidc_test_failed": "Discovery failed",
"oidc_issuer_required": "Issuer URL is required to test discovery",
"oidc_save": "Save OIDC Configuration",
"oidc_saved": "OIDC configuration saved successfully",
"language": "Language",
"change_password": "Change Password",
"password_desc": "Update your account password",
+95 -1
View File
@@ -87,6 +87,14 @@
"password_placeholder": "Wprowadź hasło",
"remember_me": "Zapamiętaj mnie",
"login_failed": "Logowanie nie powiodło się. Sprawdź dane logowania.",
"or": "lub",
"login_sso": "Zaloguj przez SSO",
"login_sso_with": "Zaloguj przez {{provider}}",
"oidc_denied": "Dostęp został odrzucony przez dostawcę tożsamości.",
"oidc_invalid": "Nieprawidłowe wywołanie zwrotne OIDC. Spróbuj ponownie.",
"oidc_failed": "Uwierzytelnianie OIDC nie powiodło się. Spróbuj ponownie.",
"oidc_error": "Wystąpił błąd podczas logowania SSO.",
"oidc_no_account": "Twoje konto nie zostało znalezione, a automatyczna rejestracja jest wyłączona.",
"invalid_credentials": "Nieprawidłowa nazwa użytkownika lub hasło.",
"pro_only_account": "To konto jest przeznaczone wyłącznie dla klienta RustDesk. Użyj aplikacji RustDesk, aby się zalogować.",
"fill_all_fields": "Wypełnij wszystkie pola",
@@ -105,8 +113,10 @@
"totp_enter_code": "Wprowadź 6-cyfrowy kod",
"totp_enter_recovery": "Wprowadź kod odzyskiwania",
"totp_session_expired": "Sesja wygasła. Zaloguj się ponownie.",
"totp_rate_limited": "Zbyt wiele prób. Spróbuj ponownie później.",
"totp_already_enabled": "Uwierzytelnianie dwuskładnikowe jest już włączone",
"passwords_mismatch": "Hasła nie są identyczne"
"passwords_mismatch": "Hasła nie są identyczne",
"emergency_mode_banner": "Serwer Go jest nieosiągalny — tryb awaryjny (tylko admin, ograniczona funkcjonalność)"
},
"dashboard": {
"title": "Panel",
@@ -314,6 +324,90 @@
"tab_server": "Serwer",
"tab_branding": "Wygląd",
"tab_backup": "Kopia zapasowa",
"tab_auth": "Uwierzytelnianie",
"ldap_title": "LDAP / Active Directory",
"ldap_desc": "Skonfiguruj zewnętrzne uwierzytelnianie przez LDAP lub Active Directory. Użytkownicy będą mogli logować się za pomocą poświadczeń domenowych.",
"ldap_enabled": "Włącz uwierzytelnianie LDAP",
"ldap_enabled_hint": "Po włączeniu użytkownicy mogą logować się za pomocą poświadczeń LDAP/AD. Konta lokalne nadal działają jako awaryjne.",
"ldap_connection": "Połączenie z serwerem",
"ldap_host": "Host",
"ldap_port": "Port",
"ldap_use_tls": "LDAPS (TLS)",
"ldap_start_tls": "StartTLS",
"ldap_skip_verify": "Pomiń weryfikację TLS",
"ldap_timeout": "Timeout połączenia (s)",
"ldap_timeout_hint": "Czas oczekiwania na odpowiedź serwera LDAP w sekundach",
"ldap_bind_mode": "Tryb wiązania",
"ldap_direct_bind": "Bezpośrednie wiązanie",
"ldap_direct_bind_hint": "Wiązanie bezpośrednio za pomocą szablonu DN zamiast konta serwisowego do wyszukiwania.",
"ldap_bind_dn": "Bind DN (konto serwisowe)",
"ldap_bind_dn_hint": "Distinguished Name konta serwisowego tylko do odczytu, używanego do wyszukiwania użytkowników.",
"ldap_bind_password": "Hasło Bind",
"ldap_base_dn": "Base DN",
"ldap_base_dn_hint": "Baza wyszukiwania użytkowników, np. dc=example,dc=com",
"ldap_user_filter": "Filtr użytkownika",
"ldap_user_filter_hint": "Filtr LDAP do wyszukiwania użytkowników. Użyj {{username}} jako placeholder. Domyślnie: (sAMAccountName={{username}})",
"ldap_direct_bind_dn": "Szablon DN bezpośredniego wiązania",
"ldap_direct_bind_dn_hint": "Szablon DN do bezpośredniego wiązania. Użyj {{username}} jako placeholder, np. uid={{username}},ou=users,dc=example,dc=com",
"ldap_user_attrs": "Atrybuty użytkownika",
"ldap_attr_id": "Atrybut nazwy użytkownika",
"ldap_attr_email": "Atrybut e-mail",
"ldap_attr_name": "Atrybut nazwy wyświetlanej",
"ldap_group_mapping": "Mapowanie grup → ról",
"ldap_group_base_dn": "Base DN grup",
"ldap_group_base_dn_hint": "Pozostaw puste, aby użyć głównego Base DN",
"ldap_group_filter": "Filtr grup",
"ldap_group_attr_name": "Atrybut nazwy grupy",
"ldap_default_role": "Domyślna rola",
"ldap_default_role_hint": "Rola przypisywana użytkownikom, których grupy LDAP nie pasują do żadnego mapowania",
"ldap_role_viewer": "Przeglądający",
"ldap_role_operator": "Operator",
"ldap_role_admin": "Administrator",
"ldap_group_role_map": "Mapa grup → ról",
"ldap_group_role_map_hint": "Mapuj grupy LDAP na role BetterDesk. Format: GroupDN=rola oddzielone | (pipe). Przykład: CN=Admins,DC=example,DC=com=admin|CN=Ops,DC=example,DC=com=operator",
"ldap_test_connection": "Testuj połączenie",
"ldap_testing": "Testowanie...",
"ldap_test_success": "Połączenie pomyślne! Serwer LDAP jest osiągalny i poświadczenia bind są prawidłowe.",
"ldap_test_failed": "Połączenie nie powiodło się",
"ldap_save": "Zapisz konfigurację LDAP",
"ldap_saved": "Konfiguracja LDAP została zapisana pomyślnie",
"oidc_title": "OIDC / OAuth2 (SSO)",
"oidc_desc": "Konfiguracja Single Sign-On przez dostawcę OpenID Connect (Azure AD, Okta, Google, Keycloak itp.).",
"oidc_enabled": "Włącz uwierzytelnianie OIDC",
"oidc_enabled_hint": "Pozwól użytkownikom logować się przez zewnętrznego dostawcę tożsamości",
"oidc_provider": "Dostawca tożsamości",
"oidc_display_name": "Tekst na przycisku",
"oidc_display_name_hint": "Tekst wyświetlany na przycisku logowania (np. Azure AD, Okta)",
"oidc_issuer_url": "URL wystawcy",
"oidc_issuer_url_hint": "URL wystawcy OpenID Connect (odkrywanie będzie automatyczne)",
"oidc_client_id": "ID klienta",
"oidc_client_secret": "Sekret klienta",
"oidc_redirect_url": "URL przekierowania (Callback)",
"oidc_redirect_url_hint": "Musi dokładnie odpowiadać konfiguracji w dostawcy. Zwykle: http(s)://twoj-serwer:21114/api/auth/oidc/callback",
"oidc_scopes": "Zakresy",
"oidc_use_pkce": "Użyj PKCE (zalecane)",
"oidc_auto_discovery": "Auto-odkrywanie",
"oidc_allow_signup": "Automatyczne tworzenie użytkowników",
"oidc_manual_endpoints": "Ręczne adresy URL endpointów",
"oidc_authorization_url": "Endpoint autoryzacji",
"oidc_token_url": "Endpoint tokenów",
"oidc_userinfo_url": "Endpoint informacji o użytkowniku",
"oidc_claim_mapping": "Mapowanie pól (claims)",
"oidc_claim_username": "Pole nazwy użytkownika",
"oidc_claim_email": "Pole e-mail",
"oidc_claim_name": "Pole wyświetlanej nazwy",
"oidc_claim_groups": "Pole grup",
"oidc_role_mapping": "Mapowanie ról",
"oidc_default_role": "Domyślna rola",
"oidc_group_role_map": "Mapowanie grupa → rola",
"oidc_group_role_map_hint": "Jedno mapowanie na linię: nazwa_grupy=rola (np. admins=admin, operators=operator)",
"oidc_test_discovery": "Test odkrywania",
"oidc_testing": "Testowanie...",
"oidc_test_success": "Odkrywanie powiodło się! Endpointy rozwiązane.",
"oidc_test_failed": "Odkrywanie nie powiodło się",
"oidc_issuer_required": "URL wystawcy jest wymagany do testu odkrywania",
"oidc_save": "Zapisz konfigurację OIDC",
"oidc_saved": "Konfiguracja OIDC została zapisana pomyślnie",
"language": "Język",
"change_password": "Zmiana hasła",
"password_desc": "Zaktualizuj hasło do konta",
+95 -1
View File
@@ -87,6 +87,14 @@
"password_placeholder": "请输入密码",
"remember_me": "记住我",
"login_failed": "登录失败,请检查您的凭据。",
"or": "或",
"login_sso": "通过 SSO 登录",
"login_sso_with": "通过 {{provider}} 登录",
"oidc_denied": "身份提供商拒绝了访问。",
"oidc_invalid": "无效的 OIDC 回调。请重试。",
"oidc_failed": "OIDC 身份验证失败。请重试。",
"oidc_error": "SSO 登录时发生错误。",
"oidc_no_account": "未找到您的帐户,且自动注册已禁用。",
"invalid_credentials": "用户名或密码无效。",
"fill_all_fields": "请填写所有字段",
"logout_confirm": "确定要退出登录吗?",
@@ -104,9 +112,11 @@
"totp_enter_code": "请输入 6 位验证码",
"totp_enter_recovery": "请输入恢复码",
"totp_session_expired": "会话已过期,请重新登录。",
"totp_rate_limited": "尝试次数过多,请稍后再试。",
"totp_already_enabled": "双因素认证已启用",
"pro_only_account": "此账户仅适用于 RustDesk 桌面客户端。请使用 RustDesk 应用登录。",
"passwords_mismatch": "两次输入的密码不一致"
"passwords_mismatch": "两次输入的密码不一致",
"emergency_mode_banner": "Go 服务器不可达 — 紧急模式运行中(仅管理员,功能受限)"
},
"dashboard": {
"title": "仪表板",
@@ -314,6 +324,90 @@
"tab_server": "服务器",
"tab_branding": "品牌",
"tab_backup": "备份",
"tab_auth": "认证",
"ldap_title": "LDAP / Active Directory",
"ldap_desc": "配置通过 LDAP 或 Active Directory 进行的外部身份验证。用户将能够使用其域凭据登录。",
"ldap_enabled": "启用 LDAP 认证",
"ldap_enabled_hint": "启用后,用户可以使用 LDAP/AD 凭据登录。本地帐户仍可作为备用。",
"ldap_connection": "服务器连接",
"ldap_host": "主机",
"ldap_port": "端口",
"ldap_use_tls": "LDAPS (TLS)",
"ldap_start_tls": "StartTLS",
"ldap_skip_verify": "跳过 TLS 验证",
"ldap_timeout": "连接超时 (秒)",
"ldap_timeout_hint": "等待 LDAP 服务器响应的秒数",
"ldap_bind_mode": "绑定模式",
"ldap_direct_bind": "直接绑定",
"ldap_direct_bind_hint": "使用 DN 模板直接绑定,而不是使用服务帐户进行搜索。",
"ldap_bind_dn": "绑定 DN(服务帐户)",
"ldap_bind_dn_hint": "用于搜索用户的只读服务帐户的可分辨名称。",
"ldap_bind_password": "绑定密码",
"ldap_base_dn": "基础 DN",
"ldap_base_dn_hint": "查找用户的搜索基础,例如 dc=example,dc=com",
"ldap_user_filter": "用户过滤器",
"ldap_user_filter_hint": "用于查找用户的 LDAP 过滤器。使用 {{username}} 作为占位符。默认值:(sAMAccountName={{username}})",
"ldap_direct_bind_dn": "直接绑定 DN 模板",
"ldap_direct_bind_dn_hint": "直接绑定的 DN 模板。使用 {{username}} 作为占位符,例如 uid={{username}},ou=users,dc=example,dc=com",
"ldap_user_attrs": "用户属性",
"ldap_attr_id": "用户名属性",
"ldap_attr_email": "邮箱属性",
"ldap_attr_name": "显示名称属性",
"ldap_group_mapping": "组 → 角色映射",
"ldap_group_base_dn": "组基础 DN",
"ldap_group_base_dn_hint": "留空则使用主基础 DN",
"ldap_group_filter": "组过滤器",
"ldap_group_attr_name": "组名属性",
"ldap_default_role": "默认角色",
"ldap_default_role_hint": "分配给 LDAP 组不匹配任何映射的用户的角色",
"ldap_role_viewer": "查看者",
"ldap_role_operator": "操作员",
"ldap_role_admin": "管理员",
"ldap_group_role_map": "组 → 角色映射",
"ldap_group_role_map_hint": "将 LDAP 组映射到 BetterDesk 角色。格式:GroupDN=角色,用 |(管道符)分隔。示例:CN=Admins,DC=example,DC=com=admin|CN=Ops,DC=example,DC=com=operator",
"ldap_test_connection": "测试连接",
"ldap_testing": "测试中...",
"ldap_test_success": "连接成功!LDAP 服务器可访问且绑定凭据有效。",
"ldap_test_failed": "连接失败",
"ldap_save": "保存 LDAP 配置",
"ldap_saved": "LDAP 配置已成功保存",
"oidc_title": "OIDC / OAuth2 (SSO)",
"oidc_desc": "通过 OpenID Connect 提供商配置单点登录 (Azure AD、Okta、Google、Keycloak 等)。",
"oidc_enabled": "启用 OIDC 身份验证",
"oidc_enabled_hint": "允许用户通过外部身份提供商登录",
"oidc_provider": "身份提供商",
"oidc_display_name": "按钮显示名称",
"oidc_display_name_hint": "登录按钮上显示的文本 (例如 Azure AD、Okta)",
"oidc_issuer_url": "发行者 URL",
"oidc_issuer_url_hint": "OpenID Connect 发行者 URL (将自动发现)",
"oidc_client_id": "客户端 ID",
"oidc_client_secret": "客户端密钥",
"oidc_redirect_url": "重定向 URL (回调)",
"oidc_redirect_url_hint": "必须与身份提供商中的配置完全匹配。通常为: http(s)://your-server:21114/api/auth/oidc/callback",
"oidc_scopes": "作用域",
"oidc_use_pkce": "使用 PKCE (推荐)",
"oidc_auto_discovery": "自动发现",
"oidc_allow_signup": "自动创建用户",
"oidc_manual_endpoints": "手动端点 URL",
"oidc_authorization_url": "授权端点",
"oidc_token_url": "令牌端点",
"oidc_userinfo_url": "用户信息端点",
"oidc_claim_mapping": "声明映射",
"oidc_claim_username": "用户名声明",
"oidc_claim_email": "电子邮件声明",
"oidc_claim_name": "显示名称声明",
"oidc_claim_groups": "组声明",
"oidc_role_mapping": "角色映射",
"oidc_default_role": "默认角色",
"oidc_group_role_map": "组 → 角色映射",
"oidc_group_role_map_hint": "每行一个映射: 组名=角色 (例如 admins=admin, operators=operator)",
"oidc_test_discovery": "测试发现",
"oidc_testing": "测试中...",
"oidc_test_success": "发现成功!端点已解析。",
"oidc_test_failed": "发现失败",
"oidc_issuer_required": "测试发现需要发行者 URL",
"oidc_save": "保存 OIDC 配置",
"oidc_saved": "OIDC 配置已成功保存",
"language": "语言",
"change_password": "修改密码",
"password_desc": "更新您的账户密码",
+2
View File
@@ -113,6 +113,8 @@ function requireAuth(req, res, next) {
// Add user info to locals for templates
res.locals.user = req.session.user;
// Phase A: expose emergency mode flag to EJS templates
res.locals.emergencyMode = !!req.session.emergencyMode;
next();
}
+36
View File
@@ -211,6 +211,42 @@
/* ==================== TOTP (2FA) Styles ==================== */
/* ==================== SSO / OIDC Styles ==================== */
.login-sso {
margin-top: var(--space-md);
}
.login-divider {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-md);
color: var(--text-tertiary);
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.login-divider::before,
.login-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border-primary);
}
.sso-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
text-decoration: none;
}
/* ==================== TOTP (2FA) Styles ==================== */
.totp-header {
text-align: center;
margin-bottom: var(--space-lg);
+99
View File
@@ -474,6 +474,27 @@ table {
NAVBAR
═══════════════════════════════════════════════════════════════════════════ */
/* Emergency mode banner (Phase A: Auth Delegation) */
.emergency-banner {
position: fixed;
top: 0;
left: var(--sidebar-current-width, var(--sidebar-width-collapsed));
right: 0;
height: 36px;
background: #d32f2f;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
z-index: calc(var(--z-sticky, 100) + 1);
transition: left var(--transition-normal);
}
.emergency-banner .material-icons { font-size: 18px; }
.emergency-banner + .navbar { top: 36px; }
.navbar {
position: fixed;
top: 0;
@@ -1727,3 +1748,81 @@ input[type="datetime-local"]:focus {
grid-template-columns: 1fr;
}
}
/* ═══════════════════════════════════════════════════════════════════════════
SETTINGS SUBSECTION & ALERTS
═══════════════════════════════════════════════════════════════════════════ */
.settings-subsection-title {
font-size: var(--font-size-base);
font-weight: 600;
color: var(--text-primary);
margin: var(--space-lg) 0 var(--space-sm) 0;
padding-bottom: var(--space-xs);
border-bottom: 1px solid var(--border-color);
}
.settings-subsection-title:first-child {
margin-top: 0;
}
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-sm) 0;
}
.toggle-row .toggle-label {
display: flex;
flex-direction: column;
gap: 2px;
}
.toggle-row .toggle-label span:first-child {
font-weight: 500;
color: var(--text-primary);
}
.toggle-row .toggle-label .form-hint {
margin-top: 0;
}
.alert {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
line-height: 1.5;
}
.alert .material-icons {
font-size: 20px;
flex-shrink: 0;
}
.alert-success {
background: rgba(46, 160, 67, 0.12);
color: #3fb950;
border: 1px solid rgba(46, 160, 67, 0.25);
}
.alert-danger {
background: rgba(248, 81, 73, 0.12);
color: #f85149;
border: 1px solid rgba(248, 81, 73, 0.25);
}
[data-theme="light"] .alert-success {
background: rgba(46, 160, 67, 0.08);
color: #1a7f37;
border-color: rgba(46, 160, 67, 0.3);
}
[data-theme="light"] .alert-danger {
background: rgba(248, 81, 73, 0.08);
color: #cf222e;
border-color: rgba(248, 81, 73, 0.3);
}
+26
View File
@@ -22,6 +22,9 @@
if (!loginForm) return;
const csrfToken = window.BetterDesk?.csrfToken || '';
// Check OIDC status and show SSO button if enabled
checkOIDCStatus();
// Password visibility toggle
passwordToggle?.addEventListener('click', () => {
@@ -210,6 +213,29 @@
}, 500);
}
}
/**
* Check if OIDC/SSO is enabled and show the SSO button.
*/
async function checkOIDCStatus() {
try {
const response = await fetch('/api/auth/oidc/status');
if (!response.ok) return;
const data = await response.json();
if (data.enabled) {
const ssoSection = document.getElementById('sso-section');
const ssoBtnText = document.getElementById('sso-btn-text');
if (ssoSection) {
ssoSection.classList.remove('hidden');
}
if (ssoBtnText && data.display_name) {
ssoBtnText.textContent = _('auth.login_sso_with', { provider: data.display_name }) || `Sign in with ${data.display_name}`;
}
}
} catch (e) {
// Silently ignore — SSO button stays hidden
}
}
// Add shake animation
const style = document.createElement('style');
+394 -1
View File
@@ -14,6 +14,10 @@
initBrandingSection();
// Only init server-config sections when the tab is visible (requires server.config permission)
if (document.getElementById('tab-auth') && document.getElementById('tab-auth').style.display !== 'none') {
initLdapSection();
initOidcSection();
}
if (document.getElementById('tab-backup') && document.getElementById('tab-backup').style.display !== 'none') {
initBackupSection();
}
@@ -48,7 +52,7 @@
// Check URL hash for direct tab navigation
const hash = window.location.hash.replace('#', '');
if (['branding', 'server', 'backup', 'updates'].includes(hash)) {
if (['branding', 'server', 'backup', 'updates', 'auth'].includes(hash)) {
const tab = document.querySelector(`[data-tab="${hash}"]`);
if (tab) tab.click();
}
@@ -1960,5 +1964,394 @@
// Expose retention loader so tab activation can call it
window.loadBackupRetention = loadBackupRetention;
// ==================== LDAP / Authentication Section ====================
/**
* Initialize LDAP settings section
*/
async function initLdapSection() {
const form = document.getElementById('ldap-form');
if (!form) return;
// Toggle config fields visibility based on enabled checkbox
const enabledCb = document.getElementById('ldap-enabled');
const configFields = document.getElementById('ldap-config-fields');
if (enabledCb && configFields) {
enabledCb.addEventListener('change', () => {
configFields.style.display = enabledCb.checked ? '' : 'none';
});
}
// Toggle direct bind vs bind+search fields
const directBindCb = document.getElementById('ldap-direct-bind');
const bindSearchFields = document.getElementById('ldap-bind-search-fields');
const directBindFields = document.getElementById('ldap-direct-bind-fields');
if (directBindCb) {
directBindCb.addEventListener('change', () => {
if (bindSearchFields) bindSearchFields.style.display = directBindCb.checked ? 'none' : '';
if (directBindFields) directBindFields.style.display = directBindCb.checked ? '' : 'none';
});
}
// Auto-update port when TLS checkbox changes
const useTlsCb = document.getElementById('ldap-use-tls');
const portInput = document.getElementById('ldap-port');
if (useTlsCb && portInput) {
useTlsCb.addEventListener('change', () => {
const current = parseInt(portInput.value, 10);
if (useTlsCb.checked && (current === 389 || !current)) {
portInput.value = 636;
} else if (!useTlsCb.checked && current === 636) {
portInput.value = 389;
}
});
}
// Load existing config
await loadLdapConfig();
// Test connection button
document.getElementById('ldap-test-btn')?.addEventListener('click', testLdapConnection);
// Form submission
form.addEventListener('submit', async (e) => {
e.preventDefault();
await saveLdapConfig();
});
}
/**
* Load LDAP configuration from Go server (via Node.js proxy)
*/
async function loadLdapConfig() {
try {
const data = await Utils.api('/api/settings/ldap');
populateLdapForm(data);
} catch (error) {
console.error('Failed to load LDAP config:', error);
}
}
/**
* Populate LDAP form fields with config data
*/
function populateLdapForm(data) {
if (!data) return;
const setVal = (id, val) => {
const el = document.getElementById(id);
if (el) el.value = val ?? '';
};
const setChecked = (id, val) => {
const el = document.getElementById(id);
if (el) el.checked = !!val;
};
setChecked('ldap-enabled', data.enabled);
setVal('ldap-host', data.host);
setVal('ldap-port', data.port || 389);
setChecked('ldap-use-tls', data.use_tls);
setChecked('ldap-start-tls', data.start_tls);
setChecked('ldap-skip-tls-verify', data.skip_tls_verify);
setVal('ldap-bind-dn', data.bind_dn);
setVal('ldap-bind-password', data.bind_password);
setVal('ldap-base-dn', data.base_dn);
setVal('ldap-user-filter', data.user_filter);
setVal('ldap-user-attr-id', data.user_attr_id);
setVal('ldap-user-attr-email', data.user_attr_email);
setVal('ldap-user-attr-name', data.user_attr_name);
setVal('ldap-group-base-dn', data.group_base_dn);
setVal('ldap-group-filter', data.group_filter);
setVal('ldap-group-attr-name', data.group_attr_name);
setVal('ldap-default-role', data.default_role || 'viewer');
setVal('ldap-group-role-map', data.group_role_map);
setChecked('ldap-direct-bind', data.direct_bind);
setVal('ldap-direct-bind-dn', data.direct_bind_dn);
setVal('ldap-conn-timeout', data.conn_timeout_sec || 10);
// Update visibility
const configFields = document.getElementById('ldap-config-fields');
if (configFields) configFields.style.display = data.enabled ? '' : 'none';
const bindSearchFields = document.getElementById('ldap-bind-search-fields');
const directBindFields = document.getElementById('ldap-direct-bind-fields');
if (bindSearchFields) bindSearchFields.style.display = data.direct_bind ? 'none' : '';
if (directBindFields) directBindFields.style.display = data.direct_bind ? '' : 'none';
}
/**
* Collect LDAP form data
*/
function collectLdapData() {
const getVal = (id) => {
const el = document.getElementById(id);
return el ? el.value.trim() : '';
};
const getChecked = (id) => {
const el = document.getElementById(id);
return el ? el.checked : false;
};
return {
enabled: getChecked('ldap-enabled'),
host: getVal('ldap-host'),
port: parseInt(getVal('ldap-port'), 10) || 389,
use_tls: getChecked('ldap-use-tls'),
start_tls: getChecked('ldap-start-tls'),
skip_tls_verify: getChecked('ldap-skip-tls-verify'),
bind_dn: getVal('ldap-bind-dn'),
bind_password: getVal('ldap-bind-password'),
base_dn: getVal('ldap-base-dn'),
user_filter: getVal('ldap-user-filter'),
user_attr_id: getVal('ldap-user-attr-id'),
user_attr_email: getVal('ldap-user-attr-email'),
user_attr_name: getVal('ldap-user-attr-name'),
group_base_dn: getVal('ldap-group-base-dn'),
group_filter: getVal('ldap-group-filter'),
group_attr_name: getVal('ldap-group-attr-name'),
default_role: getVal('ldap-default-role'),
group_role_map: getVal('ldap-group-role-map'),
direct_bind: getChecked('ldap-direct-bind'),
direct_bind_dn: getVal('ldap-direct-bind-dn'),
conn_timeout_sec: parseInt(getVal('ldap-conn-timeout'), 10) || 10
};
}
/**
* Save LDAP configuration
*/
async function saveLdapConfig() {
try {
const data = collectLdapData();
await Utils.api('/api/settings/ldap', {
method: 'PUT',
body: data
});
Notifications.success(_('settings.ldap_saved'));
} catch (error) {
Notifications.error(error.message || _('errors.server_error'));
}
}
/**
* Test LDAP connection with current form values
*/
async function testLdapConnection() {
const btn = document.getElementById('ldap-test-btn');
const resultEl = document.getElementById('ldap-test-result');
if (!btn) return;
btn.disabled = true;
btn.innerHTML = '<span class="material-icons spin">sync</span> ' + _('settings.ldap_testing');
if (resultEl) {
resultEl.innerHTML = '';
resultEl.style.display = 'none';
}
try {
const data = collectLdapData();
const result = await Utils.api('/api/settings/ldap/test', {
method: 'POST',
body: data
});
if (resultEl) {
resultEl.style.display = 'block';
if (result.success) {
resultEl.innerHTML = '<div class="alert alert-success"><span class="material-icons">check_circle</span> ' + _('settings.ldap_test_success') + '</div>';
} else {
resultEl.innerHTML = '<div class="alert alert-danger"><span class="material-icons">error</span> ' + Utils.escapeHtml(result.error || _('settings.ldap_test_failed')) + '</div>';
}
}
} catch (error) {
if (resultEl) {
resultEl.style.display = 'block';
resultEl.innerHTML = '<div class="alert alert-danger"><span class="material-icons">error</span> ' + Utils.escapeHtml(error.message || _('settings.ldap_test_failed')) + '</div>';
}
} finally {
btn.disabled = false;
btn.innerHTML = '<span class="material-icons">cable</span> ' + _('settings.ldap_test_connection');
}
}
// ==================== OIDC / OAuth2 Section ====================
async function initOidcSection() {
const form = document.getElementById('oidc-form');
if (!form) return;
// Toggle config fields visibility based on enabled checkbox
const enabledCb = document.getElementById('oidc-enabled');
const configFields = document.getElementById('oidc-config-fields');
if (enabledCb && configFields) {
enabledCb.addEventListener('change', () => {
configFields.style.display = enabledCb.checked ? '' : 'none';
});
}
// Toggle manual endpoints based on auto-discovery checkbox
const autoDiscoveryCb = document.getElementById('oidc-auto-discovery');
const manualEndpoints = document.getElementById('oidc-manual-endpoints');
if (autoDiscoveryCb && manualEndpoints) {
autoDiscoveryCb.addEventListener('change', () => {
manualEndpoints.style.display = autoDiscoveryCb.checked ? 'none' : '';
});
}
// Load existing config
await loadOidcConfig();
// Test discovery button
document.getElementById('oidc-test-btn')?.addEventListener('click', testOidcDiscovery);
// Form submission
form.addEventListener('submit', async (e) => {
e.preventDefault();
await saveOidcConfig();
});
}
async function loadOidcConfig() {
try {
const data = await Utils.api('/api/settings/oidc');
populateOidcForm(data);
} catch (error) {
console.error('Failed to load OIDC config:', error);
}
}
function populateOidcForm(data) {
if (!data) return;
const setVal = (id, val) => {
const el = document.getElementById(id);
if (el) el.value = val ?? '';
};
const setChecked = (id, val) => {
const el = document.getElementById(id);
if (el) el.checked = !!val;
};
setChecked('oidc-enabled', data.enabled);
setVal('oidc-display-name', data.display_name);
setVal('oidc-issuer-url', data.issuer_url);
setVal('oidc-client-id', data.client_id);
setVal('oidc-client-secret', data.client_secret);
setVal('oidc-redirect-url', data.redirect_url);
setVal('oidc-scopes', data.scopes || 'openid profile email');
setChecked('oidc-use-pkce', data.use_pkce);
setChecked('oidc-auto-discovery', data.auto_discovery !== false);
setChecked('oidc-allow-signup', data.allow_signup !== false);
setVal('oidc-authorization-url', data.authorization_url);
setVal('oidc-token-url', data.token_url);
setVal('oidc-userinfo-url', data.userinfo_url);
setVal('oidc-claim-username', data.claim_username || 'preferred_username');
setVal('oidc-claim-email', data.claim_email || 'email');
setVal('oidc-claim-name', data.claim_name || 'name');
setVal('oidc-claim-groups', data.claim_groups || 'groups');
setVal('oidc-default-role', data.default_role || 'viewer');
setVal('oidc-group-role-map', data.group_role_map);
// Update visibility
const configFields = document.getElementById('oidc-config-fields');
if (configFields) configFields.style.display = data.enabled ? '' : 'none';
const manualEndpoints = document.getElementById('oidc-manual-endpoints');
if (manualEndpoints) manualEndpoints.style.display = (data.auto_discovery !== false) ? 'none' : '';
}
function collectOidcData() {
const getVal = (id) => {
const el = document.getElementById(id);
return el ? el.value.trim() : '';
};
const getChecked = (id) => {
const el = document.getElementById(id);
return el ? el.checked : false;
};
return {
enabled: getChecked('oidc-enabled'),
display_name: getVal('oidc-display-name'),
issuer_url: getVal('oidc-issuer-url'),
client_id: getVal('oidc-client-id'),
client_secret: getVal('oidc-client-secret'),
redirect_url: getVal('oidc-redirect-url'),
scopes: getVal('oidc-scopes'),
use_pkce: getChecked('oidc-use-pkce'),
auto_discovery: getChecked('oidc-auto-discovery'),
allow_signup: getChecked('oidc-allow-signup'),
authorization_url: getVal('oidc-authorization-url'),
token_url: getVal('oidc-token-url'),
userinfo_url: getVal('oidc-userinfo-url'),
claim_username: getVal('oidc-claim-username'),
claim_email: getVal('oidc-claim-email'),
claim_name: getVal('oidc-claim-name'),
claim_groups: getVal('oidc-claim-groups'),
default_role: getVal('oidc-default-role'),
group_role_map: getVal('oidc-group-role-map')
};
}
async function saveOidcConfig() {
try {
const data = collectOidcData();
await Utils.api('/api/settings/oidc', {
method: 'PUT',
body: data
});
Notifications.success(_('settings.oidc_saved'));
} catch (error) {
Notifications.error(error.message || _('errors.server_error'));
}
}
async function testOidcDiscovery() {
const btn = document.getElementById('oidc-test-btn');
const resultEl = document.getElementById('oidc-test-result');
if (!btn) return;
btn.disabled = true;
btn.innerHTML = '<span class="material-icons spin">sync</span> ' + _('settings.oidc_testing');
if (resultEl) {
resultEl.innerHTML = '';
resultEl.style.display = 'none';
}
try {
const issuerUrl = document.getElementById('oidc-issuer-url')?.value?.trim();
if (!issuerUrl) {
throw new Error(_('settings.oidc_issuer_required'));
}
const result = await Utils.api('/api/settings/oidc/test', {
method: 'POST',
body: { issuer_url: issuerUrl }
});
if (resultEl) {
resultEl.style.display = 'block';
if (result.success) {
let details = '';
if (result.authorization_endpoint) details += '<br><small>Authorization: ' + Utils.escapeHtml(result.authorization_endpoint) + '</small>';
if (result.token_endpoint) details += '<br><small>Token: ' + Utils.escapeHtml(result.token_endpoint) + '</small>';
if (result.userinfo_endpoint) details += '<br><small>Userinfo: ' + Utils.escapeHtml(result.userinfo_endpoint) + '</small>';
resultEl.innerHTML = '<div class="alert alert-success"><span class="material-icons">check_circle</span> ' + _('settings.oidc_test_success') + details + '</div>';
} else {
resultEl.innerHTML = '<div class="alert alert-danger"><span class="material-icons">error</span> ' + Utils.escapeHtml(result.error || _('settings.oidc_test_failed')) + '</div>';
}
}
} catch (error) {
if (resultEl) {
resultEl.style.display = 'block';
resultEl.innerHTML = '<div class="alert alert-danger"><span class="material-icons">error</span> ' + Utils.escapeHtml(error.message || _('settings.oidc_test_failed')) + '</div>';
}
} finally {
btn.disabled = false;
btn.innerHTML = '<span class="material-icons">travel_explore</span> ' + _('settings.oidc_test_discovery');
}
}
})();
+204 -18
View File
@@ -7,9 +7,24 @@ const express = require('express');
const router = express.Router();
const authService = require('../services/authService');
const db = require('../services/database');
const betterdeskApi = require('../services/betterdeskApi');
const { guestOnly, requireAuth } = require('../middleware/auth');
const { loginLimiter, passwordChangeLimiter } = require('../middleware/rateLimiter');
/**
* Validate that a return URL is a safe relative path. Mirrors
* auth.IsRelativeReturnURL in the Go server. Rejects absolute URLs,
* protocol-relative URLs (//evil.com/...), CR/LF (response splitting),
* and anything not starting with a single "/".
*/
function isSafeReturnUrl(u) {
if (typeof u !== 'string' || u.length === 0) return false;
if (/[\r\n\x00]/.test(u)) return false;
if (!u.startsWith('/')) return false;
if (u.startsWith('//') || u.startsWith('/\\')) return false;
return true;
}
/**
* GET /login - Login page
* Serves desktop-style login when user previously had desktop mode active
@@ -94,6 +109,10 @@ router.post('/api/auth/login', loginLimiter, async (req, res) => {
// Store pending 2FA session
req.session.pendingTotpUserId = user.id;
req.session.pendingTotpUser = user;
// Phase A: store Go partial token for delegated TOTP verification
if (user.goPartialToken) {
req.session.goPartialToken = user.goPartialToken;
}
return res.json({
success: true,
@@ -116,6 +135,10 @@ router.post('/api/auth/login', loginLimiter, async (req, res) => {
username: user.username,
role: user.role
};
// Phase A: store emergency mode flag in session for UI banner
if (user.emergencyMode) {
req.session.emergencyMode = true;
}
// Log successful login
await db.logAction(user.id, 'login', `User logged in`, req.ip);
@@ -226,16 +249,146 @@ router.get('/logout', (req, res) => {
});
});
// ==================== OIDC/OAuth2 Routes ====================
const crypto = require('crypto');
const bcrypt = require('bcrypt');
/**
* GET /api/auth/oidc/status - Public endpoint for login page
* Returns whether OIDC is enabled and the display name for the button.
*/
router.get('/api/auth/oidc/status', async (req, res) => {
try {
const result = await betterdeskApi.getOIDCStatus();
if (!result.success) {
return res.json({ enabled: false });
}
res.json(result.data);
} catch (err) {
res.json({ enabled: false });
}
});
/**
* GET /api/auth/oidc/authorize - Redirect to OIDC IdP
* Proxies to Go server which handles state/nonce/PKCE generation.
* return_url is validated as a relative path before forwarding.
*/
router.get('/api/auth/oidc/authorize', (req, res) => {
const goApiUrl = process.env.BETTERDESK_API_URL || 'http://localhost:21114';
const requested = typeof req.query.return_url === 'string' ? req.query.return_url : '/';
const returnUrl = isSafeReturnUrl(requested) ? requested : '/';
res.redirect(`${goApiUrl}/api/auth/oidc/authorize?return_url=${encodeURIComponent(returnUrl)}`);
});
/**
* GET /api/auth/oidc/session - Session creation after OIDC callback.
*
* Security model: Go server NEVER passes the JWT or user identity through
* the browser URL bar. Instead the IdP callback handler stores a one-time
* auth code (60s TTL) and redirects here with only `?code=<32 bytes>`.
* Node.js POSTs back to Go's /api/auth/oidc/exchange (server-to-server)
* to retrieve the JWT plus the verified username/role.
*
* This prevents JWT/role leakage via:
* - browser history / Referer headers
* - access logs (Go, Node.js, reverse proxy)
* - role spoofing through URL tampering
*/
router.get('/api/auth/oidc/session', async (req, res) => {
const code = typeof req.query.code === 'string' ? req.query.code : '';
if (!code) {
return res.redirect('/login?error=oidc_invalid');
}
try {
// Server-to-server exchange. Go returns { token, username, role, return_url }
// only for a valid, unconsumed, unexpired code.
const exchange = await betterdeskApi.exchangeOIDCCode(code);
if (!exchange || !exchange.success || !exchange.data || !exchange.data.token || !exchange.data.username) {
console.warn('[OIDC] Exchange rejected by Go server');
return res.redirect('/login?error=oidc_invalid');
}
const { token, username, role, return_url } = exchange.data;
// Defense in depth: re-validate the return URL Go provided.
const safeReturnUrl = isSafeReturnUrl(return_url) ? return_url : '/';
// Look up the local user (may have been auto-provisioned by Go).
let user = await db.getUserByUsername(username);
if (!user) {
// Mirror Go-side auto-provisioning into auth.db so sessions persist
// and middleware/RBAC can resolve the user ID locally.
const randomPass = crypto.randomBytes(32).toString('hex');
const hash = await bcrypt.hash(randomPass, 12);
try {
await db.createUser(username, hash, role || 'viewer');
} catch (err) {
console.error('[OIDC] Failed to auto-provision local user', username, err.message);
}
user = await db.getUserByUsername(username);
}
if (!user) {
return res.redirect('/login?error=oidc_error');
}
// Regenerate session to prevent fixation across the authentication boundary.
req.session.regenerate((err) => {
if (err) {
console.error('OIDC session regenerate error:', err);
return res.redirect('/login?error=oidc_error');
}
// Trust the role from the exchange response (server-to-server), not
// the URL. Fall back to the locally stored role only if Go omitted one.
const effectiveRole = role || user.role || 'viewer';
req.session.userId = user.id;
req.session.username = user.username;
req.session.role = effectiveRole;
req.session.user = {
id: user.id,
username: user.username,
role: effectiveRole,
preferred_language: user.preferred_language || null
};
req.session.goToken = token;
req.session.authMethod = 'oidc';
req.session.save((saveErr) => {
if (saveErr) {
console.error('OIDC session save error:', saveErr);
return res.redirect('/login?error=oidc_error');
}
res.redirect(safeReturnUrl);
});
});
} catch (err) {
console.error('OIDC session creation error:', err);
res.redirect('/login?error=oidc_error');
}
});
// ==================== TOTP (2FA) Routes ====================
/**
* POST /api/auth/totp/verify - Verify TOTP code during login
*
* Phase A: If goPartialToken is present in session, delegate verification
* to Go server's POST /api/auth/login/2fa. Otherwise fall back to local
* verification (emergency mode or legacy).
*/
router.post('/api/auth/totp/verify', loginLimiter, async (req, res) => {
try {
const { code, recoveryCode } = req.body;
const pendingUserId = req.session.pendingTotpUserId;
const pendingUser = req.session.pendingTotpUser;
const goPartialToken = req.session.goPartialToken;
if (!pendingUserId || !pendingUser) {
return res.status(400).json({
@@ -243,16 +396,42 @@ router.post('/api/auth/totp/verify', loginLimiter, async (req, res) => {
error: req.t('auth.totp_session_expired')
});
}
const totpCode = code || recoveryCode || '';
// --- Phase A: Delegate to Go server when partial token available ---
if (goPartialToken) {
const goResult = await authService.verifyTotpViaGo(goPartialToken, totpCode);
if (!goResult) {
// Go unreachable — don't lock user out, fall through to local
console.warn('[AUTH] Go server unreachable during 2FA — falling back to local verification');
} else if (goResult.rejected) {
if (goResult.rateLimited) {
return res.status(429).json({
success: false,
error: req.t('auth.totp_rate_limited') || 'Too many attempts. Please try again later.'
});
}
await db.logAction(pendingUserId, 'totp_failed', 'Go server rejected TOTP', req.ip);
return res.status(401).json({
success: false,
error: req.t('auth.totp_invalid_code')
});
} else if (goResult.token) {
// Go accepted — create session
return finalizeLoginSession(req, res, pendingUser, 'totp (Go delegated)');
}
}
// --- Local TOTP verification (fallback or emergency mode) ---
let verified = false;
let method = 'totp';
if (recoveryCode) {
// Try recovery code
verified = await authService.verifyRecoveryCode(pendingUserId, recoveryCode);
method = 'recovery';
} else if (code) {
// Try TOTP code
verified = await authService.verifyTotpCode(pendingUserId, code);
}
@@ -264,20 +443,32 @@ router.post('/api/auth/totp/verify', loginLimiter, async (req, res) => {
});
}
// Regenerate session to prevent session fixation across the 2FA boundary.
// Mirrors the standard login flow: every successful authentication step
// results in a fresh session ID before any privileged data is written.
return finalizeLoginSession(req, res, pendingUser, `${method} (local)`);
} catch (err) {
console.error('TOTP verify error:', err);
res.status(500).json({
success: false,
error: req.t('errors.server_error')
});
}
});
/**
* Finalize login session after successful 2FA verification.
* Regenerates session, sets user data, logs action.
*/
function finalizeLoginSession(req, res, pendingUser, method) {
return new Promise((resolve) => {
req.session.regenerate(async (regenErr) => {
if (regenErr) {
console.error('TOTP session regeneration error:', regenErr);
return res.status(500).json({
res.status(500).json({
success: false,
error: req.t('errors.server_error')
error: 'Server error'
});
return resolve();
}
// Pending fields belonged to the old session; the regenerated session
// starts empty, so we only need to populate the authenticated user.
req.session.userId = pendingUser.id;
req.session.user = {
id: pendingUser.id,
@@ -299,15 +490,10 @@ router.post('/api/auth/totp/verify', loginLimiter, async (req, res) => {
role: pendingUser.role
}
});
resolve();
});
} catch (err) {
console.error('TOTP verify error:', err);
res.status(500).json({
success: false,
error: req.t('errors.server_error')
});
}
});
});
}
/**
* POST /api/auth/totp/setup - Generate TOTP setup (QR code + secret)
+108
View File
@@ -831,4 +831,112 @@ router.put('/api/settings/backup/retention', requireAuth, requirePermission('ser
}
});
// ==================== LDAP Configuration API (proxy to Go server) ===========
const betterdeskApi = require('../services/betterdeskApi');
/**
* GET /api/settings/ldap - Get LDAP configuration from Go server
*/
router.get('/api/settings/ldap', requireAuth, requirePermission('server.config'), async (req, res) => {
try {
const result = await betterdeskApi.getLDAPConfig();
if (!result.success) {
return res.status(500).json({ success: false, error: result.error || 'Failed to load LDAP config' });
}
res.json(result.data);
} catch (err) {
console.error('Get LDAP config error:', err);
res.status(500).json({ success: false, error: req.t('errors.server_error') });
}
});
/**
* PUT /api/settings/ldap - Save LDAP configuration to Go server
*/
router.put('/api/settings/ldap', requireAuth, requirePermission('server.config'), async (req, res) => {
try {
const result = await betterdeskApi.saveLDAPConfig(req.body);
if (!result.success) {
return res.status(400).json({ success: false, error: result.error || 'Failed to save LDAP config' });
}
await db.logAction(req.session?.userId, 'ldap_config_updated', 'Updated LDAP configuration', req.ip);
res.json({ success: true });
} catch (err) {
console.error('Save LDAP config error:', err);
res.status(500).json({ success: false, error: req.t('errors.server_error') });
}
});
/**
* POST /api/settings/ldap/test - Test LDAP connection via Go server
*/
router.post('/api/settings/ldap/test', requireAuth, requirePermission('server.config'), async (req, res) => {
try {
const result = await betterdeskApi.testLDAPConnection(req.body);
if (!result.success) {
return res.json({ success: false, error: result.error || 'Connection test failed' });
}
res.json(result.data);
} catch (err) {
console.error('Test LDAP connection error:', err);
res.status(500).json({ success: false, error: req.t('errors.server_error') });
}
});
// ==================== OIDC Configuration API (proxy to Go server) ===========
/**
* GET /api/settings/oidc - Get OIDC configuration from Go server
*/
router.get('/api/settings/oidc', requireAuth, requirePermission('server.config'), async (req, res) => {
try {
const result = await betterdeskApi.getOIDCConfig();
if (!result.success) {
return res.status(500).json({ success: false, error: result.error || 'Failed to load OIDC config' });
}
res.json(result.data);
} catch (err) {
console.error('Get OIDC config error:', err);
res.status(500).json({ success: false, error: req.t('errors.server_error') });
}
});
/**
* PUT /api/settings/oidc - Save OIDC configuration to Go server
*/
router.put('/api/settings/oidc', requireAuth, requirePermission('server.config'), async (req, res) => {
try {
const result = await betterdeskApi.saveOIDCConfig(req.body);
if (!result.success) {
return res.status(400).json({ success: false, error: result.error || 'Failed to save OIDC config' });
}
await db.logAction(req.session?.userId, 'oidc_config_updated', 'Updated OIDC configuration', req.ip);
res.json({ success: true });
} catch (err) {
console.error('Save OIDC config error:', err);
res.status(500).json({ success: false, error: req.t('errors.server_error') });
}
});
/**
* POST /api/settings/oidc/test - Test OIDC discovery via Go server
*/
router.post('/api/settings/oidc/test', requireAuth, requirePermission('server.config'), async (req, res) => {
try {
const result = await betterdeskApi.testOIDCDiscovery(req.body);
if (!result.success) {
return res.json({ success: false, error: result.error || 'Discovery test failed' });
}
res.json(result.data);
} catch (err) {
console.error('Test OIDC discovery error:', err);
res.status(500).json({ success: false, error: req.t('errors.server_error') });
}
});
module.exports = router;
+347 -80
View File
@@ -20,6 +20,190 @@ const DUMMY_HASH = '$2b$12$KiXeOj5vHpJRJHGMhWzadeKfRJLvJRaRHQbMGBBdkpu.jQfXAzgWS
const http = require('http');
const https = require('https');
// ---------------------------------------------------------------------------
// Go Server Health Cache (Phase A: Auth Delegation)
// ---------------------------------------------------------------------------
// Caches the Go server health status for 10 seconds to avoid hammering
// /api/health on every login attempt.
let _goHealthCache = { healthy: null, checkedAt: 0 };
const GO_HEALTH_CACHE_TTL = 10_000; // 10s
const GO_HEALTH_TIMEOUT = 2_000; // 2s connect+read timeout
/**
* Check whether the Go server is reachable.
* Result is cached for GO_HEALTH_CACHE_TTL ms.
* @returns {Promise<boolean>}
*/
async function checkGoServerHealth() {
const now = Date.now();
if (_goHealthCache.healthy !== null && now - _goHealthCache.checkedAt < GO_HEALTH_CACHE_TTL) {
return _goHealthCache.healthy;
}
const apiUrl = config.betterdeskApiUrl || config.hbbsApiUrl || 'http://localhost:21114/api';
let healthUrl;
try {
const base = new URL(apiUrl);
healthUrl = new URL('/api/health', base.origin);
} catch (_) {
_goHealthCache = { healthy: false, checkedAt: now };
return false;
}
const mod = healthUrl.protocol === 'https:' ? https : http;
return new Promise((resolve) => {
const req = mod.request(healthUrl, {
method: 'GET',
timeout: GO_HEALTH_TIMEOUT,
rejectUnauthorized: !config.allowSelfSignedCerts,
}, (res) => {
// Drain response body
res.resume();
const ok = res.statusCode >= 200 && res.statusCode < 400;
_goHealthCache = { healthy: ok, checkedAt: Date.now() };
resolve(ok);
});
req.on('error', () => {
_goHealthCache = { healthy: false, checkedAt: Date.now() };
resolve(false);
});
req.on('timeout', () => {
req.destroy();
_goHealthCache = { healthy: false, checkedAt: Date.now() };
resolve(false);
});
req.end();
});
}
/**
* Authenticate against Go server's POST /api/auth/login.
* Returns the full Go response on success, or null on failure/unreachable.
* The response shape is one of:
* { token, role, username } — credentials accepted, no 2FA
* { requires_2fa, partial_token } — 2FA required
* null — rejected or unreachable
*
* @param {string} username
* @param {string} password
* @returns {Promise<Object|null>}
*/
function authenticateViaGo(username, password) {
const apiUrl = config.betterdeskApiUrl || config.hbbsApiUrl || 'http://localhost:21114/api';
let authUrl;
try {
const base = new URL(apiUrl);
authUrl = new URL('/api/auth/login', base.origin);
} catch (_) {
return Promise.resolve(null);
}
const body = JSON.stringify({ username, password });
const mod = authUrl.protocol === 'https:' ? https : http;
const timeout = Math.min(config.betterdeskApiTimeout || 5000, 5000);
return new Promise((resolve) => {
const req = mod.request(authUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
timeout,
rejectUnauthorized: !config.allowSelfSignedCerts,
}, (res) => {
let data = '';
res.on('data', chunk => { data += chunk; });
res.on('end', () => {
try {
const parsed = JSON.parse(data);
if (res.statusCode === 200) {
resolve(parsed);
return;
}
// 401/400 = Go explicitly rejected
if (res.statusCode === 401 || res.statusCode === 400) {
resolve({ rejected: true, status: res.statusCode, error: parsed.error });
return;
}
// 429 = rate limited
if (res.statusCode === 429) {
resolve({ rejected: true, rateLimited: true, error: parsed.error });
return;
}
} catch (_) { /* JSON parse error */ }
resolve(null); // unexpected response — treat as unreachable
});
});
req.on('error', () => resolve(null));
req.on('timeout', () => { req.destroy(); resolve(null); });
req.write(body);
req.end();
});
}
/**
* Verify TOTP code via Go server's POST /api/auth/login/2fa.
* @param {string} partialToken — JWT partial token from Go login
* @param {string} code — TOTP code or recovery code
* @returns {Promise<Object|null>} — { token, role, username } or null
*/
function verifyTotpViaGo(partialToken, code) {
const apiUrl = config.betterdeskApiUrl || config.hbbsApiUrl || 'http://localhost:21114/api';
let url;
try {
const base = new URL(apiUrl);
url = new URL('/api/auth/login/2fa', base.origin);
} catch (_) {
return Promise.resolve(null);
}
const body = JSON.stringify({ partial_token: partialToken, code });
const mod = url.protocol === 'https:' ? https : http;
const timeout = Math.min(config.betterdeskApiTimeout || 5000, 5000);
return new Promise((resolve) => {
const req = mod.request(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
timeout,
rejectUnauthorized: !config.allowSelfSignedCerts,
}, (res) => {
let data = '';
res.on('data', chunk => { data += chunk; });
res.on('end', () => {
try {
const parsed = JSON.parse(data);
if (res.statusCode === 200 && parsed.token) {
resolve(parsed);
return;
}
if (res.statusCode === 401 || res.statusCode === 400) {
resolve({ rejected: true, error: parsed.error });
return;
}
if (res.statusCode === 429) {
resolve({ rejected: true, rateLimited: true, error: parsed.error });
return;
}
} catch (_) { /* JSON parse error */ }
resolve(null);
});
});
req.on('error', () => resolve(null));
req.on('timeout', () => { req.destroy(); resolve(null); });
req.write(body);
req.end();
});
}
/** Admin roles allowed to log in during emergency mode */
const EMERGENCY_ADMIN_ROLES = new Set(['admin', 'super_admin']);
// PBKDF2 parameters matching Go server's auth.HashPassword().
// Two formats are supported when verifying hashes that originated on the
// Go server (panel can fall back to those when migrating users):
@@ -161,10 +345,20 @@ function tryGoServerAuth(username, password) {
/**
* Authenticate user with username and password.
* Supports both bcrypt (Node.js native) and PBKDF2 (Go server) hash formats.
* When a PBKDF2 hash is verified successfully, it is auto-migrated to bcrypt
* so subsequent logins do not need the PBKDF2 code path.
* Returns user object with totpRequired flag if 2FA is enabled.
*
* Auth Delegation (Phase A):
* 1. Check Go server health (cached 10s)
* 2. If Go is healthy → delegate auth to Go POST /api/auth/login
* - Go accepts → sync local admin hash for emergency fallback, return user
* - Go rejects (401) → reject login
* - Go returns requires_2fa → return totpRequired + goPartialToken
* 3. If Go is unreachable → EMERGENCY MODE
* - Only admin roles may log in locally
* - Verify against local DB (auth.db or shared PostgreSQL)
* - Return user with emergencyMode flag
*
* Returns user object with totpRequired flag if 2FA is enabled,
* or emergencyMode flag if Go server is unreachable.
*/
async function authenticate(username, password) {
// Safeguard: reject empty username immediately (Issue #104)
@@ -172,101 +366,170 @@ async function authenticate(username, password) {
console.log(`[AUTH] Rejected authenticate() with empty/invalid username: ${JSON.stringify(username)}`);
return null;
}
const user = await db.getUserByUsername(username);
if (!user) {
// Timing-safe: do a real hash comparison to prevent user enumeration
await bcrypt.compare(password, DUMMY_HASH);
// SECURITY (audit fix H-01, 2026-04-10): auto-creation of a local
// user when the Go server accepts credentials is OPT-IN only.
// A compromised Go server would otherwise automatically provision
// admin accounts in the panel. Enable explicitly via
// BETTERDESK_AUTH_AUTOCREATE=true when synchronizing identities
// with a trusted central auth (e.g., first-time bootstrap).
const autoCreateEnabled = process.env.BETTERDESK_AUTH_AUTOCREATE === 'true';
if (autoCreateEnabled) {
const goResult = await tryGoServerAuth(username, password);
if (goResult) {
console.log(`[AUTH] Go server accepted credentials for '${username}' — creating local user (BETTERDESK_AUTH_AUTOCREATE=true)`);
const bcryptHash = await hashPassword(password);
await db.createUser(username, bcryptHash, goResult.role || 'admin');
const created = await db.getUserByUsername(username);
if (created) {
await db.updateLastLogin(created.id);
return {
id: created.id,
username: created.username,
role: created.role,
preferred_language: created.preferred_language || null,
totpRequired: false,
};
// ---------------------------------------------------------------
// Step 1: Try Go server (source of truth)
// ---------------------------------------------------------------
const goHealthy = await checkGoServerHealth();
if (goHealthy) {
const goResult = await authenticateViaGo(username, password);
if (goResult && !goResult.rejected) {
// --- 2FA required ---
if (goResult.requires_2fa && goResult.partial_token) {
console.log(`[AUTH] Go server requires 2FA for '${username}'`);
// We need a local user ID for session storage.
// Ensure the user exists locally (auto-create from Go data if needed).
const localUser = await ensureLocalUserFromGo(username, password, 'admin');
return {
id: localUser ? localUser.id : 0,
username: username,
role: localUser ? localUser.role : 'admin',
preferred_language: localUser ? (localUser.preferred_language || null) : null,
totpRequired: true,
goPartialToken: goResult.partial_token,
};
}
// --- Credentials accepted (no 2FA) ---
if (goResult.token && goResult.role) {
console.log(`[AUTH] Go server accepted credentials for '${username}' (role: ${goResult.role})`);
// Block pro-only accounts from web panel login early
if (goResult.role === 'pro') {
return null;
}
// Ensure user exists locally and sync hash for emergency fallback
const localUser = await ensureLocalUserFromGo(username, password, goResult.role);
// Update last login
if (localUser) {
try { await db.updateLastLogin(localUser.id); } catch (_) { /* ignore */ }
}
return {
id: localUser ? localUser.id : 0,
username: goResult.username || username,
role: goResult.role,
preferred_language: localUser ? (localUser.preferred_language || null) : null,
totpRequired: false,
goToken: goResult.token,
};
}
}
console.log(`[AUTH] Login failed: user '${username}' not found in database`);
return null;
}
// Diagnostic: log hash format to help debug password issues
const hashType = isPBKDF2Hash(user.password_hash) ? 'PBKDF2'
: (user.password_hash && user.password_hash.startsWith('$2')) ? 'bcrypt'
: 'unknown';
console.log(`[AUTH] Verifying password for '${username}' (hash type: ${hashType}, length: ${(user.password_hash || '').length})`);
const { valid, needsMigration } = await verifyPasswordEx(password, user.password_hash);
if (!valid) {
// Fallback: try Go server auth — password may have been changed on Go side
const goResult = await tryGoServerAuth(username, password);
if (goResult) {
console.log(`[AUTH] Go server accepted password for '${username}' — syncing local hash`);
const bcryptHash = await hashPassword(password);
await db.updateUserPassword(user.id, bcryptHash);
// Fall through to TOTP check and normal success path
} else {
console.log(`[AUTH] Login failed: password mismatch for '${username}' (hash type: ${hashType})`);
// --- Go explicitly rejected ---
if (goResult && goResult.rejected) {
if (goResult.rateLimited) {
console.log(`[AUTH] Go server rate-limited login for '${username}'`);
} else {
console.log(`[AUTH] Go server rejected credentials for '${username}'`);
}
// Timing-safe: do a real hash comparison to prevent user enumeration
await bcrypt.compare(password, DUMMY_HASH);
return null;
}
} else if (valid) {
console.log(`[AUTH] Login successful for '${username}'`);
// --- Go returned unexpected response (null) — treat as unreachable ---
console.warn(`[AUTH] Go server returned unexpected response for '${username}' — falling through to emergency mode`);
// Invalidate health cache so next attempt re-checks
_goHealthCache = { healthy: null, checkedAt: 0 };
}
// Auto-migrate PBKDF2 hash to bcrypt for future logins
if (valid && needsMigration) {
try {
const bcryptHash = await hashPassword(password);
await db.updateUserPassword(user.id, bcryptHash);
console.log(`[AUTH] Migrated password hash from PBKDF2 to bcrypt for user: ${username}`);
} catch (err) {
console.warn(`[AUTH] Failed to migrate password hash for ${username}:`, err.message);
}
// ---------------------------------------------------------------
// Step 2: Emergency mode — Go server is unreachable
// ---------------------------------------------------------------
console.warn(`[AUTH] ⚠ EMERGENCY MODE: Go server unreachable — attempting local auth for '${username}'`);
const user = await db.getUserByUsername(username);
if (!user) {
await bcrypt.compare(password, DUMMY_HASH);
console.log(`[AUTH] Emergency mode: user '${username}' not found locally`);
return null;
}
// Check if TOTP is enabled
if (user.totp_enabled) {
return {
id: user.id,
username: user.username,
role: user.role,
preferred_language: user.preferred_language || null,
totpRequired: true
};
// Only admin roles allowed in emergency mode
if (!EMERGENCY_ADMIN_ROLES.has(user.role)) {
console.warn(`[AUTH] Emergency mode: rejected non-admin user '${username}' (role: ${user.role})`);
await bcrypt.compare(password, DUMMY_HASH);
return null;
}
// Update last login
// Verify against local hash
const { valid } = await verifyPasswordEx(password, user.password_hash);
if (!valid) {
console.log(`[AUTH] Emergency mode: password mismatch for '${username}'`);
return null;
}
console.log(`[AUTH] Emergency mode: admin '${username}' authenticated locally`);
// Skip TOTP in emergency mode — admin needs access to fix the Go server
await db.updateLastLogin(user.id);
return {
id: user.id,
username: user.username,
role: user.role,
preferred_language: user.preferred_language || null,
totpRequired: false
totpRequired: false,
emergencyMode: true,
};
}
/**
* Ensure a user record exists in the local database for session storage.
* If the user doesn't exist locally, create it. If it exists, update the
* password hash (for emergency fallback) and role.
*
* @param {string} username
* @param {string} password — plaintext (available only during login)
* @param {string} role
* @returns {Promise<Object|null>} — local user record
*/
async function ensureLocalUserFromGo(username, password, role) {
let localUser = await db.getUserByUsername(username);
// Sync password hash for emergency fallback (only for admin roles)
const shouldSyncHash = EMERGENCY_ADMIN_ROLES.has(role);
if (!localUser) {
// Auto-create local user from Go server data
try {
const bcryptHash = await hashPassword(password);
await db.createUser(username, bcryptHash, role);
localUser = await db.getUserByUsername(username);
if (localUser) {
console.log(`[AUTH] Auto-created local user '${username}' (role: ${role}) for session storage`);
}
} catch (err) {
console.warn(`[AUTH] Failed to auto-create local user '${username}': ${err.message}`);
}
} else if (shouldSyncHash) {
// Update local hash so emergency fallback always has current password
try {
const bcryptHash = await hashPassword(password);
await db.updateUserPassword(localUser.id, bcryptHash);
} catch (err) {
console.warn(`[AUTH] Failed to sync local hash for '${username}': ${err.message}`);
}
// Sync role if changed on Go side
if (localUser.role !== role) {
try {
await db.updateUserRole(localUser.id, role);
localUser.role = role;
} catch (_) { /* updateUserRole may not exist in all adapters */ }
}
}
return localUser;
}
/**
* Check if the installation scripts requested a forced password update.
* Two mechanisms: sentinel file (.force_password_update) or env var FORCE_PASSWORD_UPDATE.
@@ -807,6 +1070,10 @@ module.exports = {
ensureDefaultAdmin,
changePassword,
validatePasswordStrength,
// Go server delegation (Phase A)
checkGoServerHealth,
authenticateViaGo,
verifyTotpViaGo,
// TOTP
generateTotpSetup,
verifyAndEnableTotp,
+115
View File
@@ -823,6 +823,111 @@ async function deleteRolePermission(role, permission) {
}
}
// ========================== LDAP Configuration =============================
/**
* GET /api/auth/ldap/config — Get LDAP configuration (password masked)
*/
async function getLDAPConfig() {
try {
const { data } = await apiClient.get('/auth/ldap/config');
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* PUT /api/auth/ldap/config — Save LDAP configuration
*/
async function saveLDAPConfig(config) {
try {
const { data } = await apiClient.put('/auth/ldap/config', config);
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* POST /api/auth/ldap/test — Test LDAP connection
*/
async function testLDAPConnection(config) {
try {
const { data } = await apiClient.post('/auth/ldap/test', config);
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
// ========================== OIDC Configuration =============================
/**
* GET /api/auth/oidc/config — Get OIDC configuration (secret masked)
*/
async function getOIDCConfig() {
try {
const { data } = await apiClient.get('/auth/oidc/config');
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* PUT /api/auth/oidc/config — Save OIDC configuration
*/
async function saveOIDCConfig(config) {
try {
const { data } = await apiClient.put('/auth/oidc/config', config);
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* POST /api/auth/oidc/test — Test OIDC discovery
*/
async function testOIDCDiscovery(config) {
try {
const { data } = await apiClient.post('/auth/oidc/test', config);
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* GET /api/auth/oidc/status — Check if OIDC is enabled (public)
*/
async function getOIDCStatus() {
try {
const { data } = await apiClient.get('/auth/oidc/status');
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
/**
* POST /api/auth/oidc/exchange — Exchange a one-time OIDC auth code for
* the JWT + verified user identity. Server-to-server only. The code itself
* is the credential, so this endpoint is public on the Go side. Codes are
* single-use and expire after 60 seconds.
*
* Returns { success, data: { token, username, role, return_url } } on success.
*/
async function exchangeOIDCCode(code) {
try {
const { data } = await apiClient.post('/auth/oidc/exchange', { code });
return wrap(data);
} catch (e) {
return { success: false, error: e.message };
}
}
module.exports = {
// Health / Stats
getHealth,
@@ -892,6 +997,16 @@ module.exports = {
listRolePermissionOverrides,
setRolePermission,
deleteRolePermission,
// LDAP Configuration
getLDAPConfig,
saveLDAPConfig,
testLDAPConnection,
// OIDC Configuration
getOIDCConfig,
saveOIDCConfig,
testOIDCDiscovery,
getOIDCStatus,
exchangeOIDCCode,
// Helpers
normalisePeer,
// Raw axios client (for services that need direct API access)
+11
View File
@@ -98,6 +98,17 @@
</button>
</form>
<!-- SSO / OIDC Login (shown dynamically when OIDC is enabled) -->
<div class="login-sso hidden" id="sso-section">
<div class="login-divider">
<span><%= _('auth.or') %></span>
</div>
<a href="/api/auth/oidc/authorize" class="btn btn-secondary login-btn sso-btn" id="sso-btn">
<span class="material-icons">key</span>
<span id="sso-btn-text"><%= _('auth.login_sso') %></span>
</a>
</div>
<!-- TOTP Verification Step (hidden by default) -->
<form class="login-form hidden" id="totp-form">
<div class="totp-header">
+6
View File
@@ -1,4 +1,10 @@
<!-- Top Navigation Bar -->
<% if (typeof emergencyMode !== 'undefined' && emergencyMode) { %>
<div class="emergency-banner" role="alert">
<span class="material-icons">warning</span>
<span><%= _('auth.emergency_mode_banner') %></span>
</div>
<% } %>
<header class="navbar">
<div class="navbar-left">
<!-- Mobile menu button -->
+321 -1
View File
@@ -20,7 +20,11 @@
<span class="material-icons">palette</span>
${_('settings.tab_branding')}
</button>
${canServerConfig ? `<button class="settings-tab" data-tab="backup">
${canServerConfig ? `<button class="settings-tab" data-tab="auth">
<span class="material-icons">admin_panel_settings</span>
${_('settings.tab_auth')}
</button>
<button class="settings-tab" data-tab="backup">
<span class="material-icons">backup</span>
${_('settings.tab_backup')}
</button>
@@ -577,6 +581,322 @@
</div>
</div>
<!-- Authentication Tab (server.config permission required) -->
<div class="settings-tab-content" id="tab-auth" ${canServerConfig ? '' : 'style="display:none"'}>
<div class="settings-sections">
<!-- LDAP / Active Directory -->
<div class="settings-section">
<div class="settings-section-header">
<h2 class="settings-section-title">
<span class="material-icons">domain</span>
${_('settings.ldap_title')}
</h2>
<p class="settings-section-desc">${_('settings.ldap_desc')}</p>
</div>
<div class="settings-section-body">
<form class="settings-form" id="ldap-form" style="max-width: 700px;">
<!-- Enable toggle -->
<div class="form-group" style="display: flex; align-items: center; gap: 12px;">
<input type="checkbox" id="ldap-enabled" class="toggle-switch">
<div>
<label class="form-label" for="ldap-enabled" style="margin-bottom: 0; cursor: pointer;">${_('settings.ldap_enabled')}</label>
<small class="form-hint" style="display: block; margin-top: 2px;">${_('settings.ldap_enabled_hint')}</small>
</div>
</div>
<div id="ldap-config-fields">
<!-- Server connection -->
<h3 class="settings-subsection-title">${_('settings.ldap_connection')}</h3>
<div class="form-row">
<div class="form-group" style="flex: 2;">
<label class="form-label" for="ldap-host">${_('settings.ldap_host')}</label>
<input type="text" id="ldap-host" class="form-input" placeholder="ldap.example.com">
</div>
<div class="form-group" style="flex: 1;">
<label class="form-label" for="ldap-port">${_('settings.ldap_port')}</label>
<input type="number" id="ldap-port" class="form-input" placeholder="389" min="1" max="65535">
</div>
</div>
<div class="form-group" style="display: flex; gap: 24px; flex-wrap: wrap;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="ldap-use-tls" class="toggle-switch">
<span>${_('settings.ldap_use_tls')}</span>
</label>
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="ldap-start-tls" class="toggle-switch">
<span>${_('settings.ldap_start_tls')}</span>
</label>
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="ldap-skip-tls-verify" class="toggle-switch">
<span>${_('settings.ldap_skip_verify')}</span>
</label>
</div>
<div class="form-group">
<label class="form-label" for="ldap-conn-timeout">${_('settings.ldap_timeout')}</label>
<input type="number" id="ldap-conn-timeout" class="form-input" placeholder="10" min="1" max="120" style="max-width: 120px;">
<small class="form-hint">${_('settings.ldap_timeout_hint')}</small>
</div>
<!-- Bind mode -->
<h3 class="settings-subsection-title">${_('settings.ldap_bind_mode')}</h3>
<div class="form-group" style="display: flex; align-items: center; gap: 12px;">
<input type="checkbox" id="ldap-direct-bind" class="toggle-switch">
<div>
<label class="form-label" for="ldap-direct-bind" style="margin-bottom: 0; cursor: pointer;">${_('settings.ldap_direct_bind')}</label>
<small class="form-hint" style="display: block; margin-top: 2px;">${_('settings.ldap_direct_bind_hint')}</small>
</div>
</div>
<!-- Bind+Search fields (shown when direct_bind is off) -->
<div id="ldap-bind-search-fields">
<div class="form-group">
<label class="form-label" for="ldap-bind-dn">${_('settings.ldap_bind_dn')}</label>
<input type="text" id="ldap-bind-dn" class="form-input" placeholder="cn=readonly,dc=example,dc=com">
<small class="form-hint">${_('settings.ldap_bind_dn_hint')}</small>
</div>
<div class="form-group">
<label class="form-label" for="ldap-bind-password">${_('settings.ldap_bind_password')}</label>
<input type="password" id="ldap-bind-password" class="form-input" placeholder="••••••••">
</div>
<div class="form-group">
<label class="form-label" for="ldap-base-dn">${_('settings.ldap_base_dn')}</label>
<input type="text" id="ldap-base-dn" class="form-input" placeholder="dc=example,dc=com">
<small class="form-hint">${_('settings.ldap_base_dn_hint')}</small>
</div>
<div class="form-group">
<label class="form-label" for="ldap-user-filter">${_('settings.ldap_user_filter')}</label>
<input type="text" id="ldap-user-filter" class="form-input" placeholder="(sAMAccountName={{username}})">
<small class="form-hint">${_('settings.ldap_user_filter_hint')}</small>
</div>
</div>
<!-- Direct bind fields (shown when direct_bind is on) -->
<div id="ldap-direct-bind-fields" style="display: none;">
<div class="form-group">
<label class="form-label" for="ldap-direct-bind-dn">${_('settings.ldap_direct_bind_dn')}</label>
<input type="text" id="ldap-direct-bind-dn" class="form-input" placeholder="uid={{username}},ou=users,dc=example,dc=com">
<small class="form-hint">${_('settings.ldap_direct_bind_dn_hint')}</small>
</div>
</div>
<!-- User attributes -->
<h3 class="settings-subsection-title">${_('settings.ldap_user_attrs')}</h3>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="ldap-user-attr-id">${_('settings.ldap_attr_id')}</label>
<input type="text" id="ldap-user-attr-id" class="form-input" placeholder="sAMAccountName">
</div>
<div class="form-group">
<label class="form-label" for="ldap-user-attr-email">${_('settings.ldap_attr_email')}</label>
<input type="text" id="ldap-user-attr-email" class="form-input" placeholder="mail">
</div>
<div class="form-group">
<label class="form-label" for="ldap-user-attr-name">${_('settings.ldap_attr_name')}</label>
<input type="text" id="ldap-user-attr-name" class="form-input" placeholder="displayName">
</div>
</div>
<!-- Group mapping -->
<h3 class="settings-subsection-title">${_('settings.ldap_group_mapping')}</h3>
<div class="form-group">
<label class="form-label" for="ldap-group-base-dn">${_('settings.ldap_group_base_dn')}</label>
<input type="text" id="ldap-group-base-dn" class="form-input" placeholder="ou=groups,dc=example,dc=com">
<small class="form-hint">${_('settings.ldap_group_base_dn_hint')}</small>
</div>
<div class="form-group">
<label class="form-label" for="ldap-group-filter">${_('settings.ldap_group_filter')}</label>
<input type="text" id="ldap-group-filter" class="form-input" placeholder="(member={{dn}})">
</div>
<div class="form-group">
<label class="form-label" for="ldap-group-attr-name">${_('settings.ldap_group_attr_name')}</label>
<input type="text" id="ldap-group-attr-name" class="form-input" placeholder="cn">
</div>
<div class="form-group">
<label class="form-label" for="ldap-default-role">${_('settings.ldap_default_role')}</label>
<select id="ldap-default-role" class="form-input" style="max-width: 300px;">
<option value="viewer">${_('settings.ldap_role_viewer')}</option>
<option value="operator">${_('settings.ldap_role_operator')}</option>
<option value="admin">${_('settings.ldap_role_admin')}</option>
</select>
<small class="form-hint">${_('settings.ldap_default_role_hint')}</small>
</div>
<div class="form-group">
<label class="form-label" for="ldap-group-role-map">${_('settings.ldap_group_role_map')}</label>
<textarea id="ldap-group-role-map" class="form-input form-textarea" rows="4"
placeholder="CN=BetterDesk Admins,OU=Groups,DC=example,DC=com=admin|CN=Operators,OU=Groups,DC=example,DC=com=operator"></textarea>
<small class="form-hint">${_('settings.ldap_group_role_map_hint')}</small>
</div>
</div>
<div class="form-actions" style="display: flex; gap: 12px; margin-top: 16px;">
<button type="button" class="btn btn-secondary" id="ldap-test-btn">
<span class="material-icons">cable</span>
${_('settings.ldap_test_connection')}
</button>
<button type="submit" class="btn btn-primary">
<span class="material-icons">save</span>
${_('settings.ldap_save')}
</button>
</div>
<div id="ldap-test-result" style="margin-top: 12px;"></div>
</form>
</div>
</div>
<!-- OIDC / OAuth2 / SSO -->
<div class="settings-section">
<div class="settings-section-header">
<h2 class="settings-section-title">
<span class="material-icons">key</span>
${_('settings.oidc_title')}
</h2>
<p class="settings-section-desc">${_('settings.oidc_desc')}</p>
</div>
<div class="settings-section-body">
<form class="settings-form" id="oidc-form" style="max-width: 700px;">
<!-- Enable toggle -->
<div class="form-group" style="display: flex; align-items: center; gap: 12px;">
<input type="checkbox" id="oidc-enabled" class="toggle-switch">
<div>
<label class="form-label" for="oidc-enabled" style="margin-bottom: 0; cursor: pointer;">${_('settings.oidc_enabled')}</label>
<small class="form-hint" style="display: block; margin-top: 2px;">${_('settings.oidc_enabled_hint')}</small>
</div>
</div>
<div id="oidc-config-fields">
<!-- Provider settings -->
<h3 class="settings-subsection-title">${_('settings.oidc_provider')}</h3>
<div class="form-group">
<label class="form-label" for="oidc-display-name">${_('settings.oidc_display_name')}</label>
<input type="text" id="oidc-display-name" class="form-input" placeholder="Azure AD / Okta / Google" style="max-width: 400px;">
<small class="form-hint">${_('settings.oidc_display_name_hint')}</small>
</div>
<div class="form-group">
<label class="form-label" for="oidc-issuer-url">${_('settings.oidc_issuer_url')}</label>
<input type="url" id="oidc-issuer-url" class="form-input" placeholder="https://login.microsoftonline.com/{tenant}/v2.0">
<small class="form-hint">${_('settings.oidc_issuer_url_hint')}</small>
</div>
<div class="form-row">
<div class="form-group" style="flex: 2;">
<label class="form-label" for="oidc-client-id">${_('settings.oidc_client_id')}</label>
<input type="text" id="oidc-client-id" class="form-input" placeholder="your-client-id">
</div>
<div class="form-group" style="flex: 2;">
<label class="form-label" for="oidc-client-secret">${_('settings.oidc_client_secret')}</label>
<input type="password" id="oidc-client-secret" class="form-input" placeholder="••••••••">
</div>
</div>
<div class="form-group">
<label class="form-label" for="oidc-redirect-url">${_('settings.oidc_redirect_url')}</label>
<input type="url" id="oidc-redirect-url" class="form-input" placeholder="http://your-server:21114/api/auth/oidc/callback">
<small class="form-hint">${_('settings.oidc_redirect_url_hint')}</small>
</div>
<div class="form-group">
<label class="form-label" for="oidc-scopes">${_('settings.oidc_scopes')}</label>
<input type="text" id="oidc-scopes" class="form-input" placeholder="openid profile email" style="max-width: 500px;">
</div>
<div class="form-group" style="display: flex; gap: 24px; flex-wrap: wrap;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="oidc-use-pkce" class="toggle-switch">
<span>${_('settings.oidc_use_pkce')}</span>
</label>
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="oidc-auto-discovery" class="toggle-switch" checked>
<span>${_('settings.oidc_auto_discovery')}</span>
</label>
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="oidc-allow-signup" class="toggle-switch" checked>
<span>${_('settings.oidc_allow_signup')}</span>
</label>
</div>
<!-- Manual endpoints (shown when auto_discovery is off) -->
<div id="oidc-manual-endpoints" style="display: none;">
<h3 class="settings-subsection-title">${_('settings.oidc_manual_endpoints')}</h3>
<div class="form-group">
<label class="form-label" for="oidc-authorization-url">${_('settings.oidc_authorization_url')}</label>
<input type="url" id="oidc-authorization-url" class="form-input" placeholder="https://idp.example.com/authorize">
</div>
<div class="form-group">
<label class="form-label" for="oidc-token-url">${_('settings.oidc_token_url')}</label>
<input type="url" id="oidc-token-url" class="form-input" placeholder="https://idp.example.com/token">
</div>
<div class="form-group">
<label class="form-label" for="oidc-userinfo-url">${_('settings.oidc_userinfo_url')}</label>
<input type="url" id="oidc-userinfo-url" class="form-input" placeholder="https://idp.example.com/userinfo">
</div>
</div>
<!-- Claim mapping -->
<h3 class="settings-subsection-title">${_('settings.oidc_claim_mapping')}</h3>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="oidc-claim-username">${_('settings.oidc_claim_username')}</label>
<input type="text" id="oidc-claim-username" class="form-input" placeholder="preferred_username">
</div>
<div class="form-group">
<label class="form-label" for="oidc-claim-email">${_('settings.oidc_claim_email')}</label>
<input type="text" id="oidc-claim-email" class="form-input" placeholder="email">
</div>
<div class="form-group">
<label class="form-label" for="oidc-claim-name">${_('settings.oidc_claim_name')}</label>
<input type="text" id="oidc-claim-name" class="form-input" placeholder="name">
</div>
</div>
<div class="form-group">
<label class="form-label" for="oidc-claim-groups">${_('settings.oidc_claim_groups')}</label>
<input type="text" id="oidc-claim-groups" class="form-input" placeholder="groups" style="max-width: 300px;">
</div>
<!-- Role mapping -->
<h3 class="settings-subsection-title">${_('settings.oidc_role_mapping')}</h3>
<div class="form-group">
<label class="form-label" for="oidc-default-role">${_('settings.oidc_default_role')}</label>
<select id="oidc-default-role" class="form-input" style="max-width: 300px;">
<option value="viewer">${_('settings.ldap_role_viewer')}</option>
<option value="operator">${_('settings.ldap_role_operator')}</option>
<option value="admin">${_('settings.ldap_role_admin')}</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="oidc-group-role-map">${_('settings.oidc_group_role_map')}</label>
<textarea id="oidc-group-role-map" class="form-input form-textarea" rows="3"
placeholder="betterdesk-admins=admin&#10;betterdesk-operators=operator"></textarea>
<small class="form-hint">${_('settings.oidc_group_role_map_hint')}</small>
</div>
</div>
<div class="form-actions" style="display: flex; gap: 12px; margin-top: 16px;">
<button type="button" class="btn btn-secondary" id="oidc-test-btn">
<span class="material-icons">travel_explore</span>
${_('settings.oidc_test_discovery')}
</button>
<button type="submit" class="btn btn-primary">
<span class="material-icons">save</span>
${_('settings.oidc_save')}
</button>
</div>
<div id="oidc-test-result" style="margin-top: 12px;"></div>
</form>
</div>
</div>
</div>
</div>
<!-- Backup & Restore Tab (server.config permission required) -->
<div class="settings-tab-content" id="tab-backup" ${canServerConfig ? '' : 'style="display:none"'}>
<div class="settings-sections">