Add OIDC strict mode

This commit is contained in:
Jordan Dominion
2025-03-09 22:06:57 -04:00
parent 2e75125860
commit ffc5bccfd1
9 changed files with 237 additions and 37 deletions
@@ -41,6 +41,11 @@ namespace Tgstation.Server.Api.Models.Response
/// </summary>
public List<OidcProviderInfo>? OidcProviderInfos { get; set; }
/// <summary>
/// If only OIDC logins and registration is allowed.
/// </summary>
public bool OidcStrictMode { get; set; }
/// <summary>
/// If there is a server update in progress.
/// </summary>
@@ -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
/// </summary>
readonly ISessionInvalidationTracker sessionInvalidationTracker;
/// <summary>
/// The <see cref="SecurityConfiguration"/> for the <see cref="LoginAuthority"/>.
/// </summary>
readonly SecurityConfiguration securityConfiguration;
/// <summary>
/// Generate an <see cref="AuthorityResponse{TResult}"/> for a given <paramref name="headersException"/>.
/// </summary>
@@ -106,6 +113,7 @@ namespace Tgstation.Server.Host.Authority
/// <param name="cryptographySuite">The value of <see cref="cryptographySuite"/>.</param>
/// <param name="identityCache">The value of <see cref="identityCache"/>.</param>
/// <param name="sessionInvalidationTracker">The value of <see cref="sessionInvalidationTracker"/>.</param>
/// <param name="securityConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="securityConfiguration"/>.</param>
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<SecurityConfiguration> 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));
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse<LoginResult>> AttemptLogin(CancellationToken cancellationToken)
{
// password and oauth logins disabled
if (securityConfiguration.OidcStrictMode)
return Unauthorized<LoginResult>();
var headers = apiHeadersProvider.ApiHeaders;
if (headers == null)
return GenerateHeadersExceptionResponse<LoginResult>(apiHeadersProvider.HeadersException!);
@@ -1,5 +1,7 @@
using System;
using Microsoft.IdentityModel.JsonWebTokens;
namespace Tgstation.Server.Host.Configuration
{
/// <summary>
@@ -41,5 +43,10 @@ namespace Tgstation.Server.Host.Configuration
/// Image URL that should be used to theme this OIDC provider.
/// </summary>
public Uri? ThemeIconUrl { get; set; }
/// <summary>
/// The name of the claim used to set the user's name.
/// </summary>
public string UsernameClaim { get; set; } = JwtRegisteredClaimNames.PreferredUsername;
}
}
@@ -63,6 +63,11 @@ namespace Tgstation.Server.Host.Configuration
/// </summary>
public string? CustomTokenSigningKeyBase64 { get; set; }
/// <summary>
/// If OIDC strict mode should be enabled. This mode enforces the existence of at least one <see cref="OpenIDConnect"/>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.
/// </summary>
public bool OidcStrictMode { get; set; }
/// <summary>
/// OAuth provider settings.
/// </summary>
@@ -172,6 +172,7 @@ namespace Tgstation.Server.Host.Controllers
OAuthProviderInfos = oAuthProviders.ProviderInfos(),
OidcProviderInfos = securityConfiguration.OidcProviderInfos().ToList(),
UpdateInProgress = serverControl.UpdateInProgress,
OidcStrictMode = securityConfiguration.OidcStrictMode,
});
}
+46 -27
View File
@@ -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<ITokenValidator>()
.ValidateOidcToken(
context,
context
.HttpContext
.RequestAborted),
OnRemoteFailure = context =>
{
context.HandleResponse();
@@ -919,6 +892,52 @@ namespace Tgstation.Server.Host.Core
return Task.CompletedTask;
},
};
Task CompleteAuth(RemoteAuthenticationContext<OpenIdConnectOptions> context)
=> context
.HttpContext
.RequestServices
.GetRequiredService<ITokenValidator>()
.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");
});
}
}
@@ -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
/// <returns>A new <see cref="UserGroups"/>.</returns>
public UserGroups Groups() => new();
/// <summary>
/// If only OIDC logins and registration is allowed.
/// </summary>
/// <param name="securityConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the <see cref="SecurityConfiguration"/>.</param>
/// <returns><see langword="true"/> if OIDC strict mode is enabled, <see langword="false"/> otherwise.</returns>
public bool OidcStrictMode(
[Service] IOptions<SecurityConfiguration> securityConfigurationOptions)
{
ArgumentNullException.ThrowIfNull(securityConfigurationOptions);
return securityConfigurationOptions.Value.OidcStrictMode;
}
/// <summary>
/// Gets the current <see cref="User"/>.
/// </summary>
@@ -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
/// <inheritdoc cref="IAuthenticationContext" />
sealed class AuthenticationContextFactory : ITokenValidator, IDisposable
{
/// <summary>
/// Claim name used to set user groups in OIDC strict mode.
/// </summary>
public const string TgsGroupIdClaimName = "tgstation-server-group-id";
/// <summary>
/// The <see cref="IAuthenticationContext"/> the <see cref="AuthenticationContextFactory"/> created.
/// </summary>
@@ -47,6 +57,11 @@ namespace Tgstation.Server.Host.Security
/// </summary>
readonly SwarmConfiguration swarmConfiguration;
/// <summary>
/// The <see cref="SecurityConfiguration"/> for the <see cref="AuthenticationContextFactory"/>.
/// </summary>
readonly SecurityConfiguration securityConfiguration;
/// <summary>
/// Backing field for <see cref="CurrentAuthenticationContext"/>.
/// </summary>
@@ -93,12 +108,14 @@ namespace Tgstation.Server.Host.Security
/// <param name="identityCache">The value of <see cref="identityCache"/>.</param>
/// <param name="apiHeadersProvider">The <see cref="IApiHeadersProvider"/> containing the value of <see cref="apiHeaders"/>.</param>
/// <param name="swarmConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="swarmConfiguration"/>.</param>
/// <param name="securityConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="securityConfiguration"/>.</param>
/// <param name="logger">The value of <see cref="logger"/>.</param>
public AuthenticationContextFactory(
IDatabaseContext databaseContext,
IIdentityCache identityCache,
IApiHeadersProvider apiHeadersProvider,
IOptions<SwarmConfiguration> swarmConfigurationOptions,
IOptions<SecurityConfiguration> securityConfigurationOptions,
ILogger<AuthenticationContextFactory> 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
}
/// <inheritdoc />
public async Task ValidateOidcToken(Microsoft.AspNetCore.Authentication.OpenIdConnect.TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken)
public async Task ValidateOidcToken(RemoteAuthenticationContext<OpenIdConnectOptions> 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<OidcConnection>
{
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,
@@ -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
{
/// <summary>
@@ -19,9 +22,10 @@ namespace Tgstation.Server.Host.Security
/// <summary>
/// Handles OIDC <paramref name="tokenValidatedContext"/>s.
/// </summary>
/// <param name="tokenValidatedContext">The <see cref="Microsoft.AspNetCore.Authentication.OpenIdConnect.TokenValidatedContext"/>.</param>
/// <param name="tokenValidatedContext">The <see cref="RemoteAuthenticationContext{TOptions}"/> for <see cref="OpenIdConnectOptions"/>.</param>
/// <param name="schemeKey">The scheme key being used to login.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task"/> representing the running operation.</returns>
Task ValidateOidcToken(Microsoft.AspNetCore.Authentication.OpenIdConnect.TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken);
Task ValidateOidcToken(RemoteAuthenticationContext<OpenIdConnectOptions> tokenValidatedContext, string schemeKey, CancellationToken cancellationToken);
}
}