From ffc5bccfd15d63b5b7588bfd7bf9b5c96c4bd04d Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 9 Mar 2025 22:06:57 -0400 Subject: [PATCH] Add OIDC strict mode --- .../Response/ServerInformationResponse.cs | 5 + .../Authority/LoginAuthority.cs | 16 +- .../Configuration/OidcConfiguration.cs | 7 + .../Configuration/SecurityConfiguration.cs | 5 + .../Controllers/ApiRootController.cs | 1 + src/Tgstation.Server.Host/Core/Application.cs | 73 +++++---- .../GraphQL/Types/Users.cs | 15 ++ .../Security/AuthenticationContextFactory.cs | 144 +++++++++++++++++- .../Security/ITokenValidator.cs | 8 +- 9 files changed, 237 insertions(+), 37 deletions(-) diff --git a/src/Tgstation.Server.Api/Models/Response/ServerInformationResponse.cs b/src/Tgstation.Server.Api/Models/Response/ServerInformationResponse.cs index 7fb4b689b9..ea5497acfc 100644 --- a/src/Tgstation.Server.Api/Models/Response/ServerInformationResponse.cs +++ b/src/Tgstation.Server.Api/Models/Response/ServerInformationResponse.cs @@ -41,6 +41,11 @@ namespace Tgstation.Server.Api.Models.Response /// public List? OidcProviderInfos { get; set; } + /// + /// If only OIDC logins and registration is allowed. + /// + public bool OidcStrictMode { get; set; } + /// /// If there is a server update in progress. /// diff --git a/src/Tgstation.Server.Host/Authority/LoginAuthority.cs b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs index c98863d01c..e7a978ca5b 100644 --- a/src/Tgstation.Server.Host/Authority/LoginAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs @@ -5,11 +5,13 @@ using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Host.Authority.Core; +using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.GraphQL.Mutations.Payloads; using Tgstation.Server.Host.Models; @@ -58,6 +60,11 @@ namespace Tgstation.Server.Host.Authority /// readonly ISessionInvalidationTracker sessionInvalidationTracker; + /// + /// The for the . + /// + readonly SecurityConfiguration securityConfiguration; + /// /// Generate an for a given . /// @@ -106,6 +113,7 @@ namespace Tgstation.Server.Host.Authority /// The value of . /// The value of . /// The value of . + /// The containing the value of . public LoginAuthority( IAuthenticationContext authenticationContext, IDatabaseContext databaseContext, @@ -116,7 +124,8 @@ namespace Tgstation.Server.Host.Authority ITokenFactory tokenFactory, ICryptographySuite cryptographySuite, IIdentityCache identityCache, - ISessionInvalidationTracker sessionInvalidationTracker) + ISessionInvalidationTracker sessionInvalidationTracker, + IOptions securityConfigurationOptions) : base( authenticationContext, databaseContext, @@ -129,11 +138,16 @@ namespace Tgstation.Server.Host.Authority this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite)); this.identityCache = identityCache ?? throw new ArgumentNullException(nameof(identityCache)); this.sessionInvalidationTracker = sessionInvalidationTracker ?? throw new ArgumentNullException(nameof(sessionInvalidationTracker)); + securityConfiguration = securityConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(securityConfigurationOptions)); } /// public async ValueTask> AttemptLogin(CancellationToken cancellationToken) { + // password and oauth logins disabled + if (securityConfiguration.OidcStrictMode) + return Unauthorized(); + var headers = apiHeadersProvider.ApiHeaders; if (headers == null) return GenerateHeadersExceptionResponse(apiHeadersProvider.HeadersException!); diff --git a/src/Tgstation.Server.Host/Configuration/OidcConfiguration.cs b/src/Tgstation.Server.Host/Configuration/OidcConfiguration.cs index 4cdcb72ebb..3a7062bb44 100644 --- a/src/Tgstation.Server.Host/Configuration/OidcConfiguration.cs +++ b/src/Tgstation.Server.Host/Configuration/OidcConfiguration.cs @@ -1,5 +1,7 @@ using System; +using Microsoft.IdentityModel.JsonWebTokens; + namespace Tgstation.Server.Host.Configuration { /// @@ -41,5 +43,10 @@ namespace Tgstation.Server.Host.Configuration /// Image URL that should be used to theme this OIDC provider. /// public Uri? ThemeIconUrl { get; set; } + + /// + /// The name of the claim used to set the user's name. + /// + public string UsernameClaim { get; set; } = JwtRegisteredClaimNames.PreferredUsername; } } diff --git a/src/Tgstation.Server.Host/Configuration/SecurityConfiguration.cs b/src/Tgstation.Server.Host/Configuration/SecurityConfiguration.cs index 8f542b3f83..14bffaf2bd 100644 --- a/src/Tgstation.Server.Host/Configuration/SecurityConfiguration.cs +++ b/src/Tgstation.Server.Host/Configuration/SecurityConfiguration.cs @@ -63,6 +63,11 @@ namespace Tgstation.Server.Host.Configuration /// public string? CustomTokenSigningKeyBase64 { get; set; } + /// + /// If OIDC strict mode should be enabled. This mode enforces the existence of at least one ion and allows users to register using them. Users must have the tgstation-server.group_id role set to a valid TGS group ID to login. This mode disables regular and OAuth login methods. + /// + public bool OidcStrictMode { get; set; } + /// /// OAuth provider settings. /// diff --git a/src/Tgstation.Server.Host/Controllers/ApiRootController.cs b/src/Tgstation.Server.Host/Controllers/ApiRootController.cs index bc58e7cc93..ab7000e9e1 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiRootController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiRootController.cs @@ -172,6 +172,7 @@ namespace Tgstation.Server.Host.Controllers OAuthProviderInfos = oAuthProviders.ProviderInfos(), OidcProviderInfos = securityConfiguration.OidcProviderInfos().ToList(), UpdateInProgress = serverControl.UpdateInProgress, + OidcStrictMode = securityConfiguration.OidcStrictMode, }); } diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index ee013656f6..060e31b641 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -870,35 +870,8 @@ namespace Tgstation.Server.Host.Core options.ClientId = config.ClientId; options.ClientSecret = config.ClientSecret; - options.Scope.Add(OpenIdConnectScope.OpenId); - options.Scope.Add(OpenIdConnectScope.OfflineAccess); - -#if DEBUG - options.RequireHttpsMetadata = false; -#endif - - options.SaveTokens = true; - options.ResponseType = OpenIdConnectResponseType.Code; - options.MapInboundClaims = false; - - options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; - - var basePath = $"/oidc/{configName}/"; - options.CallbackPath = new PathString(basePath + "signin-callback"); - options.SignedOutCallbackPath = new PathString(basePath + "signout-callback"); - options.RemoteSignOutPath = new PathString(basePath + "signout"); - options.Events = new OpenIdConnectEvents { - OnTokenValidated = context => context - .HttpContext - .RequestServices - .GetRequiredService() - .ValidateOidcToken( - context, - context - .HttpContext - .RequestAborted), OnRemoteFailure = context => { context.HandleResponse(); @@ -919,6 +892,52 @@ namespace Tgstation.Server.Host.Core return Task.CompletedTask; }, }; + + Task CompleteAuth(RemoteAuthenticationContext context) + => context + .HttpContext + .RequestServices + .GetRequiredService() + .ValidateOidcToken( + context, + configName, + context + .HttpContext + .RequestAborted); + + if (securityConfiguration.OidcStrictMode) + { + options.GetClaimsFromUserInfoEndpoint = true; + options.ClaimActions.MapUniqueJsonKey(AuthenticationContextFactory.TgsGroupIdClaimName, AuthenticationContextFactory.TgsGroupIdClaimName); + options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters + { + NameClaimType = config.UsernameClaim, + RoleClaimType = "roles", + }; + + options.Scope.Add(OpenIdConnectScope.Profile); + options.Events.OnUserInformationReceived = CompleteAuth; + } + else + options.Events.OnTokenValidated = CompleteAuth; + + options.Scope.Add(OpenIdConnectScope.OpenId); + options.Scope.Add(OpenIdConnectScope.OfflineAccess); + +#if DEBUG + options.RequireHttpsMetadata = false; +#endif + + options.SaveTokens = true; + options.ResponseType = OpenIdConnectResponseType.Code; + options.MapInboundClaims = false; + + options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; + + var basePath = $"/oidc/{configName}/"; + options.CallbackPath = new PathString(basePath + "signin-callback"); + options.SignedOutCallbackPath = new PathString(basePath + "signout-callback"); + options.RemoteSignOutPath = new PathString(basePath + "signout"); }); } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Users.cs b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs index 04ac751b25..8ec3667fe3 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/Users.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs @@ -8,7 +8,10 @@ using HotChocolate.Data; using HotChocolate.Types; using HotChocolate.Types.Relay; +using Microsoft.Extensions.Options; + using Tgstation.Server.Host.Authority; +using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Models.Transformers; using Tgstation.Server.Host.Security; @@ -27,6 +30,18 @@ namespace Tgstation.Server.Host.GraphQL.Types /// A new . public UserGroups Groups() => new(); + /// + /// If only OIDC logins and registration is allowed. + /// + /// The containing the . + /// if OIDC strict mode is enabled, otherwise. + public bool OidcStrictMode( + [Service] IOptions securityConfigurationOptions) + { + ArgumentNullException.ThrowIfNull(securityConfigurationOptions); + return securityConfigurationOptions.Value.OidcStrictMode; + } + /// /// Gets the current . /// diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs index 8422ba806f..1b2f9f13e8 100644 --- a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs +++ b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs @@ -1,10 +1,13 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Security.Claims; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -12,8 +15,10 @@ using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using Tgstation.Server.Api; +using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Utils; @@ -22,6 +27,11 @@ namespace Tgstation.Server.Host.Security /// sealed class AuthenticationContextFactory : ITokenValidator, IDisposable { + /// + /// Claim name used to set user groups in OIDC strict mode. + /// + public const string TgsGroupIdClaimName = "tgstation-server-group-id"; + /// /// The the created. /// @@ -47,6 +57,11 @@ namespace Tgstation.Server.Host.Security /// readonly SwarmConfiguration swarmConfiguration; + /// + /// The for the . + /// + readonly SecurityConfiguration securityConfiguration; + /// /// Backing field for . /// @@ -93,12 +108,14 @@ namespace Tgstation.Server.Host.Security /// The value of . /// The containing the value of . /// The containing the value of . + /// The containing the value of . /// The value of . public AuthenticationContextFactory( IDatabaseContext databaseContext, IIdentityCache identityCache, IApiHeadersProvider apiHeadersProvider, IOptions swarmConfigurationOptions, + IOptions securityConfigurationOptions, ILogger logger) { this.databaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext)); @@ -107,6 +124,8 @@ namespace Tgstation.Server.Host.Security apiHeaders = apiHeadersProvider.ApiHeaders; swarmConfiguration = swarmConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(swarmConfigurationOptions)); + securityConfiguration = securityConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(securityConfigurationOptions)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); currentAuthenticationContext = new AuthenticationContext(); @@ -209,11 +228,13 @@ namespace Tgstation.Server.Host.Security } /// - public async Task ValidateOidcToken(Microsoft.AspNetCore.Authentication.OpenIdConnect.TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken) + public async Task ValidateOidcToken(RemoteAuthenticationContext tokenValidatedContext, string schemeKey, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(tokenValidatedContext); - var principal = new ClaimsPrincipal(new ClaimsIdentity(tokenValidatedContext.SecurityToken.Claims)); + var principal = tokenValidatedContext.Principal; + if (principal == null) + throw new InvalidOperationException("Expected a valid principal here!"); var userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub); if (userIdClaim == default) @@ -227,18 +248,127 @@ namespace Tgstation.Server.Host.Security .AsQueryable() .Where(oidcConnection => oidcConnection.ExternalUserId == userId && oidcConnection.SchemeKey == deprefixedScheme) .Include(oidcConnection => oidcConnection.User) - .ThenInclude(user => user!.PermissionSet) + .ThenInclude(user => user!.Group) + .ThenInclude(group => group!.PermissionSet) .FirstOrDefaultAsync(cancellationToken); - if (connection == default) + + User user; + if (!securityConfiguration.OidcStrictMode) { - tokenValidatedContext.Fail($"Unable to find user with OidcConnection for {deprefixedScheme}/{userId}!"); - return; + if (connection == default) + { + tokenValidatedContext.Fail($"Unable to find user with OidcConnection for {deprefixedScheme}/{userId}!"); + return; + } + + user = connection.User!; + } + else + { + var groupClaim = principal.FindFirst(TgsGroupIdClaimName); + long? groupId; + if (groupClaim == default) + groupId = null; + else if (Int64.TryParse(groupClaim.Value, out long groupIdParsed)) + groupId = groupIdParsed; + else + { + tokenValidatedContext.Fail($"User has non-numeric '{TgsGroupIdClaimName}' claim!"); + return; + } + + UserGroup? group = groupId.HasValue + ? await databaseContext + .Groups + .AsQueryable() + .Where(group => group.Id == groupId.Value) + .Include(group => group.PermissionSet) + .FirstOrDefaultAsync(cancellationToken) + : null; + + var missingClaimError = $"User missing '{TgsGroupIdClaimName}' claim!"; + if (connection == default) + { + var username = principal.Identity?.Name; + if (username == null) + { + tokenValidatedContext.Fail("Failed to retrieve user's name from retrieved claims!"); + return; + } + + if (group == null) + { + tokenValidatedContext.Fail( + groupId.HasValue + ? $"'{TgsGroupIdClaimName}' does not point to a valid group!" + : missingClaimError); + return; + } + + var tgsUser = await databaseContext + .Users + .GetTgsUser( + dbUser => new User + { + Id = dbUser.Id!.Value, + Name = dbUser.Name, + }, + cancellationToken); + + user = new User + { + CreatedAt = DateTimeOffset.UtcNow, + CanonicalName = User.CanonicalizeName(username), + Name = username, + CreatedBy = tgsUser, + Enabled = true, + Group = group, + OidcConnections = new List + { + new OidcConnection + { + SchemeKey = schemeKey, + ExternalUserId = userId, + }, + }, + PasswordHash = "_", // This can't be hashed + }; + + databaseContext.Users.Add(user); + } + else + { + user = connection.User!; + + // group update + if (group == null) + { + user.PermissionSet = new PermissionSet + { + AdministrationRights = AdministrationRights.None, + InstanceManagerRights = InstanceManagerRights.None, + }; + user.GroupId = null; + user.Enabled = false; + + tokenValidatedContext.Fail(missingClaimError); + return; + } + + user.Group = group; + if (user.PermissionSet != null) + databaseContext.PermissionSets.Remove(user.PermissionSet); + + user.Enabled = true; + } + + await databaseContext.Save(cancellationToken); } var expires = ParseTime(principal, JwtRegisteredClaimNames.Exp); currentAuthenticationContext.Initialize( - connection.User!, + user, expires, Guid.NewGuid().ToString(), null, diff --git a/src/Tgstation.Server.Host/Security/ITokenValidator.cs b/src/Tgstation.Server.Host/Security/ITokenValidator.cs index 34dfa54747..e7d17f14aa 100644 --- a/src/Tgstation.Server.Host/Security/ITokenValidator.cs +++ b/src/Tgstation.Server.Host/Security/ITokenValidator.cs @@ -1,6 +1,9 @@ using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; + namespace Tgstation.Server.Host.Security { /// @@ -19,9 +22,10 @@ namespace Tgstation.Server.Host.Security /// /// Handles OIDC s. /// - /// The . + /// The for . + /// The scheme key being used to login. /// The for the operation. /// A representing the running operation. - Task ValidateOidcToken(Microsoft.AspNetCore.Authentication.OpenIdConnect.TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken); + Task ValidateOidcToken(RemoteAuthenticationContext tokenValidatedContext, string schemeKey, CancellationToken cancellationToken); } }