mirror of
https://github.com/tgstation/tgstation-server.git
synced 2026-08-21 12:06:59 +01:00
Add OIDC strict mode
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user