mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-10-26 22:37:07 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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": "更新您的账户密码",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 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">
|
||||
|
||||
Reference in New Issue
Block a user