diff --git a/DnsServerCore/Auth/AuthManager.cs b/DnsServerCore/Auth/AuthManager.cs index 7b7be8f6..b160b018 100644 --- a/DnsServerCore/Auth/AuthManager.cs +++ b/DnsServerCore/Auth/AuthManager.cs @@ -840,6 +840,9 @@ public async Task CreateSessionAsync(UserSessionType type, string t { User user = await AuthenticateUserAsync(username, password, totp, remoteAddress); + if (user.IsSsoUser && (type == UserSessionType.Standard)) + throw new DnsWebServiceException("SSO users must login via SSO provider."); + UserSession session = new UserSession(type, tokenName, user, remoteAddress, userAgent); if (!_sessions.TryAdd(session.Token, session)) @@ -869,6 +872,21 @@ public UserSession CreateApiToken(string tokenName, string username, IPAddress r return session; } + public UserSession CreateSsoSession(User user, IPAddress remoteAddress, string userAgent) + { + if (user.Disabled) + throw new DnsWebServiceException("Account is suspended."); + + UserSession session = new UserSession(UserSessionType.Standard, null, user, remoteAddress, userAgent); + + if (!_sessions.TryAdd(session.Token, session)) + throw new DnsWebServiceException("Error while creating session. Please try again."); + + user.LoggedInFrom(remoteAddress); + + return session; + } + public UserSession DeleteSession(string token) { if (_sessions.TryRemove(token, out UserSession session)) diff --git a/DnsServerCore/Auth/User.cs b/DnsServerCore/Auth/User.cs index 114b4474..53a4875a 100644 --- a/DnsServerCore/Auth/User.cs +++ b/DnsServerCore/Auth/User.cs @@ -52,6 +52,7 @@ class User : IComparable AuthenticatorKeyUri _totpKeyUri; bool _totpEnabled; bool _disabled; + bool _isSsoUser; // New field for SSO tracking int _sessionTimeoutSeconds = 30 * 60; //default 30 mins DateTime _previousSessionLoggedOn; @@ -85,6 +86,7 @@ public User(BinaryReader bR, IReadOnlyDictionary groups) { case 1: case 2: + case 3: // Version 3 adds IsSsoUser _displayName = bR.ReadShortString(); _username = bR.ReadShortString(); _passwordHashType = (UserPasswordHashType)bR.ReadByte(); @@ -102,6 +104,12 @@ public User(BinaryReader bR, IReadOnlyDictionary groups) } _disabled = bR.ReadBoolean(); + + if (version >= 3) + { + _isSsoUser = bR.ReadBoolean(); + } + _sessionTimeoutSeconds = bR.ReadInt32(); _previousSessionLoggedOn = bR.ReadDateTime(); @@ -259,7 +267,7 @@ public bool IsMemberOfGroup(Group group) public void WriteTo(BinaryWriter bW) { - bW.Write((byte)2); + bW.Write((byte)3); // Bump version to 3 bW.WriteShortString(_displayName); bW.WriteShortString(_username); bW.Write((byte)_passwordHashType); @@ -274,6 +282,7 @@ public void WriteTo(BinaryWriter bW) bW.Write(_totpEnabled); bW.Write(_disabled); + bW.Write(_isSsoUser); // Write IsSsoUser bW.Write(_sessionTimeoutSeconds); bW.Write(_previousSessionLoggedOn); @@ -417,6 +426,12 @@ public IPAddress RecentSessionRemoteAddress public ICollection MemberOfGroups { get { return _memberOfGroups.Values; } } + public bool IsSsoUser + { + get { return _isSsoUser; } + set { _isSsoUser = value; } + } + #endregion } } diff --git a/DnsServerCore/DnsServerCore.csproj b/DnsServerCore/DnsServerCore.csproj index 9be1310b..ec8b10c7 100644 --- a/DnsServerCore/DnsServerCore.csproj +++ b/DnsServerCore/DnsServerCore.csproj @@ -1,4 +1,4 @@ - + net9.0 @@ -46,6 +46,7 @@ + diff --git a/DnsServerCore/DnsWebService.cs b/DnsServerCore/DnsWebService.cs index c1185425..a029f8cf 100644 --- a/DnsServerCore/DnsWebService.cs +++ b/DnsServerCore/DnsWebService.cs @@ -24,6 +24,11 @@ You should have received a copy of the GNU General Public License using DnsServerCore.Dns.Applications; using DnsServerCore.Dns.Dnssec; using DnsServerCore.Dns.Zones; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Diagnostics; @@ -42,6 +47,7 @@ You should have received a copy of the GNU General Public License using System.IO.Compression; using System.Linq; using System.Net; +using System.Net.Http; using System.Net.Quic; using System.Net.Security; using System.Reflection; @@ -102,6 +108,19 @@ public sealed partial class DnsWebService : IAsyncDisposable, IDisposable string _webServiceTlsCertificatePath; string _webServiceTlsCertificatePassword; string _webServiceRealIpHeader = "X-Real-IP"; + + //SSO + internal bool _webServiceSsoEnabled; + internal string _webServiceSsoAuthority; + internal string _webServiceSsoClientId; + internal string _webServiceSsoClientSecret; + internal string _webServiceSsoScopes; + internal string _webServiceSsoRedirectUri; + internal string _webServiceSsoMetadataAddress; + internal bool _webServiceSsoAllowHttp; + internal bool _webServiceSsoAllowSignup; + internal string _webServiceSsoGroupMappings; + internal bool _webServiceSsoVerboseLogging; Timer _tlsCertificateUpdateTimer; const int TLS_CERTIFICATE_UPDATE_TIMER_INITIAL_INTERVAL = 60000; @@ -438,7 +457,7 @@ private void ReadConfigFrom(Stream s) throw new InvalidDataException("Web Service config file format is invalid."); int version = bR.ReadByte(); - if (version > 1) + if (version > 2) throw new InvalidDataException("Web Service config version not supported."); _webServiceHttpPort = bR.ReadInt32(); @@ -499,14 +518,33 @@ private void ReadConfigFrom(Stream s) CheckAndLoadSelfSignedCertificate(false, false); _webServiceRealIpHeader = bR.ReadShortString(); + + if (version >= 2) + { + _webServiceSsoAuthority = bR.ReadShortString(); + _webServiceSsoClientId = bR.ReadShortString(); + _webServiceSsoClientSecret = bR.ReadShortString(); + _webServiceSsoScopes = bR.ReadShortString(); + _ = bR.ReadShortString(); + _webServiceSsoMetadataAddress = bR.ReadShortString(); + _webServiceSsoAllowHttp = bR.ReadBoolean(); + _webServiceSsoAllowSignup = bR.ReadBoolean(); + _webServiceSsoGroupMappings = bR.ReadShortString(); + _webServiceSsoVerboseLogging = bR.ReadBoolean(); + + if (bR.BaseStream.Position < bR.BaseStream.Length) + { + _webServiceSsoRedirectUri = bR.ReadShortString(); + } + } } private void WriteConfigTo(Stream s) { BinaryWriter bW = new BinaryWriter(s); - bW.Write(Encoding.ASCII.GetBytes("WC")); //format - bW.Write((byte)1); //version + bW.Write(Encoding.ASCII.GetBytes("WC")); + bW.Write((byte)2); bW.Write(_webServiceHttpPort); bW.Write(_webServiceTlsPort); @@ -534,6 +572,22 @@ private void WriteConfigTo(Stream s) bW.WriteShortString(_webServiceTlsCertificatePassword); bW.WriteShortString(_webServiceRealIpHeader); + + //version 2 - SSO settings + bW.WriteShortString(_webServiceSsoAuthority ?? ""); + bW.WriteShortString(_webServiceSsoClientId ?? ""); + // SECURITY NOTE: Client secret is stored in plain text (same as TLS cert password). + // Ensure webservice.config has restricted file permissions (chmod 600 on Linux). + // For production, consider using environment variable DNS_SERVER_SSO_CLIENT_SECRET. + bW.WriteShortString(_webServiceSsoClientSecret ?? ""); + bW.WriteShortString(_webServiceSsoScopes ?? ""); + bW.WriteShortString(""); + bW.WriteShortString(_webServiceSsoMetadataAddress ?? ""); + bW.Write(_webServiceSsoAllowHttp); + bW.Write(_webServiceSsoAllowSignup); + bW.WriteShortString(_webServiceSsoGroupMappings ?? ""); + bW.Write(_webServiceSsoVerboseLogging); + bW.WriteShortString(_webServiceSsoRedirectUri ?? ""); } #endregion @@ -1535,6 +1589,303 @@ private async Task StartWebServiceAsync(bool httpOnlyMode) UseActivePolling = true, UsePollingFileWatcher = true }; +#if DEBUG + Microsoft.IdentityModel.Logging.IdentityModelEventSource.ShowPII = true; +#else + Microsoft.IdentityModel.Logging.IdentityModelEventSource.ShowPII = false; +#endif + + // Load cookie secret with auto-generation for seamless upgrades + string cookieSecret = Environment.GetEnvironmentVariable("DNS_SERVER_COOKIE_SECRET"); + string cookieSecretFile = Environment.GetEnvironmentVariable("DNS_SERVER_COOKIE_SECRET_FILE"); + string cookieSecretFilePath = Path.Combine(_configFolder, "cookie.secret"); + + if (!string.IsNullOrEmpty(cookieSecretFile) && File.Exists(cookieSecretFile)) + { + cookieSecret = File.ReadAllText(cookieSecretFile).Trim(); + _log.Write($"Loaded DNS_SERVER_COOKIE_SECRET from file: {cookieSecretFile}"); + } + + // Auto-generate and persist if not provided + if (string.IsNullOrEmpty(cookieSecret)) + { + if (File.Exists(cookieSecretFilePath)) + { + // Load previously generated secret + cookieSecret = File.ReadAllText(cookieSecretFilePath).Trim(); + _log.Write("Loaded auto-generated DNS_SERVER_COOKIE_SECRET from config folder"); + } + else + { + // Generate new secret + using (var rng = System.Security.Cryptography.RandomNumberGenerator.Create()) + { + byte[] secretBytes = new byte[32]; + rng.GetBytes(secretBytes); + cookieSecret = Convert.ToBase64String(secretBytes); + } + + // Persist for future restarts + try + { + File.WriteAllText(cookieSecretFilePath, cookieSecret); + _log.Write("========================================"); + _log.Write("AUTO-GENERATED DNS_SERVER_COOKIE_SECRET"); + _log.Write("========================================"); + _log.Write($"Cookie Secret: {cookieSecret}"); + _log.Write($"Saved to: {cookieSecretFilePath}"); + _log.Write(""); + _log.Write("IMPORTANT: For production deployments, set DNS_SERVER_COOKIE_SECRET environment variable"); + _log.Write("or DNS_SERVER_COOKIE_SECRET_FILE to persist this value across container recreations."); + _log.Write("========================================"); + } + catch (Exception ex) + { + _log.Write($"WARNING: Failed to persist cookie secret: {ex.Message}"); + _log.Write("Cookie secret will regenerate on restart (sessions will be invalidated)"); + } + } + } + + // Helper method to load config from env var or file + string LoadConfigValue(string envVarName, string fileEnvVarName, string defaultValue = null) + { + string value = Environment.GetEnvironmentVariable(envVarName); + string filePath = Environment.GetEnvironmentVariable(fileEnvVarName); + + if (!string.IsNullOrEmpty(filePath) && File.Exists(filePath)) + { + try + { + value = File.ReadAllText(filePath).Trim(); + _log.Write($"Loaded {envVarName} from file: {filePath}"); + } + catch (Exception ex) + { + _log.Write($"ERROR: Failed to read {fileEnvVarName}: {ex.Message}"); + } + } + + if (string.IsNullOrEmpty(value)) + value = defaultValue; + + return value; + } + + // Load all SSO configuration with file-based secret support + string ssoAuthority = LoadConfigValue("DNS_SERVER_SSO_AUTHORITY", "DNS_SERVER_SSO_AUTHORITY_FILE", _webServiceSsoAuthority); + string ssoClientId = LoadConfigValue("DNS_SERVER_SSO_CLIENT_ID", "DNS_SERVER_SSO_CLIENT_ID_FILE", _webServiceSsoClientId); + string ssoClientSecret = LoadConfigValue("DNS_SERVER_SSO_CLIENT_SECRET", "DNS_SERVER_SSO_CLIENT_SECRET_FILE", _webServiceSsoClientSecret); + + _webServiceSsoEnabled = !string.IsNullOrEmpty(ssoAuthority) && !string.IsNullOrEmpty(ssoClientId); + + // Configure Data Protection for cookie encryption + IDataProtectionBuilder dataProtectionBuilder = builder.Services.AddDataProtection() + .SetApplicationName("DnsServer") + .PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(_configFolder, "keys"))); + + if (OperatingSystem.IsWindows()) + { + dataProtectionBuilder.ProtectKeysWithDpapi(protectToLocalMachine: true); + } + + #region SSO Configuration + + if (_webServiceSsoEnabled) + { + builder.Services.AddAuthentication(options => + { + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; + }) + .AddCookie(options => + { + options.Cookie.Name = "dnsserver_session"; + options.Cookie.HttpOnly = true; + options.Cookie.SecurePolicy = _webServiceEnableTls ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest; + options.Cookie.SameSite = SameSiteMode.Lax; + options.ExpireTimeSpan = TimeSpan.FromHours(24); + options.SlidingExpiration = true; + }) + .AddOpenIdConnect(options => + { + options.Authority = ssoAuthority; + options.ClientId = ssoClientId; + options.ClientSecret = ssoClientSecret; + options.ResponseType = "code"; + + // Enable SSO session refresh + options.UseTokenLifetime = true; // Respect IdP token expiration + options.SaveTokens = true; // Required for refresh token support + + // HTTPS enforcement with override for reverse proxy deployments + string envAllowHttp = LoadConfigValue("DNS_SERVER_SSO_ALLOW_HTTP", "DNS_SERVER_SSO_ALLOW_HTTP_FILE", _webServiceSsoAllowHttp.ToString()); + bool allowHttp = bool.Parse(envAllowHttp ?? "false"); + + string envAllowSignup = LoadConfigValue("DNS_SERVER_SSO_ALLOW_SIGNUP", "DNS_SERVER_SSO_ALLOW_SIGNUP_FILE", _webServiceSsoAllowSignup.ToString()); + bool allowSignup = bool.Parse(envAllowSignup ?? "false"); + + // We load this for use in AuthApi but don't set options here (options doesn't use it directly) + // Just ensuring the property reflects the effective config + _webServiceSsoAllowSignup = allowSignup; + + string groupMappings = LoadConfigValue("DNS_SERVER_SSO_GROUP_MAPPINGS", "DNS_SERVER_SSO_GROUP_MAPPINGS_FILE", _webServiceSsoGroupMappings); + _webServiceSsoGroupMappings = groupMappings; + + // Validate group mappings JSON at startup to fail fast on invalid configuration + if (!string.IsNullOrEmpty(groupMappings)) + { + try + { + JsonSerializer.Deserialize>(groupMappings); + _log.Write($"SSO: Group mappings validated successfully"); + } + catch (Exception ex) + { + throw new Exception($"Invalid DNS_SERVER_SSO_GROUP_MAPPINGS JSON: {ex.Message}. Expected format: {{\"oidc-group-guid\":\"LocalGroupName\"}}", ex); + } + } + + options.RequireHttpsMetadata = !allowHttp; + + + if (allowHttp) + { + _log.Write("WARNING: SSO metadata over HTTP is allowed (DNS_SERVER_SSO_ALLOW_HTTP=true). This is INSECURE. Only use behind TLS-terminating reverse proxy."); + } + + options.Scope.Clear(); + string scopes = LoadConfigValue("DNS_SERVER_SSO_SCOPES", "DNS_SERVER_SSO_SCOPES_FILE", _webServiceSsoScopes); + if (string.IsNullOrEmpty(scopes)) + scopes = "openid profile email"; + + foreach (string scope in scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + options.Scope.Add(scope); + } + + // Configurable redirect URI (for proxy support and consolidated configuration) + string ssoRedirectUri = LoadConfigValue("DNS_SERVER_SSO_REDIRECT_URI", "DNS_SERVER_SSO_REDIRECT_URI_FILE", _webServiceSsoRedirectUri); + + if (!string.IsNullOrEmpty(ssoRedirectUri)) + { + if (Uri.TryCreate(ssoRedirectUri, UriKind.Absolute, out Uri uri)) + { + options.CallbackPath = PathString.FromUriComponent(uri); + + // Warn if using HTTP without explicit permission + if (uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) && !allowHttp) + { + _log.Write("WARNING: DNS_SERVER_SSO_REDIRECT_URI is using HTTP. This is insecure. Set DNS_SERVER_SSO_ALLOW_HTTP=true to suppress this warning if using a trusted reverse proxy."); + } + } + else + { + _log.Write($"ERROR: Invalid DNS_SERVER_SSO_REDIRECT_URI: {ssoRedirectUri}. Falling back to default callback path."); + options.CallbackPath = new PathString("/oidc/callback"); + } + } + else + { + options.CallbackPath = new PathString("/oidc/callback"); + } + + string ssoMetadataAddress = LoadConfigValue("DNS_SERVER_SSO_METADATA_ADDRESS", "DNS_SERVER_SSO_METADATA_ADDRESS_FILE", _webServiceSsoMetadataAddress); + if (!string.IsNullOrEmpty(ssoMetadataAddress)) + { + options.MetadataAddress = ssoMetadataAddress; + options.ConfigurationManager = new ConfigurationManager( + ssoMetadataAddress, + new OpenIdConnectConfigurationRetriever(), + new HttpDocumentRetriever(options.Backchannel) { RequireHttps = options.RequireHttpsMetadata } + ); + } + + options.Events = new OpenIdConnectEvents + { + OnRedirectToIdentityProvider = context => + { + if (!string.IsNullOrEmpty(ssoRedirectUri)) + { + context.ProtocolMessage.RedirectUri = ssoRedirectUri; + _log.Write($"OIDC: Overriding Redirect URI to: {ssoRedirectUri}"); + } + + _log.Write($"OIDC: Redirecting to IdP: {context.ProtocolMessage.IssuerAddress}"); + if (string.IsNullOrEmpty(context.ProtocolMessage.State)) + { + _log.Write("OIDC: WARNING - No state parameter generated!"); + } + return Task.CompletedTask; + }, + OnAuthorizationCodeReceived = context => + { + if (!string.IsNullOrEmpty(ssoRedirectUri)) + { + context.TokenEndpointRequest.RedirectUri = ssoRedirectUri; + } + return Task.CompletedTask; + }, + OnMessageReceived = context => + { + _log.Write("OIDC: Authorization code received"); + if (string.IsNullOrEmpty(context.ProtocolMessage.State)) + { + _log.Write("OIDC: WARNING - No state parameter in callback!"); + context.Response.Redirect("/"); + return Task.CompletedTask; + } + return Task.CompletedTask; + }, + OnTokenValidated = context => + { + string userId = context.Principal?.FindFirst("sub")?.Value + ?? context.Principal?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value + ?? context.Principal?.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value + ?? "unknown"; + _log.Write($"OIDC: Token validated for user: {userId}"); + return Task.CompletedTask; + }, + OnAuthenticationFailed = context => + { + _log.Write($"OIDC: Authentication failed - {context.Exception?.Message}"); + if (context.Exception?.InnerException != null) + _log.Write($"OIDC: Inner exception - {context.Exception.InnerException.Message}"); + + context.HandleResponse(); + context.Response.Redirect("/index.html?error=sso_failed"); + return Task.CompletedTask; + }, + OnRemoteFailure = context => + { + _log.Write($"OIDC: Remote failure - {context.Failure?.Message}"); + context.HandleResponse(); + context.Response.Redirect("/index.html?error=sso_failed"); + return Task.CompletedTask; + } + }; + }); + } + else + { + builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(); + } + + #endregion + + builder.Services.AddAuthorization(); + + builder.Environment.ContentRootFileProvider = new PhysicalFileProvider(_appFolder) + { + UseActivePolling = true, + UsePollingFileWatcher = true + }; + + builder.Environment.WebRootFileProvider = new PhysicalFileProvider(Path.Combine(_appFolder, "www")) + { + UseActivePolling = true, + UsePollingFileWatcher = true + }; builder.Services.AddResponseCompression(delegate (ResponseCompressionOptions options) { @@ -1582,8 +1933,41 @@ private async Task StartWebServiceAsync(bool httpOnlyMode) _webService = builder.Build(); + _webService.UseExceptionHandler(WebServiceExceptionHandler); + _webService.UseResponseCompression(); + // Add security headers middleware + _webService.Use(async (context, next) => + { + // Security headers for all responses + context.Response.Headers["X-Content-Type-Options"] = "nosniff"; + context.Response.Headers["X-Frame-Options"] = "DENY"; + context.Response.Headers["X-XSS-Protection"] = "1; mode=block"; + context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; + + // HSTS only on HTTPS connections + if (context.Request.IsHttps) + { + context.Response.Headers["Strict-Transport-Security"] = "max-age=86400; includeSubDomains"; + } + + // CSP for HTML pages (not API endpoints) + if (!context.Request.Path.StartsWithSegments("/api")) + { + context.Response.Headers["Content-Security-Policy"] = + "default-src 'self'; " + + "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + + "style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data:; " + + "connect-src 'self'; " + + "font-src 'self'; " + + "frame-ancestors 'none'"; + } + + await next(); + }); + if (_webServiceHttpToTlsRedirect && !httpOnlyMode && _webServiceEnableTls && (_webServiceSslServerAuthenticationOptions is not null)) _webService.Use(WebServiceHttpsRedirectionMiddleware); @@ -1598,6 +1982,9 @@ private async Task StartWebServiceAsync(bool httpOnlyMode) ServeUnknownFileTypes = true }); + _webService.UseAuthentication(); + _webService.UseAuthorization(); + ConfigureWebServiceRoutes(); try @@ -1667,6 +2054,9 @@ private void ConfigureWebServiceRoutes() _webService.MapGetAndPost("/api/user/login", delegate (HttpContext context) { return _authApi.LoginAsync(context, UserSessionType.Standard); }); _webService.MapGetAndPost("/api/user/createToken", delegate (HttpContext context) { return _authApi.LoginAsync(context, UserSessionType.ApiToken); }); _webService.MapGetAndPost("/api/user/logout", _authApi.Logout); + _webService.MapGetAndPost("/api/user/sso/login", _authApi.SsoLoginAsync); + _webService.MapGetAndPost("/api/user/sso/finalize", _authApi.SsoFinalizeAsync); + _webService.MapGetAndPost("/api/user/status", _authApi.StatusAsync); //user _webService.MapGetAndPost("/api/user/session/get", _authApi.GetCurrentSessionDetails); @@ -1944,8 +2334,11 @@ private async Task WebServiceApiMiddleware(HttpContext context, RequestDelegate switch (request.Path) { case "/api/user/login": + case "/api/user/status": case "/api/user/createToken": case "/api/user/logout": + case "/api/user/sso/login": + case "/api/user/sso/finalize": needsJsonResponseObject = false; break; @@ -2038,6 +2431,12 @@ private async Task WebServiceApiMiddleware(HttpContext context, RequestDelegate object apiFallback = context.Items["apiFallback"]; //check api fallback mark if (apiFallback is null) { + if (response.StatusCode == StatusCodes.Status302Found || response.StatusCode == StatusCodes.Status301MovedPermanently || response.StatusCode == StatusCodes.Status307TemporaryRedirect || response.StatusCode == StatusCodes.Status308PermanentRedirect) + { + // Response is a redirect, don't overwrite it with JSON success + return; + } + response.StatusCode = StatusCodes.Status200OK; response.ContentType = "application/json; charset=utf-8"; response.ContentLength = mS.Length; @@ -2057,9 +2456,15 @@ private void WebServiceExceptionHandler(IApplicationBuilder exceptionHandlerApp) exceptionHandlerApp.Run(async delegate (HttpContext context) { IExceptionHandlerPathFeature exceptionHandlerPathFeature = context.Features.Get(); + if (exceptionHandlerPathFeature == null) + return; + + Exception ex = exceptionHandlerPathFeature.Error; + if (exceptionHandlerPathFeature.Path.StartsWith("/api/")) { - Exception ex = exceptionHandlerPathFeature.Error; + if (ex != null && ex is not InvalidTokenWebServiceException) + _log.Write(context.GetRemoteEndPoint(_webServiceRealIpHeader), ex); HttpResponse response = context.Response; @@ -2105,7 +2510,28 @@ private void WebServiceExceptionHandler(IApplicationBuilder exceptionHandlerApp) private bool TryGetSession(HttpContext context, out UserSession session) { - string token = context.Request.GetQueryOrForm("token"); + // Try query/form first, then fallback to cookie + string token = context.Request.GetQueryOrForm("token", null); + + if (string.IsNullOrEmpty(token)) + { + // Check for session_token cookie + token = context.Request.Cookies["session_token"]; + } + + if (string.IsNullOrEmpty(token)) + { + // Check for ASP.NET Core Identity (SSO) authentication + if (context.User.Identity.IsAuthenticated) + { + // SSO users get their session via cookie set during SsoFinalizeAsync + // If we reach here with IsAuthenticated but no token, the session_token cookie wasn't set + } + + session = null; + return false; + } + session = _authManager.GetSession(token); if ((session is null) || session.User.Disabled) return false; diff --git a/DnsServerCore/WebServiceAuthApi.cs b/DnsServerCore/WebServiceAuthApi.cs index 40ead56e..fa4d323d 100644 --- a/DnsServerCore/WebServiceAuthApi.cs +++ b/DnsServerCore/WebServiceAuthApi.cs @@ -20,10 +20,17 @@ You should have received a copy of the GNU General Public License using DnsServerCore.Auth; using Microsoft.AspNetCore.Http; using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.Linq; using System.Net; +using System.Text; using System.Text.Json; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using System.Security.Cryptography; using TechnitiumLibrary.Security.OTP; namespace DnsServerCore @@ -36,6 +43,19 @@ sealed class WebServiceAuthApi readonly DnsWebService _dnsWebService; + // SSO Settings + private const int DEFAULT_SSO_PROVISIONING_RATE_LIMIT = 25; // Conservative: 25 attempts per hour per IP + private const int DEFAULT_SSO_MAX_AUTO_PROVISION = 25; // Conservative default for DNS server admin teams + // Rate limiting tracking + private static readonly ConcurrentDictionary _provisioningAttempts = new ConcurrentDictionary(); + + // Allowed redirect paths for security + private static readonly HashSet _allowedRedirectPaths = new HashSet + { + "/index.html", + "/api/user/sso/finalize" + }; + #endregion #region constructor @@ -49,11 +69,46 @@ public WebServiceAuthApi(DnsWebService dnsWebService) #region private - private void WriteCurrentSessionDetails(Utf8JsonWriter jsonWriter, UserSession currentSession, bool includeInfo) + private void SafeRedirect(HttpContext context, string path, Dictionary queryParams = null) + { + string basePath = path.Split('?')[0]; + + if (!_allowedRedirectPaths.Contains(basePath)) + { + _dnsWebService._log.Write($"SSO: WARNING - Blocked unsafe redirect to: {path}"); + path = "/index.html"; + queryParams = new Dictionary { ["error"] = "invalid_redirect" }; + } + + if (queryParams != null && queryParams.Count > 0) + { + var validParams = new[] { "error", "reason" }; + var query = string.Join("&", queryParams + .Where(kv => validParams.Contains(kv.Key)) + .Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")); + + path += "?" + query; + } + + context.Response.Redirect(path); + } + + private static string ComputeSha256Hash(string input) + { + using (var sha256 = System.Security.Cryptography.SHA256.Create()) + { + byte[] bytes = Encoding.UTF8.GetBytes(input); + byte[] hash = sha256.ComputeHash(bytes); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } + } + + private void WriteCurrentSessionDetails(Utf8JsonWriter jsonWriter, UserSession currentSession, bool includeInfo, bool includeToken = true) { if (currentSession.Type == UserSessionType.ApiToken) { jsonWriter.WriteString("username", currentSession.User.Username); + jsonWriter.WriteString("identitySource", currentSession.User.IsSsoUser ? "Remote/SSO" : "Local"); jsonWriter.WriteString("tokenName", currentSession.TokenName); jsonWriter.WriteString("token", currentSession.Token); } @@ -61,8 +116,11 @@ private void WriteCurrentSessionDetails(Utf8JsonWriter jsonWriter, UserSession c { jsonWriter.WriteString("displayName", currentSession.User.DisplayName); jsonWriter.WriteString("username", currentSession.User.Username); + jsonWriter.WriteString("identitySource", currentSession.User.IsSsoUser ? "Remote/SSO" : "Local"); jsonWriter.WriteBoolean("totpEnabled", currentSession.User.TOTPEnabled); - jsonWriter.WriteString("token", currentSession.Token); + + if (includeToken) + jsonWriter.WriteString("token", currentSession.Token); } if (includeInfo) @@ -115,6 +173,10 @@ private void WriteUserDetails(Utf8JsonWriter jsonWriter, User user, UserSession jsonWriter.WriteString("username", user.Username); jsonWriter.WriteBoolean("totpEnabled", user.TOTPEnabled); jsonWriter.WriteBoolean("disabled", user.Disabled); + + // Add identity source + jsonWriter.WriteString("identitySource", user.IsSsoUser ? "Remote/SSO" : "Local"); + jsonWriter.WriteString("previousSessionLoggedOn", user.PreviousSessionLoggedOn); jsonWriter.WriteString("previousSessionRemoteAddress", user.PreviousSessionRemoteAddress.ToString()); jsonWriter.WriteString("recentSessionLoggedOn", user.RecentSessionLoggedOn); @@ -318,6 +380,18 @@ public async Task LoginAsync(HttpContext context, UserSessionType sessionType) bool includeInfo = request.GetQueryOrForm("includeInfo", bool.Parse, false); IPEndPoint remoteEP = context.GetRemoteEndPoint(_dnsWebService._webServiceRealIpHeader); + // Block SSO users from using standard login form (they must use OIDC flow) + // API token creation is still allowed for SSO users + if (sessionType == UserSessionType.Standard) + { + User existingUser = _dnsWebService._authManager.GetUser(username); + if (existingUser != null && existingUser.IsSsoUser) + { + _dnsWebService._log.Write(remoteEP, "[" + username + "] SSO user attempted to use standard login form. Blocked."); + throw new DnsWebServiceException("SSO users must use the 'Login with SSO' option. Standard password login is not available for SSO accounts."); + } + } + UserSession session = await _dnsWebService._authManager.CreateSessionAsync(sessionType, tokenName, username, password, totp, remoteEP.Address, request.Headers.UserAgent); _dnsWebService._log.Write(remoteEP, "[" + session.User.Username + "] User logged in."); @@ -330,30 +404,57 @@ public async Task LoginAsync(HttpContext context, UserSessionType sessionType) if (_dnsWebService._clusterManager.ClusterInitialized) _dnsWebService._clusterManager.TriggerNotifyAllSecondaryNodesIfPrimarySelfNode(); } + else + { + // Set session cookie for standard user sessions (not API tokens) + // This is done regardless of cookie_auth flag to ensure optional convenience for scripts that handle cookies + context.Response.Cookies.Append("session_token", session.Token, new CookieOptions + { + HttpOnly = true, + Secure = context.Request.IsHttps, + SameSite = SameSiteMode.Lax, + MaxAge = TimeSpan.FromHours(24), + Path = "/" + }); + } + + bool cookieAuth = request.GetQueryOrForm("cookie_auth", bool.Parse, false); Utf8JsonWriter jsonWriter = context.GetCurrentJsonWriter(); - WriteCurrentSessionDetails(jsonWriter, session, includeInfo); + WriteCurrentSessionDetails(jsonWriter, session, includeInfo, !cookieAuth); } public void Logout(HttpContext context) { - string token = context.Request.GetQueryOrForm("token"); + string token = context.Request.GetQueryOrForm("token", null); - UserSession session = _dnsWebService._authManager.DeleteSession(token); - if (session is not null) + if (string.IsNullOrEmpty(token)) + token = context.Request.Cookies["session_token"]; + + if (!string.IsNullOrEmpty(token)) { - _dnsWebService._log.Write(context.GetRemoteEndPoint(_dnsWebService._webServiceRealIpHeader), "[" + session.User.Username + "] User logged out."); + UserSession session = _dnsWebService._authManager.DeleteSession(token); + if (session is not null) + { + _dnsWebService._log.Write(context.GetRemoteEndPoint(_dnsWebService._webServiceRealIpHeader), "[" + session.User.Username + "] User logged out."); - _dnsWebService._authManager.SaveConfigFile(); + _dnsWebService._authManager.SaveConfigFile(); + } } + + // Clear the session cookie + context.Response.Cookies.Delete("session_token"); } public void GetCurrentSessionDetails(HttpContext context) { + HttpRequest request = context.Request; + bool includeToken = request.GetQueryOrForm("includeToken", bool.Parse, true); + UserSession session = context.GetCurrentSession(); Utf8JsonWriter jsonWriter = context.GetCurrentJsonWriter(); - WriteCurrentSessionDetails(jsonWriter, session, true); + WriteCurrentSessionDetails(jsonWriter, session, true, includeToken); } public async Task ChangePasswordAsync(HttpContext context) @@ -407,6 +508,9 @@ public void Enable2FA(HttpContext context) User sessionUser = _dnsWebService.GetSessionUser(context, true); HttpRequest request = context.Request; + if (sessionUser.IsSsoUser) + throw new DnsWebServiceException("Two-factor authentication is managed by your SSO provider."); + string totp = request.GetQueryOrForm("totp"); sessionUser.EnableTOTP(totp); @@ -679,7 +783,13 @@ public void SetUserDetails(HttpContext context) user.DisplayName = displayName; if (request.TryGetQueryOrForm("newUser", out string newUsername)) - _dnsWebService._authManager.ChangeUsername(user, newUsername); + { + // Prevent changing usernames for SSO users (identified by sso_ prefix) + if (user.Username.StartsWith("sso_", StringComparison.OrdinalIgnoreCase)) + throw new DnsWebServiceException("Cannot change username for SSO users. Username is managed by identity provider."); + + _dnsWebService._authManager.ChangeUsername(user, newUsername); + } if (request.TryGetQueryOrForm("totpEnabled", bool.Parse, out bool totpEnabled)) { @@ -1193,6 +1303,301 @@ public void SetPermissionsDetails(HttpContext context, PermissionSection section } #endregion + + #region SSO + + public async Task SsoLoginAsync(HttpContext context) + { + try + { + await context.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, new AuthenticationProperties() + { + RedirectUri = "/api/user/sso/finalize" + }); + } + catch (Exception ex) + { + _dnsWebService._log.Write(context.GetRemoteEndPoint(_dnsWebService._webServiceRealIpHeader), ex); + throw new DnsWebServiceException("SSO Login failed: " + ex.Message, ex); + } + } + + public async Task SsoFinalizeAsync(HttpContext context) + { + try + { + AuthenticateResult result = await context.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme); + + if (!result.Succeeded) + { + SafeRedirect(context, "/index.html", new Dictionary { ["error"] = "sso_failed" }); + return; + } + + // Verbose logging control + bool verboseLogging = _dnsWebService._webServiceSsoVerboseLogging; + + if (verboseLogging) + { + _dnsWebService._log.Write("SSO: ========== OIDC Claims Received =========="); + foreach (var claim in result.Principal.Claims) + { + // Don't log sensitive 'sub' claim value + if (claim.Type == "sub") + _dnsWebService._log.Write($"SSO: Claim [sub] = "); + else + _dnsWebService._log.Write($"SSO: Claim [{claim.Type}] = {claim.Value}"); + } + _dnsWebService._log.Write("SSO: =========================================="); + } + + // Extract claims - try standard 'sub' first, then Microsoft-specific claims + string uniqueId = result.Principal.FindFirst("sub")?.Value + ?? result.Principal.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value + ?? result.Principal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value + ?? result.Principal.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + + if (string.IsNullOrEmpty(uniqueId)) + { + _dnsWebService._log.Write("SSO: CRITICAL - No unique identifier claim found (tried 'sub', nameidentifier, objectidentifier)"); + SafeRedirect(context, "/index.html", new Dictionary { ["error"] = "no_username_claim" }); + return; + } + string email = result.Principal.FindFirst("email")?.Value + ?? result.Principal.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value + ?? result.Principal.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn")?.Value + ?? result.Principal.FindFirst("upn")?.Value + ?? result.Principal.FindFirst("preferred_username")?.Value; + + string displayName = result.Principal.FindFirst("name")?.Value + ?? result.Principal.FindFirst(System.Security.Claims.ClaimTypes.Name)?.Value + ?? email; + + // Stable username generation using SHA256 hash of sub + string username = $"sso_{ComputeSha256Hash(uniqueId).Substring(0, 16)}"; + + _dnsWebService._log.Write($"SSO: Finalizing login for '{displayName}' (username: {username}, email: {email ?? "none"})"); + // Get or provision user + User user = _dnsWebService._authManager.GetUser(username); + + if (user != null) + { + // Ensure IsSsoUser flag is set for SSO-provisioned users + if (!user.IsSsoUser) + { + user.IsSsoUser = true; + try { _dnsWebService._authManager.SaveConfigFile(); } catch { } + _dnsWebService._log.Write($"SSO: Migrated existing user '{username}' to IsSsoUser=true"); + } + + // Check disabled status + if (user.Disabled) + { + _dnsWebService._log.Write($"SSO: Login denied for disabled user: {username}"); + SafeRedirect(context, "/index.html", new Dictionary + { + ["error"] = "access_denied", + ["reason"] = "account_disabled" + }); + return; + } + } + else + { + // User doesn't exist - check if auto-provisioning allowed + bool allowSignup = _dnsWebService._webServiceSsoAllowSignup; + + if (!allowSignup) + { + _dnsWebService._log.Write($"SSO: Auto-provisioning disabled, user not found: {username}"); + SafeRedirect(context, "/index.html", new Dictionary + { + ["error"] = "access_denied", + ["reason"] = "not_registered" + }); + return; + } + + // Rate limiting for auto-provisioning + // Configurable via DNS_SERVER_SSO_PROVISIONING_RATE_LIMIT env var (default: 25 per hour, 0 = unlimited) + string provisionKey = context.Connection.RemoteIpAddress.ToString(); + int maxProvisioningPerHour = DEFAULT_SSO_PROVISIONING_RATE_LIMIT; + string maxProvisioningEnv = Environment.GetEnvironmentVariable("DNS_SERVER_SSO_PROVISIONING_RATE_LIMIT"); + if (!string.IsNullOrEmpty(maxProvisioningEnv) && int.TryParse(maxProvisioningEnv, out int parsedRateLimit)) + { + maxProvisioningPerHour = parsedRateLimit; + } + + if (maxProvisioningPerHour > 0) + { + var recentAttempts = _provisioningAttempts + .Where(kv => kv.Key.StartsWith(provisionKey) && DateTime.UtcNow - kv.Value < TimeSpan.FromHours(1)) + .Count(); + + if (recentAttempts >= maxProvisioningPerHour) + { + _dnsWebService._log.Write($"SSO: Provisioning rate limit exceeded for {provisionKey} ({recentAttempts}/{maxProvisioningPerHour} attempts in last hour). Set DNS_SERVER_SSO_PROVISIONING_RATE_LIMIT=0 to disable or increase the value."); + SafeRedirect(context, "/index.html", new Dictionary { ["error"] = "rate_limited" }); + return; + } + } + + // Check system capacity for auto-provisioning + // Configurable via DNS_SERVER_SSO_MAX_AUTO_PROVISION env var (default: 25, 0 = unlimited) + int maxAutoProvision = DEFAULT_SSO_MAX_AUTO_PROVISION; + string maxAutoProvisionEnv = Environment.GetEnvironmentVariable("DNS_SERVER_SSO_MAX_AUTO_PROVISION"); + if (!string.IsNullOrEmpty(maxAutoProvisionEnv) && int.TryParse(maxAutoProvisionEnv, out int parsedLimit)) + { + maxAutoProvision = parsedLimit; + } + + if (maxAutoProvision > 0 && _dnsWebService._authManager.Users.Count >= maxAutoProvision) + { + _dnsWebService._log.Write($"SSO: User auto-provisioning blocked - system at capacity ({_dnsWebService._authManager.Users.Count}/{maxAutoProvision} users). Set DNS_SERVER_SSO_MAX_AUTO_PROVISION=0 to disable limit or increase the value."); + SafeRedirect(context, "/index.html", new Dictionary { ["error"] = "system_full" }); + return; + } + + // Auto-provision user + // Cleanup old provisioning attempts if dictionary grows too large + if (_provisioningAttempts.Count > 1000) + { + var expiredKeys = _provisioningAttempts + .Where(kv => DateTime.UtcNow - kv.Value > TimeSpan.FromHours(1)) + .Select(kv => kv.Key) + .ToList(); + + foreach (var key in expiredKeys) + { + _provisioningAttempts.TryRemove(key, out _); + } + + _dnsWebService._log.Write($"SSO: Cleaned up {expiredKeys.Count} expired provisioning attempt entries"); + } + + _provisioningAttempts[$"{provisionKey}_{DateTime.UtcNow.Ticks}"] = DateTime.UtcNow; + + + using (var rng = RandomNumberGenerator.Create()) + { + byte[] randomBytes = new byte[32]; + rng.GetBytes(randomBytes); + string randomPassword = Convert.ToBase64String(randomBytes); + + user = _dnsWebService._authManager.CreateUser( + displayName ?? email ?? username, + username, + randomPassword + ); + } + + // Mark as SSO user + user.IsSsoUser = true; + + _dnsWebService._log.Write($"SSO: Auto-provisioned new user '{username}' (display: {displayName}, email: {email})"); + } + // Enforce 2FA disablement for SSO users (handled by IdP) + if (user.TOTPEnabled) + { + user.DisableTOTP(); + _dnsWebService._log.Write($"SSO: Disabled local 2FA for user '{username}' (enforced by SSO)"); + } + + // Default Group + string defaultGroupName = Environment.GetEnvironmentVariable("DNS_SERVER_SSO_DEFAULT_GROUP"); + if (!string.IsNullOrEmpty(defaultGroupName)) + { + Group defaultGroup = _dnsWebService._authManager.GetGroup(defaultGroupName); + if (defaultGroup != null) + { + if (!user.IsMemberOfGroup(defaultGroup)) + { + user.AddToGroup(defaultGroup); + _dnsWebService._log.Write($"SSO: Added user {username} to default group: {defaultGroupName}"); + } + } + else + { + _dnsWebService._log.Write($"SSO: WARNING - Default group '{defaultGroupName}' not found."); + } + } + + // Group Mapping + string groupMappingsJson = _dnsWebService._webServiceSsoGroupMappings; + + + if (!string.IsNullOrEmpty(groupMappingsJson)) + { + try + { + Dictionary mappings = JsonSerializer.Deserialize>(groupMappingsJson); + if (mappings != null) + { + foreach (var mapping in mappings) + { + string oidcGroup = mapping.Key; + string localGroupName = mapping.Value; + + if (result.Principal.HasClaim(c => (c.Type == "groups" || c.Type == "roles" || c.Type == System.Security.Claims.ClaimTypes.Role) && c.Value == oidcGroup)) + { + Group localGroup = _dnsWebService._authManager.GetGroup(localGroupName); + if (localGroup != null) + { + if (!user.IsMemberOfGroup(localGroup)) + { + user.AddToGroup(localGroup); + _dnsWebService._log.Write($"SSO: Added user {username} to group '{localGroupName}' based on OIDC claim '{oidcGroup}'"); + } + } + else + { + _dnsWebService._log.Write($"SSO: WARNING - Mapped local group '{localGroupName}' not found."); + } + } + } + } + } + catch (Exception ex) + { + _dnsWebService._log.Write("SSO: Failed to parse group mappings JSON: " + ex.Message); + } + } + + // Create Session + UserSession session = _dnsWebService._authManager.CreateSsoSession(user, context.Connection.RemoteIpAddress, context.Request.Headers.UserAgent); + + // Store session token in encrypted cookie (NOT in URL) + context.Response.Cookies.Append("session_token", session.Token, new CookieOptions + { + HttpOnly = true, + Secure = context.Request.IsHttps, + SameSite = SameSiteMode.Lax, + MaxAge = TimeSpan.FromHours(24), + Path = "/" + }); + + _dnsWebService._log.Write($"SSO: Session created for {username}"); + _dnsWebService._authManager.SaveConfigFile(); + + // Redirect without token in URL + SafeRedirect(context, "/index.html", null); + } + catch (Exception ex) + { + _dnsWebService._log.Write(context.GetRemoteEndPoint(_dnsWebService._webServiceRealIpHeader), ex); + SafeRedirect(context, "/index.html", new Dictionary { ["error"] = "sso_failed" }); + } + } + + public Task StatusAsync(HttpContext context) + { + Utf8JsonWriter jsonWriter = context.GetCurrentJsonWriter(); + jsonWriter.WriteBoolean("ssoEnabled", _dnsWebService._webServiceSsoEnabled); + return Task.CompletedTask; + } + + #endregion + } } } diff --git a/DnsServerCore/WebServiceSettingsApi.cs b/DnsServerCore/WebServiceSettingsApi.cs index 5dbc7cb2..3a92cb1a 100644 --- a/DnsServerCore/WebServiceSettingsApi.cs +++ b/DnsServerCore/WebServiceSettingsApi.cs @@ -251,6 +251,54 @@ private void WriteDnsSettings(Utf8JsonWriter jsonWriter) jsonWriter.WriteString("webServiceTlsCertificatePath", _dnsWebService._webServiceTlsCertificatePath); jsonWriter.WriteString("webServiceTlsCertificatePassword", "************"); jsonWriter.WriteString("webServiceRealIpHeader", _dnsWebService._webServiceRealIpHeader); + + //sso + jsonWriter.WriteBoolean("ssoEnabled", _dnsWebService._webServiceSsoEnabled); + + string ssoAuthority = GetEffectiveSsoConfig("DNS_SERVER_SSO_AUTHORITY", "DNS_SERVER_SSO_AUTHORITY_FILE", _dnsWebService._webServiceSsoAuthority); + jsonWriter.WriteBoolean("ssoAuthorityReadOnly", IsEnvVarSet("DNS_SERVER_SSO_AUTHORITY", "DNS_SERVER_SSO_AUTHORITY_FILE")); + jsonWriter.WriteString("ssoAuthority", ssoAuthority); + + string ssoClientId = GetEffectiveSsoConfig("DNS_SERVER_SSO_CLIENT_ID", "DNS_SERVER_SSO_CLIENT_ID_FILE", _dnsWebService._webServiceSsoClientId); + jsonWriter.WriteBoolean("ssoClientIdReadOnly", IsEnvVarSet("DNS_SERVER_SSO_CLIENT_ID", "DNS_SERVER_SSO_CLIENT_ID_FILE")); + jsonWriter.WriteString("ssoClientId", ssoClientId); + + string ssoClientSecret = GetEffectiveSsoConfig("DNS_SERVER_SSO_CLIENT_SECRET", "DNS_SERVER_SSO_CLIENT_SECRET_FILE", _dnsWebService._webServiceSsoClientSecret); + jsonWriter.WriteBoolean("ssoClientSecretReadOnly", IsEnvVarSet("DNS_SERVER_SSO_CLIENT_SECRET", "DNS_SERVER_SSO_CLIENT_SECRET_FILE")); + jsonWriter.WriteString("ssoClientSecret", string.IsNullOrEmpty(ssoClientSecret) ? "" : "************"); + + string ssoScopes = GetEffectiveSsoConfig("DNS_SERVER_SSO_SCOPES", "DNS_SERVER_SSO_SCOPES_FILE", _dnsWebService._webServiceSsoScopes); + jsonWriter.WriteBoolean("ssoScopesReadOnly", IsEnvVarSet("DNS_SERVER_SSO_SCOPES", "DNS_SERVER_SSO_SCOPES_FILE")); + jsonWriter.WriteString("ssoScopes", ssoScopes); + + string ssoRedirectUri = GetEffectiveSsoConfig("DNS_SERVER_SSO_REDIRECT_URI", "DNS_SERVER_SSO_REDIRECT_URI_FILE", _dnsWebService._webServiceSsoRedirectUri); + jsonWriter.WriteBoolean("ssoRedirectUriReadOnly", IsEnvVarSet("DNS_SERVER_SSO_REDIRECT_URI", "DNS_SERVER_SSO_REDIRECT_URI_FILE")); + jsonWriter.WriteString("ssoRedirectUri", ssoRedirectUri); + + string ssoMetadataAddress = GetEffectiveSsoConfig("DNS_SERVER_SSO_METADATA_ADDRESS", "DNS_SERVER_SSO_METADATA_ADDRESS_FILE", _dnsWebService._webServiceSsoMetadataAddress); + jsonWriter.WriteBoolean("ssoMetadataAddressReadOnly", IsEnvVarSet("DNS_SERVER_SSO_METADATA_ADDRESS", "DNS_SERVER_SSO_METADATA_ADDRESS_FILE")); + jsonWriter.WriteString("ssoMetadataAddress", ssoMetadataAddress); + + string envSsoAllowHttp = Environment.GetEnvironmentVariable("DNS_SERVER_SSO_ALLOW_HTTP"); + bool ssoAllowHttp = !string.IsNullOrEmpty(envSsoAllowHttp) ? bool.Parse(envSsoAllowHttp) : _dnsWebService._webServiceSsoAllowHttp; + jsonWriter.WriteBoolean("ssoAllowHttpReadOnly", !string.IsNullOrEmpty(envSsoAllowHttp)); + jsonWriter.WriteBoolean("ssoAllowHttp", ssoAllowHttp); + + string envSsoAllowSignup = Environment.GetEnvironmentVariable("DNS_SERVER_SSO_ALLOW_SIGNUP"); + bool ssoAllowSignup = !string.IsNullOrEmpty(envSsoAllowSignup) ? bool.Parse(envSsoAllowSignup) : _dnsWebService._webServiceSsoAllowSignup; + jsonWriter.WriteBoolean("ssoAllowSignupReadOnly", !string.IsNullOrEmpty(envSsoAllowSignup)); + jsonWriter.WriteBoolean("ssoAllowSignup", ssoAllowSignup); + + string envSsoVerboseLogging = Environment.GetEnvironmentVariable("DNS_SERVER_SSO_VERBOSE_LOGGING"); + bool ssoVerboseLogging = !string.IsNullOrEmpty(envSsoVerboseLogging) ? bool.Parse(envSsoVerboseLogging) : _dnsWebService._webServiceSsoVerboseLogging; + jsonWriter.WriteBoolean("ssoVerboseLoggingReadOnly", !string.IsNullOrEmpty(envSsoVerboseLogging)); + jsonWriter.WriteBoolean("ssoVerboseLogging", ssoVerboseLogging); + + string ssoGroupMappings = GetEffectiveSsoConfig("DNS_SERVER_SSO_GROUP_MAPPINGS", "DNS_SERVER_SSO_GROUP_MAPPINGS_FILE", _dnsWebService._webServiceSsoGroupMappings); + jsonWriter.WriteBoolean("ssoGroupMappingsReadOnly", IsEnvVarSet("DNS_SERVER_SSO_GROUP_MAPPINGS", "DNS_SERVER_SSO_GROUP_MAPPINGS_FILE")); + jsonWriter.WriteString("ssoGroupMappings", ssoGroupMappings); + + //optional protocols jsonWriter.WriteBoolean("enableDnsOverUdpProxy", _dnsWebService._dnsServer.EnableDnsOverUdpProxy); @@ -988,6 +1036,125 @@ public async Task SetDnsSettingsAsync(HttpContext context) throw new ArgumentException("Web service Real IP header name cannot contain invalid characters.", nameof(webServiceRealIpHeader)); _dnsWebService._webServiceRealIpHeader = webServiceRealIpHeader; + _dnsWebService._webServiceRealIpHeader = webServiceRealIpHeader; + } + + string ssoAuthority = request.QueryOrForm("ssoAuthority"); + if (ssoAuthority is not null) + { + if (ssoAuthority.Length > 0 && !Uri.TryCreate(ssoAuthority, UriKind.Absolute, out _)) + throw new ArgumentException("Invalid SSO Authority URL.", nameof(ssoAuthority)); + + if (ssoAuthority != _dnsWebService._webServiceSsoAuthority) + { + _dnsWebService._webServiceSsoAuthority = ssoAuthority; + restartWebService = true; + } + } + + string ssoClientId = request.QueryOrForm("ssoClientId"); + if (ssoClientId is not null) + { + if (ssoClientId != _dnsWebService._webServiceSsoClientId) + { + _dnsWebService._webServiceSsoClientId = ssoClientId; + restartWebService = true; + } + } + + string ssoClientSecret = request.QueryOrForm("ssoClientSecret"); + if ((ssoClientSecret is not null) && (ssoClientSecret != "************")) + { + if (ssoClientSecret != _dnsWebService._webServiceSsoClientSecret) + { + _dnsWebService._webServiceSsoClientSecret = ssoClientSecret; + restartWebService = true; + } + } + + string ssoScopes = request.QueryOrForm("ssoScopes"); + if (ssoScopes is not null) + { + if (ssoScopes != _dnsWebService._webServiceSsoScopes) + { + _dnsWebService._webServiceSsoScopes = ssoScopes; + restartWebService = true; + } + } + + string ssoRedirectUri = request.QueryOrForm("ssoRedirectUri"); + if (ssoRedirectUri is not null) + { + if (ssoRedirectUri.Length > 0 && !Uri.TryCreate(ssoRedirectUri, UriKind.Absolute, out _)) + throw new ArgumentException("Invalid SSO Redirect URI. It must be a valid absolute URL.", nameof(ssoRedirectUri)); + + if (ssoRedirectUri != _dnsWebService._webServiceSsoRedirectUri) + { + _dnsWebService._webServiceSsoRedirectUri = ssoRedirectUri; + restartWebService = true; + } + } + + string ssoMetadataAddress = request.QueryOrForm("ssoMetadataAddress"); + if (ssoMetadataAddress is not null) + { + if (ssoMetadataAddress.Length > 0 && !Uri.TryCreate(ssoMetadataAddress, UriKind.Absolute, out _)) + throw new ArgumentException("Invalid SSO Metadata Address URL.", nameof(ssoMetadataAddress)); + + if (ssoMetadataAddress != _dnsWebService._webServiceSsoMetadataAddress) + { + _dnsWebService._webServiceSsoMetadataAddress = ssoMetadataAddress; + restartWebService = true; + } + } + + if (request.TryGetQueryOrForm("ssoAllowHttp", bool.Parse, out bool ssoAllowHttp)) + { + if (_dnsWebService._webServiceSsoAllowHttp != ssoAllowHttp) + { + _dnsWebService._webServiceSsoAllowHttp = ssoAllowHttp; + restartWebService = true; + } + } + + if (request.TryGetQueryOrForm("ssoAllowSignup", bool.Parse, out bool ssoAllowSignup)) + { + if (_dnsWebService._webServiceSsoAllowSignup != ssoAllowSignup) + { + _dnsWebService._webServiceSsoAllowSignup = ssoAllowSignup; + restartWebService = true; + } + } + + if (request.TryGetQueryOrForm("ssoVerboseLogging", bool.Parse, out bool ssoVerboseLogging)) + { + if (_dnsWebService._webServiceSsoVerboseLogging != ssoVerboseLogging) + { + _dnsWebService._webServiceSsoVerboseLogging = ssoVerboseLogging; + restartWebService = true; + } + } + + string ssoGroupMappings = request.QueryOrForm("ssoGroupMappings"); + if (ssoGroupMappings is not null) + { + if (!string.IsNullOrEmpty(ssoGroupMappings)) + { + try + { + JsonSerializer.Deserialize>(ssoGroupMappings); + } + catch + { + throw new ArgumentException("Invalid Group Mappings JSON. It must be a valid JSON object (e.g. {\"OidcGroup\": \"LocalGroup\"}).", nameof(ssoGroupMappings)); + } + } + + if (ssoGroupMappings != _dnsWebService._webServiceSsoGroupMappings) + { + _dnsWebService._webServiceSsoGroupMappings = ssoGroupMappings; + restartWebService = true; + } } #endregion @@ -1916,6 +2083,40 @@ public void TemporaryDisableBlocking(HttpContext context) } #endregion + private bool IsEnvVarSet(string envVarName, string fileEnvVarName) + { + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(envVarName))) + return true; + + string filePath = Environment.GetEnvironmentVariable(fileEnvVarName); + if (!string.IsNullOrEmpty(filePath) && File.Exists(filePath)) + return true; + + return false; + } + + private string GetEffectiveSsoConfig(string envVarName, string fileEnvVarName, string configValue) + { + string value = Environment.GetEnvironmentVariable(envVarName); + string filePath = Environment.GetEnvironmentVariable(fileEnvVarName); + + if (!string.IsNullOrEmpty(filePath) && File.Exists(filePath)) + { + try + { + value = File.ReadAllText(filePath).Trim(); + } + catch + { + // ignore error + } + } + + if (string.IsNullOrEmpty(value)) + value = configValue; + + return value; + } } } } diff --git a/DnsServerCore/www/index.html b/DnsServerCore/www/index.html index 5f5b75e2..d8a8a09e 100644 --- a/DnsServerCore/www/index.html +++ b/DnsServerCore/www/index.html @@ -103,14 +103,20 @@

DNS Server

-
+
- +
+ +
@@ -1417,6 +1423,130 @@

+ +
+
+ +
+

Configure OpenID Connect (OIDC) settings here.

+ Clear Authority and Client ID to disable SSO.

+ Note! Environment variables will take precedence over these settings.

+
+
+ +
+
+ +
+ +
+
The OIDC Authority URL (Issuer).
+
+ +
+ +
+ +
+
The OIDC Client ID.
+
+ +
+ +
+ +
+
+ The OIDC Client Secret. +
+ Security Note: The client secret is stored in plain text in webservice.config. Ensure this file has restricted permissions (e.g., chmod 600 on Linux). For enhanced security, use the DNS_SERVER_SSO_CLIENT_SECRET environment variable instead. +
+
+
+ +
+ +
+ +
+
Space-separated list of scopes to request.
+
+ +
+ +
+ +
+
The complete redirect URI (callback URL) registered with the OIDC provider.
+
+ +
+ +
+ +
+
Optional. Overrides the default metadata discovery URL.
+
+ +
+ +
+
+ +
+
Support OIDC over HTTP (e.g. for testing or internal reverse proxies).
+
+
+ +
+ +
+
+ +
+
Automatically provision local accounts for new users who log in via SSO.
+
+
+ +
+ +
+ +
+

Note! Map OIDC groups/roles to local user groups (JSON format). Key is the OIDC group claim value, Value is the local group name. If a user maps to multiple local groups, they are assigned all matched roles (union).

+ Example: +
{"google-group-name": "DNS Administrators", "entra-group-guid": "DHCP Administrators"}
+
+
+
+ +
+ +
+ +
+

Optional. Group name to assign all auto-provisioned SSO users. Leave empty for no default group.

+
+
+
+ +
+ +
+
+ +
+
Enable this option to log detailed OIDC claims and debugging information.
+
+
+
+
@@ -6310,6 +6440,13 @@
+
+ +
+
+
+
+
diff --git a/DnsServerCore/www/js/auth.js b/DnsServerCore/www/js/auth.js index 2cfa5b1a..9ff9bb30 100644 --- a/DnsServerCore/www/js/auth.js +++ b/DnsServerCore/www/js/auth.js @@ -20,34 +20,72 @@ along with this program. If not, see . var sessionData = null; $(function () { - var token = localStorage.getItem("token"); - if (token == null) { - showPageLogin(); - login("admin", "admin"); - } - else { - HTTPRequest({ - url: "api/user/session/get?token=" + token, - success: function (responseJSON) { - sessionData = responseJSON; - localStorage.setItem("token", sessionData.token); + var urlParams = new URLSearchParams(window.location.search); - $("#mnuUserDisplayName").text(sessionData.displayName); - document.title = sessionData.info.dnsServerDomain + " - " + "Technitium DNS Server v" + sessionData.info.version; - $("#lblAboutVersion").text(sessionData.info.version); - $("#lblAboutUptime").text(moment(sessionData.info.uptimestamp).local().format("lll") + " (" + moment(sessionData.info.uptimestamp).fromNow() + ")"); - $("#lblDnsServerDomain").text(" - " + sessionData.info.dnsServerDomain); - $("#chkUseSoaSerialDateScheme").prop("checked", sessionData.info.useSoaSerialDateScheme); - $("#chkDnssecValidation").prop("checked", sessionData.info.dnssecValidation); - - showPageMain(); - }, - error: function () { - showPageLogin(); + // Explicitly hide OTP box on load to prevent flash/regression + $("#div2FAOTP").hide(); + + // Check SSO Status + HTTPRequest({ + url: "api/user/status", + success: function (response) { + if (response.ssoEnabled) { + $("#divSsoLogin").show(); } - }); + }, + error: function () { + // SSO status check failed, hide SSO login option + } + }); + + // Check for SSO error from redirect + var errorParam = urlParams.get('error'); + if (errorParam) { + var reason = urlParams.get('reason'); + var errorMsg = "SSO Login Failed"; + if (errorParam === 'sso_failed') errorMsg = "SSO authentication failed. Please try again."; + else if (errorParam === 'no_username_claim') errorMsg = "SSO provider did not return required user information."; + else if (errorParam === 'access_denied') { + if (reason === 'not_registered') errorMsg = "Access denied. Your account is not registered."; + else if (reason === 'account_disabled') errorMsg = "Access denied. Your account is disabled."; + } + else if (errorParam === 'rate_limited') errorMsg = "Too many provisioning attempts. Please try again later."; + else if (errorParam === 'system_full') errorMsg = "System is at user capacity. Contact administrator."; + + showAlert("danger", "Login Error", errorMsg); + + // Clear error from URL + var cleanUrl = window.location.protocol + "//" + window.location.host + window.location.pathname; + window.history.replaceState({ path: cleanUrl }, '', cleanUrl); + + showPageLogin(); + return; } + // Check if we have a valid session (cookie-based or token-based) + HTTPRequest({ + url: "api/user/session/get?includeToken=false", + success: function (responseJSON) { + sessionData = responseJSON; + if (!sessionData.token) sessionData.token = ""; + + $("#mnuUserDisplayName").text(sessionData.displayName); + document.title = sessionData.info.dnsServerDomain + " - " + "Technitium DNS Server v" + sessionData.info.version; + $("#lblAboutVersion").text(sessionData.info.version); + $("#lblAboutUptime").text(moment(sessionData.info.uptimestamp).local().format("lll") + " (" + moment(sessionData.info.uptimestamp).fromNow() + ")"); + $("#lblDnsServerDomain").text(" - " + sessionData.info.dnsServerDomain); + $("#chkUseSoaSerialDateScheme").prop("checked", sessionData.info.useSoaSerialDateScheme); + $("#chkDnssecValidation").prop("checked", sessionData.info.dnssecValidation); + + showPageMain(); + }, + error: function () { + showPageLogin(); + // Auto-login with default credentials (for fresh installs) + login("admin", "admin"); + } + }); + $("#optGroupDetailsUserList").on("change", function () { var selectedUser = $("#optGroupDetailsUserList").val(); @@ -215,11 +253,12 @@ function login(username, password) { HTTPRequest({ url: "api/user/login", method: "POST", - data: "user=" + encodeURIComponent(username) + "&pass=" + encodeURIComponent(password) + "&totp=" + encodeURIComponent(totp) + "&includeInfo=true", + data: "user=" + encodeURIComponent(username) + "&pass=" + encodeURIComponent(password) + "&totp=" + encodeURIComponent(totp) + "&includeInfo=true&cookie_auth=true", procecssData: false, success: function (responseJSON) { sessionData = responseJSON; - localStorage.setItem("token", sessionData.token); + if (!sessionData.token) sessionData.token = ""; + // Token now managed via HTTP-only cookie, no localStorage needed $("#mnuUserDisplayName").text(sessionData.displayName); document.title = sessionData.info.dnsServerDomain + " - " + "Technitium DNS Server v" + sessionData.info.version; @@ -266,11 +305,23 @@ function logout() { url: "api/user/logout?token=" + sessionData.token, success: function (responseJSON) { sessionData = null; - showPageLogin(); + localStorage.removeItem("token"); + localStorage.removeItem("token_expires"); + // HttpOnly cookie is cleared by server response + + + // Force page reload to ensure clean state + window.location.reload(); }, error: function () { sessionData = null; - showPageLogin(); + localStorage.removeItem("token"); + localStorage.removeItem("token_expires"); + // HttpOnly cookie is cleared by server response + + + // Force page reload to ensure clean state + window.location.reload(); } }); } @@ -1098,6 +1149,30 @@ function getAdminUsersRowHtml(id, user) { return tableHtmlRows; } +function showPageLogin() { + $("#pageMain").hide(); + $("#pageLogin").show(); + $("#txtUser").focus(); + + // Check SSO status + HTTPRequest({ + url: "api/user/status", + success: function (responseJSON) { + if (responseJSON.ssoEnabled) { + $("#btnSsoLogin").show(); + $("#divSsoLogin").show(); + } else { + $("#btnSsoLogin").hide(); + $("#divSsoLogin").hide(); + } + }, + error: function () { + $("#btnSsoLogin").hide(); + $("#divSsoLogin").hide(); + } + }); +} + function showAddUserModal() { $("#divAddUserAlert").html(""); @@ -1199,6 +1274,20 @@ function showUserDetailsModal(objMenuItem) { success: function (responseJSON) { $("#txtUserDetailsDisplayName").val(responseJSON.response.displayName); $("#txtUserDetailsUsername").val(responseJSON.response.username); + $("#txtUserDetailsUsername").data("original-username", responseJSON.response.username); + + var identitySource = responseJSON.response.identitySource || "Local"; + $("#lblUserDetailsIdentitySource").text(identitySource); + + // Disable username editing for SSO users + if ((identitySource === "Remote/SSO") || responseJSON.response.username.toLowerCase().startsWith("sso_")) { + $("#txtUserDetailsUsername").prop("disabled", true); + $("#txtUserDetailsUsername").attr("title", "Username is managed by SSO identity provider and cannot be changed"); + } else { + $("#txtUserDetailsUsername").prop("disabled", false); + $("#txtUserDetailsUsername").attr("title", ""); + } + $("#lblUserDetails2FAStatus").text(responseJSON.response.totpEnabled ? "Enabled" : "Disabled"); $("#chkUserDetailsDisableAccount").prop("checked", responseJSON.response.disabled); $("#txtUserDetailsSessionTimeout").val(responseJSON.response.sessionTimeoutSeconds); @@ -1333,6 +1422,17 @@ function saveUserDetails(objBtn) { var username = btn.attr("data-username"); var newUsername = $("#txtUserDetailsUsername").val(); var displayName = $("#txtUserDetailsDisplayName").val(); + var originalUsername = $("#txtUserDetailsUsername").data("original-username"); + + // Prevent changing SSO usernames + if (originalUsername && originalUsername.toLowerCase().startsWith("sso_") && + newUsername !== originalUsername) { + showAlert("warning", "Cannot Change Username", + "SSO user usernames are managed by the identity provider and cannot be changed.", + divUserDetailsAlert); + return; + } + var disabled = $("#chkUserDetailsDisableAccount").prop("checked"); var sessionTimeoutSeconds = $("#txtUserDetailsSessionTimeout").val(); diff --git a/DnsServerCore/www/js/main.js b/DnsServerCore/www/js/main.js index 8518f1d5..4f45aeb4 100644 --- a/DnsServerCore/www/js/main.js +++ b/DnsServerCore/www/js/main.js @@ -1112,6 +1112,33 @@ function loadDnsSettings(responseJSON) { $("#lblWebServiceRealIpHeader").text(responseJSON.response.webServiceRealIpHeader); $("#lblWebServiceRealIpNginx").text("proxy_set_header " + responseJSON.response.webServiceRealIpHeader + " $remote_addr;"); + // SSO + $("#txtSsoAuthority").val(responseJSON.response.ssoAuthority); + $("#txtSsoClientId").val(responseJSON.response.ssoClientId); + $("#txtSsoClientSecret").val(responseJSON.response.ssoClientSecret); + $("#txtSsoScopes").val(responseJSON.response.ssoScopes); + $("#txtSsoCallbackPath").val(responseJSON.response.ssoCallbackPath); + $("#txtSsoRedirectUri").val(responseJSON.response.ssoRedirectUri); + $("#txtSsoMetadataAddress").val(responseJSON.response.ssoMetadataAddress); + $("#chkSsoAllowHttp").prop("checked", responseJSON.response.ssoAllowHttp); + + $("#txtSsoAuthority").prop("disabled", responseJSON.response.ssoAuthorityReadOnly); + $("#txtSsoClientId").prop("disabled", responseJSON.response.ssoClientIdReadOnly); + $("#txtSsoClientSecret").prop("disabled", responseJSON.response.ssoClientSecretReadOnly); + $("#txtSsoScopes").prop("disabled", responseJSON.response.ssoScopesReadOnly); + $("#txtSsoRedirectUri").prop("disabled", responseJSON.response.ssoRedirectUriReadOnly); + $("#txtSsoMetadataAddress").prop("disabled", responseJSON.response.ssoMetadataAddressReadOnly); + $("#chkSsoAllowHttp").prop("disabled", responseJSON.response.ssoAllowHttpReadOnly); + + $("#chkSsoAllowSignup").prop("checked", responseJSON.response.ssoAllowSignup); + $("#chkSsoAllowSignup").prop("disabled", responseJSON.response.ssoAllowSignupReadOnly); + + $("#txtSsoGroupMappings").val(responseJSON.response.ssoGroupMappings); + $("#txtSsoGroupMappings").prop("disabled", responseJSON.response.ssoGroupMappingsReadOnly); + + $("#chkSsoVerboseLogging").prop("checked", responseJSON.response.ssoVerboseLogging); + $("#chkSsoVerboseLogging").prop("disabled", responseJSON.response.ssoVerboseLoggingReadOnly); + //optional protocols $("#chkEnableDnsOverUdpProxy").prop("checked", responseJSON.response.enableDnsOverUdpProxy); $("#chkEnableDnsOverTcpProxy").prop("checked", responseJSON.response.enableDnsOverTcpProxy); @@ -1632,7 +1659,19 @@ function saveDnsSettings(objBtn) { var webServiceTlsCertificatePassword = $("#txtWebServiceTlsCertificatePassword").val(); var webServiceRealIpHeader = $("#txtWebServiceRealIpHeader").val(); + var ssoAuthority = $("#txtSsoAuthority").val(); + var ssoClientId = $("#txtSsoClientId").val(); + var ssoClientSecret = $("#txtSsoClientSecret").val(); + var ssoScopes = $("#txtSsoScopes").val(); + var ssoRedirectUri = $("#txtSsoRedirectUri").val(); + var ssoMetadataAddress = $("#txtSsoMetadataAddress").val(); + var ssoAllowHttp = $("#chkSsoAllowHttp").prop("checked"); + var ssoAllowSignup = $("#chkSsoAllowSignup").prop("checked"); + var ssoGroupMappings = $("#txtSsoGroupMappings").val(); + var ssoVerboseLogging = $("#chkSsoVerboseLogging").prop("checked"); + formData += "&webServiceLocalAddresses=" + encodeURIComponent(webServiceLocalAddresses) + "&webServiceHttpPort=" + webServiceHttpPort + "&webServiceEnableTls=" + webServiceEnableTls + "&webServiceEnableHttp3=" + webServiceEnableHttp3 + "&webServiceHttpToTlsRedirect=" + webServiceHttpToTlsRedirect + "&webServiceUseSelfSignedTlsCertificate=" + webServiceUseSelfSignedTlsCertificate + "&webServiceTlsPort=" + webServiceTlsPort + "&webServiceTlsCertificatePath=" + encodeURIComponent(webServiceTlsCertificatePath) + "&webServiceTlsCertificatePassword=" + encodeURIComponent(webServiceTlsCertificatePassword) + "&webServiceRealIpHeader=" + encodeURIComponent(webServiceRealIpHeader); + formData += "&ssoAuthority=" + encodeURIComponent(ssoAuthority) + "&ssoClientId=" + encodeURIComponent(ssoClientId) + "&ssoClientSecret=" + encodeURIComponent(ssoClientSecret) + "&ssoScopes=" + encodeURIComponent(ssoScopes) + "&ssoRedirectUri=" + encodeURIComponent(ssoRedirectUri) + "&ssoMetadataAddress=" + encodeURIComponent(ssoMetadataAddress) + "&ssoAllowHttp=" + ssoAllowHttp + "&ssoAllowSignup=" + ssoAllowSignup + "&ssoGroupMappings=" + encodeURIComponent(ssoGroupMappings) + "&ssoVerboseLogging=" + ssoVerboseLogging; } //optional protocols diff --git a/DockerEnvironmentVariables.md b/DockerEnvironmentVariables.md index 7ee0e6de..6b76eb8a 100644 --- a/DockerEnvironmentVariables.md +++ b/DockerEnvironmentVariables.md @@ -7,7 +7,7 @@ NOTE! These environment variables are read by the DNS server only when the DNS c The environment variables are described below: | Environment Variable | Type | Description | -| ---------------------------------------------- | ------- | -----------------------------------------------------------------------------------------------------------------------------------------| +| ---------------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | DNS_SERVER_DOMAIN | String | The primary domain name used by this DNS Server to identify itself. | | DNS_SERVER_ADMIN_PASSWORD | String | The DNS web console admin user password. | | DNS_SERVER_ADMIN_PASSWORD_FILE | String | The path to a file that contains a plain text password for the DNS web console admin user. | @@ -31,3 +31,67 @@ The environment variables are described below: | DNS_SERVER_FORWARDERS | String | A comma separated list of forwarder addresses. | | DNS_SERVER_FORWARDER_PROTOCOL | String | Forwarder protocol options: `Udp`, `Tcp`, `Tls`, `Https`, `HttpsJson`. | | DNS_SERVER_LOG_USING_LOCAL_TIME | Boolean | Enable this option to use local time instead of UTC for logging. | + +## Single Sign-On (SSO) Environment Variables + +The following environment variables configure OpenID Connect (OIDC) Single Sign-On for the DNS Server web console: + +| Environment Variable | Type | Description | +| ---------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| DNS_SERVER_SSO_AUTHORITY | String | The OIDC authority/issuer URL (e.g., `https://login.microsoftonline.com/{tenant-id}/v2.0`). | +| DNS_SERVER_SSO_AUTHORITY_FILE | String | Path to file containing the OIDC authority URL. | +| DNS_SERVER_SSO_CLIENT_ID | String | The OIDC client ID for the DNS Server application. | +| DNS_SERVER_SSO_CLIENT_ID_FILE | String | Path to file containing the OIDC client ID. | +| DNS_SERVER_SSO_CLIENT_SECRET | String | The OIDC client secret for the DNS Server application. | +| DNS_SERVER_SSO_CLIENT_SECRET_FILE | String | Path to file containing the OIDC client secret (recommended for secrets). | +| DNS_SERVER_SSO_SCOPES | String | Space-separated OIDC scopes to request. Default: `openid profile email`. | +| DNS_SERVER_SSO_SCOPES_FILE | String | Path to file containing OIDC scopes. | +| DNS_SERVER_SSO_REDIRECT_URI | String | The OIDC redirect URI (overrides auto-detection). | +| DNS_SERVER_SSO_REDIRECT_URI_FILE | String | Path to file containing the OIDC redirect URI. | +| DNS_SERVER_SSO_METADATA_ADDRESS | String | The OIDC metadata endpoint URL (optional, auto-discovered if not set). | +| DNS_SERVER_SSO_METADATA_ADDRESS_FILE | String | Path to file containing the OIDC metadata endpoint URL. | +| DNS_SERVER_SSO_ALLOW_HTTP | Boolean | Allow OIDC metadata over HTTP. **INSECURE** - only use behind TLS-terminating reverse proxy. Default: `false`. | +| DNS_SERVER_SSO_ALLOW_SIGNUP | Boolean | Allow automatic provisioning of new users via SSO. Default: `false`. | +| DNS_SERVER_SSO_DEFAULT_GROUP | String | Default group name to assign all auto-provisioned SSO users. Leave empty for no default group. | +| DNS_SERVER_SSO_DEFAULT_GROUP_FILE | String | Path to file containing the default group name. | +| DNS_SERVER_SSO_GROUP_MAPPINGS | String | JSON mapping of OIDC group GUIDs/claims to DNS Server groups (e.g., `{"oidc-group-guid": "Admins"}`). | +| DNS_SERVER_SSO_GROUP_MAPPINGS_FILE | String | Path to file containing JSON group mappings. | +| DNS_SERVER_SSO_VERBOSE_LOGGING | Boolean | Enable verbose logging of OIDC claims and SSO flow (for debugging). Default: `false`. | +| DNS_SERVER_SSO_MAX_AUTO_PROVISION | Integer | Maximum number of users that can be auto-provisioned via SSO. Default: `25`. Set to `0` for unlimited. | +| DNS_SERVER_SSO_PROVISIONING_RATE_LIMIT | Integer | Maximum SSO auto-provisioning attempts per IP address per hour. Default: `25`. Set to `0` for unlimited. | + +**SSO Notes:** +- The `_FILE` variants allow reading sensitive values from files (Docker secrets pattern) +- Environment variables take precedence over web console configuration +- When environment variables are set, corresponding UI fields become read-only +- Clear Authority and Client ID in the web console to disable SSO + +## Example SSO Configurations + +### Microsoft Entra ID (Azure AD) + +```bash +DNS_SERVER_SSO_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/v2.0 +DNS_SERVER_SSO_CLIENT_ID=your-application-client-id +DNS_SERVER_SSO_CLIENT_SECRET_FILE=/run/secrets/sso_client_secret +DNS_SERVER_SSO_SCOPES=openid profile email +DNS_SERVER_SSO_ALLOW_SIGNUP=true +DNS_SERVER_SSO_DEFAULT_GROUP=Administrators +DNS_SERVER_SSO_GROUP_MAPPINGS='{"group-object-id-1":"Administrators","group-object-id-2":"DNS Administrators"}' +DNS_SERVER_SSO_VERBOSE_LOGGING=false +``` + +**Note**: Replace `{tenant-id}` with your Azure AD tenant ID. Group GUIDs can be found in Azure Portal under Azure AD > Groups. + +### Generic OIDC Provider + +```bash +DNS_SERVER_SSO_AUTHORITY=https://your-oidc-provider.com/ +DNS_SERVER_SSO_CLIENT_ID=your-client-id +DNS_SERVER_SSO_CLIENT_SECRET_FILE=/run/secrets/sso_client_secret +DNS_SERVER_SSO_METADATA_ADDRESS=https://your-oidc-provider.com/.well-known/openid-configuration +DNS_SERVER_SSO_SCOPES=openid profile email +DNS_SERVER_SSO_CALLBACK_PATH=/oidc/callback +DNS_SERVER_SSO_ALLOW_SIGNUP=false +DNS_SERVER_SSO_ALLOW_HTTP=false +```