Merge pull request #2144 from tgstation/2097-OIDC

OpenID Connect Support and Strict Mode
This commit is contained in:
Jordan Dominion
2025-03-15 16:51:27 -04:00
committed by GitHub
60 changed files with 6024 additions and 219 deletions
+25 -9
View File
@@ -321,11 +321,19 @@ Create an `appsettings.Production.yml` file next to `appsettings.yml`. This will
- `Swarm:UpdateRequiredNodeCount`: Should be set to the total number of servers in your swarm minus 1. Prevents updates from occurring unless the non-controller server count in the swarm is greater than or equal to this value.
- `Security:OAuth:<Provider Name>`: Sets the OAuth client ID and secret for a given `<Provider Name>`. The currently supported providers are `Keycloak`, `GitHub`, `Discord`, `InvisionCommunity` and `TGForums`. Setting these fields to `null` disables logins AND gateway auth with the provider, but does not stop users from associating their accounts using the API. Sample Entry:
- `Telemetry:DisableVersionReporting`: Prevents you installation and the version you're using from being reported on the source repository's deployments list
- `Telemetry:ServerFriendlyName`: Prevents anonymous TGS version usage statistics from being sent to be displayed on the repository.
- `Telemetry:VersionReportingRepositoryId`: The repository telemetry is sent to. For security reasons, this is not the main TGS repo. See the [tgstation-server-deployments](https://github.com/tgstation/tgstation-server-deployments) repository for more information.
#### OAuth Configuration
- `Security:OAuth:<Provider Name>`: Sets the OAuth client ID and secret for a given `<Provider Name>`. The currently supported providers are `GitHub`, `Discord`, `InvisionCommunity` and `TGForums`. Setting these fields to `null` disables logins AND gateway auth with the provider, but does not stop users from associating their accounts using the API. Sample Entry:
```yml
Security:
OAuth:
Keycloak:
InvisionCommunity:
ClientId: "..."
ClientSecret: "..."
RedirectUrl: "..."
@@ -336,22 +344,30 @@ Security:
The following providers use the `RedirectUrl` setting:
- GitHub
- TGForums
- Keycloak
- InvisionCommunity
The following providers use the `ServerUrl` setting:
- Keycloak
- InvisionCommunity
Gateway auth simply allows the users to authenticate with the service using the configuration you provide and have their impersonation token passed back to the client. An example of this is using GitHub gateway auth to allow clients to enumerate pull requests without getting rate limited.
- `Telemetry:DisableVersionReporting`: Prevents you installation and the version you're using from being reported on the source repository's deployments list
#### OpenID Connect Configuration
- `Security:Oidc:<Scheme Key>`: Sets up an OpenID Connect Provider with given "Scheme Key". Sample entry:
```yml
Security:
Oidc:
Example:
Authority: "..." # This is the root of the URL containing the "/.well-known/openid-configuration" endpoint
ClientId: "..."
ClientSecret: "..."
FriendlyName: "Example Provider" # Friendly name is how this provider is displayed in UIs
ThemeColour: "#ff0000" # (Optional) Hex color code indicating the color used to theme UI elements relating to this provider
ThemeIconUrl: "..." # (Optional) Public URL of an image that can be used to theme UI elements relating to this provider
UsernameClaim: "preferred_username" # (Optional) Name of claim used to set TGS usernames upon registration. By default "preferred_username" is used. Only applies when using OidcStrictMode.
```
- `Telemetry:ServerFriendlyName`: Prevents anonymous TGS version usage statistics from being sent to be displayed on the repository.
- `Telemetry:VersionReportingRepositoryId`: The repository telemetry is sent to. For security reasons, this is not the main TGS repo. See the [tgstation-server-deployments](https://github.com/tgstation/tgstation-server-deployments) repository for more information.
- `Security:OidcStrictMode`: Boolean flag that, when `true`, disables password and OAuth logins, password changes, individual permission set assignment, and enables user registration using OpenID Connect providers. The claim name `tgstation-server-group-id` is used to dictate what TGS group users are registered to.
### Database Configuration
+6 -6
View File
@@ -3,13 +3,13 @@
<!-- Integration tests will ensure they match across the board -->
<Import Project="WebpanelVersion.props" />
<PropertyGroup>
<TgsCoreVersion>6.14.1</TgsCoreVersion>
<TgsConfigVersion>5.5.0</TgsConfigVersion>
<TgsRestVersion>10.12.1</TgsRestVersion>
<TgsGraphQLVersion>0.5.0</TgsGraphQLVersion>
<TgsCoreVersion>6.15.0</TgsCoreVersion>
<TgsConfigVersion>5.6.0</TgsConfigVersion>
<TgsRestVersion>10.13.0</TgsRestVersion>
<TgsGraphQLVersion>0.6.0</TgsGraphQLVersion>
<TgsCommonLibraryVersion>7.0.0</TgsCommonLibraryVersion>
<TgsApiLibraryVersion>17.0.1</TgsApiLibraryVersion>
<TgsClientVersion>20.0.0</TgsClientVersion>
<TgsApiLibraryVersion>18.0.0</TgsApiLibraryVersion>
<TgsClientVersion>21.0.0</TgsClientVersion>
<TgsDmapiVersion>7.3.1</TgsDmapiVersion>
<TgsInteropVersion>5.10.0</TgsInteropVersion>
<TgsHostWatchdogVersion>1.6.0</TgsHostWatchdogVersion>
+7 -1
View File
@@ -584,7 +584,7 @@ namespace Tgstation.Server.Api.Models
/// Attempted to set <see cref="Internal.UserApiBase.OAuthConnections"/> for the admin user.
/// </summary>
[Description("The admin user cannot use OAuth connections!")]
AdminUserCannotOAuth,
AdminUserCannotHaveServiceConnection,
/// <summary>
/// Attempted to login with a disabled OAuth provider.
@@ -669,5 +669,11 @@ namespace Tgstation.Server.Api.Models
/// </summary>
[Description("GraphQL swarm remote gateways not implemented!")]
RemoteGatewaysNotImplemented,
/// <summary>
/// Attempted to edit a user security property which is locked down due to the server being in <see cref="Response.ServerInformationResponse.OidcStrictMode"/>.
/// </summary>
[Description("One or more of the fields requested to be updated cannot be due to the server being in OIDC strict mode.")]
BadUserEditDueToOidcStrictMode,
}
}
@@ -10,6 +10,11 @@ namespace Tgstation.Server.Api.Models.Internal
/// </summary>
public ICollection<OAuthConnection>? OAuthConnections { get; set; }
/// <summary>
/// List of <see cref="OidcConnection"/>s associated with the user.
/// </summary>
public ICollection<OidcConnection>? OidcConnections { get; set; }
/// <summary>
/// The <see cref="Models.PermissionSet"/> directly associated with the user.
/// </summary>
@@ -1,10 +1,12 @@
using Newtonsoft.Json;
using System;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace Tgstation.Server.Api.Models
{
/// <summary>
/// List of OAuth providers supported by TGS.
/// List of OAuth2.0 providers supported by TGS that do not support OIDC.
/// </summary>
[JsonConverter(typeof(StringEnumConverter))]
public enum OAuthProvider
@@ -20,13 +22,15 @@ namespace Tgstation.Server.Api.Models
Discord,
/// <summary>
/// https://tgstation13.org.
/// Pre-hostening https://tgstation13.org. No longer supported.
/// </summary>
[Obsolete("tgstation13.org no longer has a custom OAuth solution. This option will be removed in a future TGS version.")]
TGForums,
/// <summary>
/// https://www.keycloak.org.
/// </summary>
[Obsolete("This should now be implemented as an OIDC provider. This option will be removed in a future TGS version.")]
Keycloak,
/// <summary>
@@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
using Newtonsoft.Json;
namespace Tgstation.Server.Api.Models
{
/// <summary>
/// Represents a valid OIDC connection.
/// </summary>
public class OidcConnection
{
/// <summary>
/// The <see cref="OidcProviderInfo.SchemeKey"/> of the <see cref="OidcConnection"/>.
/// </summary>]
[Required]
[RequestOptions(FieldPresence.Required)]
[StringLength(Limits.MaximumIndexableStringLength, MinimumLength = 1)]
public string? SchemeKey { get; set; }
/// <summary>
/// The ID of the user in the OIDC proivder ("sub" claim).
/// </summary>
[Required]
[RequestOptions(FieldPresence.Required)]
[StringLength(Limits.MaximumIndexableStringLength, MinimumLength = 1)]
public string? ExternalUserId { get; set; }
}
}
@@ -0,0 +1,30 @@
using System;
namespace Tgstation.Server.Api.Models
{
/// <summary>
/// Represents a configured OIDC provider.
/// </summary>
public sealed class OidcProviderInfo
{
/// <summary>
/// The scheme key used to reference the <see cref="OidcProviderInfo"/>. Unique amongst providers.
/// </summary>
public string? SchemeKey { get; set; }
/// <summary>
/// The provider's name as it should be displayed to the user.
/// </summary>
public string? FriendlyName { get; set; }
/// <summary>
/// Colour that should be used to theme this OIDC provider.
/// </summary>
public string? ThemeColour { get; set; }
/// <summary>
/// Image URL that should be used to theme this OIDC provider.
/// </summary>
public Uri? ThemeIconUrl { get; set; }
}
}
@@ -36,6 +36,16 @@ namespace Tgstation.Server.Api.Models.Response
/// </summary>
public Dictionary<OAuthProvider, OAuthProviderInfo>? OAuthProviderInfos { get; set; }
/// <summary>
/// List of configured <see cref="OidcProviderInfo"/>s.
/// </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>
@@ -44,9 +44,9 @@ namespace Tgstation.Server.Api.Rights
DownloadLogs = 1 << 5,
/// <summary>
/// User can modify their own <see cref="Models.Internal.UserApiBase.OAuthConnections"/>.
/// User can modify their own <see cref="Models.Internal.UserApiBase.OAuthConnections"/> and <see cref="Models.Internal.UserApiBase.OidcConnections"/>.
/// </summary>
EditOwnOAuthConnections = 1 << 6,
EditOwnServiceConnections = 1 << 6,
/// <summary>
/// User can upgrade/downgrade TGS using file uploads through the API.
@@ -1,5 +1,5 @@
mutation CreateUserFromOAuthConnection($name: String!, $oAuthConnections: [OAuthConnectionInput!]!) {
createUserByOAuthAndPermissionSet(input: { name: $name, oAuthConnections: $oAuthConnections }) {
createUserByServiceConnectionAndPermissionSet(input: { name: $name, oAuthConnections: $oAuthConnections }) {
errors {
... on ErrorMessageError {
additionalData
@@ -7,7 +7,7 @@ mutation CreateUserGroup($name: String!) {
administrationRights {
canChangeVersion
canDownloadLogs
canEditOwnOAuthConnections
canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
@@ -7,7 +7,7 @@ mutation CreateUserGroupWithInstanceListPerm($name: String!) {
administrationRights {
canChangeVersion
canDownloadLogs
canEditOwnOAuthConnections
canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
@@ -6,7 +6,7 @@ mutation SetFullPermsOnUserGroup($id: ID!) {
administrationRights: {
canChangeVersion: true
canDownloadLogs: true
canEditOwnOAuthConnections: true
canEditOwnServiceConnections: true
canEditOwnPassword: true
canReadUsers: true
canWriteUsers: true
@@ -36,7 +36,7 @@ mutation SetFullPermsOnUserGroup($id: ID!) {
administrationRights {
canChangeVersion
canDownloadLogs
canEditOwnOAuthConnections
canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
@@ -10,27 +10,27 @@ mutation SetUserGroup($id: ID!, $newGroupId: ID!) {
user {
ownedPermissionSet {
instanceManagerRights {
canCreate
canDelete
canGrantPermissions
canList
canRead
canRelocate
canRename
canSetAutoUpdate
canSetChatBotLimit
canSetConfiguration
canSetOnline
canCreate
canDelete
canGrantPermissions
canList
canRead
canRelocate
canRename
canSetAutoUpdate
canSetChatBotLimit
canSetConfiguration
canSetOnline
}
administrationRights {
canChangeVersion
canDownloadLogs
canEditOwnOAuthConnections
canEditOwnPassword
canReadUsers
canRestartHost
canUploadVersion
canWriteUsers
canChangeVersion
canDownloadLogs
canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
canUploadVersion
canWriteUsers
}
}
group {
@@ -12,7 +12,7 @@ mutation SetUserPermissionSet($id: ID!, $permissionSet: PermissionSetInput!) {
administrationRights {
canChangeVersion
canDownloadLogs
canEditOwnOAuthConnections
canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
@@ -37,7 +37,7 @@ mutation SetUserPermissionSet($id: ID!, $permissionSet: PermissionSetInput!) {
administrationRights {
canChangeVersion
canDownloadLogs
canEditOwnOAuthConnections
canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
@@ -20,7 +20,7 @@ query GetSomeGroupInfo($id: ID!) {
administrationRights {
canChangeVersion
canDownloadLogs
canEditOwnOAuthConnections
canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
@@ -20,7 +20,7 @@ query ReadCurrentUser {
administrationRights {
canChangeVersion
canDownloadLogs
canEditOwnOAuthConnections
canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
@@ -43,6 +43,14 @@ namespace Tgstation.Server.Host.Authority
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in an <see cref="global::System.Array"/> of <see cref="GraphQL.Types.OAuth.OAuthConnection"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<AuthorityResponse<GraphQL.Types.OAuth.OAuthConnection[]>> OAuthConnections(long userId, CancellationToken cancellationToken);
/// <summary>
/// Gets the <see cref="GraphQL.Types.OAuth.OidcConnection"/>s for the <see cref="User"/> with a given <paramref name="userId"/>.
/// </summary>
/// <param name="userId">The <see cref="EntityId.Id"/> of the <see cref="User"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in an <see cref="global::System.Array"/> of <see cref="GraphQL.Types.OAuth.OidcConnection"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<AuthorityResponse<GraphQL.Types.OAuth.OidcConnection[]>> OidcConnections(long userId, CancellationToken cancellationToken);
/// <summary>
/// Gets all registered <see cref="User"/>s.
/// </summary>
@@ -70,7 +78,7 @@ namespace Tgstation.Server.Host.Authority
/// <param name="updateRequest">The <see cref="UserUpdateRequest"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in am <see cref="AuthorityResponse{TResult}"/> for the created <see cref="User"/>.</returns>
[TgsAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword | AdministrationRights.EditOwnOAuthConnections)]
[TgsAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword | AdministrationRights.EditOwnServiceConnections)]
ValueTask<AuthorityResponse<User>> Update(UserUpdateRequest updateRequest, CancellationToken cancellationToken);
}
}
@@ -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!);
@@ -318,6 +332,12 @@ namespace Tgstation.Server.Host.Authority
(string? UserID, string AccessCode)? oauthResult;
try
{
// minor special case here until its removal
#pragma warning disable CS0618 // Type or member is obsolete
if (oAuthProvider == OAuthProvider.TGForums)
#pragma warning restore CS0618 // Type or member is obsolete
return (Unauthorized<TResult>(), null);
var validator = oAuthProviders
.GetValidator(oAuthProvider, forLogin);
@@ -41,6 +41,11 @@ namespace Tgstation.Server.Host.Authority
/// </summary>
readonly IOAuthConnectionsDataLoader oAuthConnectionsDataLoader;
/// <summary>
/// The <see cref="IOidcConnectionsDataLoader"/> for the <see cref="UserAuthority"/>.
/// </summary>
readonly IOidcConnectionsDataLoader oidcConnectionsDataLoader;
/// <summary>
/// The <see cref="ISystemIdentityFactory"/> for the <see cref="UserAuthority"/>.
/// </summary>
@@ -71,6 +76,11 @@ namespace Tgstation.Server.Host.Authority
/// </summary>
readonly IOptionsSnapshot<GeneralConfiguration> generalConfigurationOptions;
/// <summary>
/// The <see cref="IOptions{TOptions}"/> of <see cref="SecurityConfiguration"/> for the <see cref="UserAuthority"/>.
/// </summary>
readonly IOptions<SecurityConfiguration> securityConfigurationOptions;
/// <summary>
/// Implements the <see cref="usersDataLoader"/>.
/// </summary>
@@ -95,7 +105,7 @@ namespace Tgstation.Server.Host.Authority
}
/// <summary>
/// Implements the <see cref="usersDataLoader"/>.
/// Implements the <see cref="oAuthConnectionsDataLoader"/>.
/// </summary>
/// <param name="userIds">The <see cref="IReadOnlyCollection{T}"/> of <see cref="User"/> <see cref="EntityId.Id"/>s to load the OAuthConnections for.</param>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> to load from.</param>
@@ -117,10 +127,37 @@ namespace Tgstation.Server.Host.Authority
.ToListAsync(cancellationToken);
return list.ToLookup(
oauthConnection => oauthConnection.UserId,
oAuthConnection => oAuthConnection.UserId,
x => new GraphQL.Types.OAuth.OAuthConnection(x.ExternalUserId!, x.Provider));
}
/// <summary>
/// Implements the <see cref="oidcConnectionsDataLoader"/>.
/// </summary>
/// <param name="userIds">The <see cref="IReadOnlyCollection{T}"/> of <see cref="User"/> <see cref="EntityId.Id"/>s to load the OidcConnections for.</param>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> to load from.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="Dictionary{TKey, TValue}"/> of the requested <see cref="User"/>s.</returns>
[DataLoader]
public static async ValueTask<ILookup<long, GraphQL.Types.OAuth.OidcConnection>> GetOidcConnections(
IReadOnlyList<long> userIds,
IDatabaseContext databaseContext,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(userIds);
ArgumentNullException.ThrowIfNull(databaseContext);
var list = await databaseContext
.OidcConnections
.AsQueryable()
.Where(x => userIds.Contains(x.User!.Id!.Value))
.ToListAsync(cancellationToken);
return list.ToLookup(
oidcConnection => oidcConnection.UserId,
x => new GraphQL.Types.OAuth.OidcConnection(x.ExternalUserId!, x.SchemeKey!));
}
/// <summary>
/// Check if a given <paramref name="model"/> has a valid <see cref="UserName.Name"/> specified.
/// </summary>
@@ -147,24 +184,28 @@ namespace Tgstation.Server.Host.Authority
/// <param name="logger">The <see cref="ILogger"/> to use.</param>
/// <param name="usersDataLoader">The value of <see cref="usersDataLoader"/>.</param>
/// <param name="oAuthConnectionsDataLoader">The value of <see cref="oAuthConnectionsDataLoader"/>.</param>
/// <param name="oidcConnectionsDataLoader">The value of <see cref="oidcConnectionsDataLoader"/>.</param>
/// <param name="systemIdentityFactory">The value of <see cref="systemIdentityFactory"/>.</param>
/// <param name="permissionsUpdateNotifyee">The value of <see cref="permissionsUpdateNotifyee"/>.</param>
/// <param name="cryptographySuite">The value of <see cref="cryptographySuite"/>.</param>
/// <param name="sessionInvalidationTracker">The value of <see cref="sessionInvalidationTracker"/>.</param>
/// <param name="topicEventSender">The value of <see cref="topicEventSender"/>.</param>
/// <param name="generalConfigurationOptions">The value of <see cref="generalConfigurationOptions"/>.</param>
/// <param name="securityConfigurationOptions">The value of <see cref="securityConfigurationOptions"/>.</param>
public UserAuthority(
IAuthenticationContext authenticationContext,
IDatabaseContext databaseContext,
ILogger<UserAuthority> logger,
IUsersDataLoader usersDataLoader,
IOAuthConnectionsDataLoader oAuthConnectionsDataLoader,
IOidcConnectionsDataLoader oidcConnectionsDataLoader,
ISystemIdentityFactory systemIdentityFactory,
IPermissionsUpdateNotifyee permissionsUpdateNotifyee,
ICryptographySuite cryptographySuite,
ISessionInvalidationTracker sessionInvalidationTracker,
ITopicEventSender topicEventSender,
IOptionsSnapshot<GeneralConfiguration> generalConfigurationOptions)
IOptionsSnapshot<GeneralConfiguration> generalConfigurationOptions,
IOptions<SecurityConfiguration> securityConfigurationOptions)
: base(
authenticationContext,
databaseContext,
@@ -172,12 +213,14 @@ namespace Tgstation.Server.Host.Authority
{
this.usersDataLoader = usersDataLoader ?? throw new ArgumentNullException(nameof(usersDataLoader));
this.oAuthConnectionsDataLoader = oAuthConnectionsDataLoader ?? throw new ArgumentNullException(nameof(oAuthConnectionsDataLoader));
this.oidcConnectionsDataLoader = oidcConnectionsDataLoader ?? throw new ArgumentNullException(nameof(oidcConnectionsDataLoader));
this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory));
this.permissionsUpdateNotifyee = permissionsUpdateNotifyee ?? throw new ArgumentNullException(nameof(permissionsUpdateNotifyee));
this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite));
this.sessionInvalidationTracker = sessionInvalidationTracker ?? throw new ArgumentNullException(nameof(sessionInvalidationTracker));
this.topicEventSender = topicEventSender ?? throw new ArgumentNullException(nameof(topicEventSender));
this.generalConfigurationOptions = generalConfigurationOptions ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
this.securityConfigurationOptions = securityConfigurationOptions ?? throw new ArgumentNullException(nameof(securityConfigurationOptions));
}
/// <summary>
@@ -289,6 +332,11 @@ namespace Tgstation.Server.Host.Authority
=> new AuthorityResponse<GraphQL.Types.OAuth.OAuthConnection[]>(
await oAuthConnectionsDataLoader.LoadRequiredAsync(userId, cancellationToken));
/// <inheritdoc />
public async ValueTask<AuthorityResponse<GraphQL.Types.OAuth.OidcConnection[]>> OidcConnections(long userId, CancellationToken cancellationToken)
=> new AuthorityResponse<GraphQL.Types.OAuth.OidcConnection[]>(
await oidcConnectionsDataLoader.LoadRequiredAsync(userId, cancellationToken));
/// <inheritdoc />
public async ValueTask<AuthorityResponse<User>> Create(
UserCreateRequest createRequest,
@@ -372,7 +420,7 @@ namespace Tgstation.Server.Host.Authority
var callerAdministrationRights = (AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration);
var canEditAllUsers = callerAdministrationRights.HasFlag(AdministrationRights.WriteUsers);
var passwordEdit = canEditAllUsers || callerAdministrationRights.HasFlag(AdministrationRights.EditOwnPassword);
var oAuthEdit = canEditAllUsers || callerAdministrationRights.HasFlag(AdministrationRights.EditOwnOAuthConnections);
var oAuthEdit = canEditAllUsers || callerAdministrationRights.HasFlag(AdministrationRights.EditOwnServiceConnections);
var originalUser = !canEditAllUsers
? AuthenticationContext.User
@@ -382,6 +430,7 @@ namespace Tgstation.Server.Host.Authority
.Where(x => x.Id == model.Id)
.Include(x => x.CreatedBy)
.Include(x => x.OAuthConnections)
.Include(x => x.OidcConnections)
.Include(x => x.Group!)
.ThenInclude(x => x.PermissionSet)
.Include(x => x.PermissionSet)
@@ -437,8 +486,11 @@ namespace Tgstation.Server.Host.Authority
&& (model.OAuthConnections.Count != originalUser.OAuthConnections!.Count
|| !model.OAuthConnections.All(x => originalUser.OAuthConnections.Any(y => y.Provider == x.Provider && y.ExternalUserId == x.ExternalUserId))))
{
if (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<User>(ErrorCode.BadUserEditDueToOidcStrictMode);
if (originalUser.CanonicalName == User.CanonicalizeName(DefaultCredentials.AdminUserName))
return BadRequest<User>(ErrorCode.AdminUserCannotOAuth);
return BadRequest<User>(ErrorCode.AdminUserCannotHaveServiceConnection);
if (model.OAuthConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null)
return BadRequest<User>(ErrorCode.CannotRemoveLastAuthenticationOption);
@@ -452,8 +504,33 @@ namespace Tgstation.Server.Host.Authority
});
}
if (model.OidcConnections != null
&& (model.OidcConnections.Count != originalUser.OidcConnections!.Count
|| !model.OidcConnections.All(x => originalUser.OidcConnections.Any(y => y.SchemeKey == x.SchemeKey && y.ExternalUserId == x.ExternalUserId))))
{
if (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<User>(ErrorCode.BadUserEditDueToOidcStrictMode);
if (originalUser.CanonicalName == User.CanonicalizeName(DefaultCredentials.AdminUserName))
return BadRequest<User>(ErrorCode.AdminUserCannotHaveServiceConnection);
if (model.OidcConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null)
return BadRequest<User>(ErrorCode.CannotRemoveLastAuthenticationOption);
originalUser.OidcConnections.Clear();
foreach (var updatedConnection in model.OidcConnections)
originalUser.OidcConnections.Add(new Models.OidcConnection
{
SchemeKey = updatedConnection.SchemeKey,
ExternalUserId = updatedConnection.ExternalUserId,
});
}
if (model.Group != null)
{
if (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<User>(ErrorCode.BadUserEditDueToOidcStrictMode);
originalUser.Group = await DatabaseContext
.Groups
.AsQueryable()
@@ -474,6 +551,9 @@ namespace Tgstation.Server.Host.Authority
}
else if (model.PermissionSet != null)
{
if (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<User>(ErrorCode.BadUserEditDueToOidcStrictMode);
if (originalUser.PermissionSet == null)
{
Logger.LogTrace("Creating new permission set...");
@@ -495,6 +575,9 @@ namespace Tgstation.Server.Host.Authority
if (model.Enabled.HasValue)
{
if (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<User>(ErrorCode.BadUserEditDueToOidcStrictMode);
invalidateSessions = originalUser.Require(x => x.Enabled) && !model.Enabled.Value;
originalUser.Enabled = model.Enabled.Value;
}
@@ -554,6 +637,7 @@ namespace Tgstation.Server.Host.Authority
queryable = queryable
.Include(x => x.CreatedBy)
.Include(x => x.OAuthConnections)
.Include(x => x.OidcConnections)
.Include(x => x.Group!)
.ThenInclude(x => x.PermissionSet)
.Include(x => x.PermissionSet);
@@ -603,6 +687,15 @@ namespace Tgstation.Server.Host.Authority
})
.ToList()
?? new List<Models.OAuthConnection>(),
OidcConnections = model
.OidcConnections
?.Select(x => new Models.OidcConnection
{
SchemeKey = x.SchemeKey,
ExternalUserId = x.ExternalUserId,
})
.ToList()
?? new List<Models.OidcConnection>(),
};
}
@@ -0,0 +1,52 @@
using System;
using Microsoft.IdentityModel.JsonWebTokens;
namespace Tgstation.Server.Host.Configuration
{
/// <summary>
/// Configuration for an OpenID Connect provider.
/// </summary>
public sealed class OidcConfiguration
{
/// <summary>
/// The <see cref="Uri"/> containing the .well-known endpoint for the provider.
/// </summary>
public Uri? Authority { get; set; }
/// <summary>
/// The OIDC client ID.
/// </summary>
public string? ClientId { get; set; }
/// <summary>
/// The OIDC client secret.
/// </summary>
public string? ClientSecret { get; set; }
/// <summary>
/// The provider's name as it should be displayed to the user.
/// </summary>
public string? FriendlyName { get; set; }
/// <summary>
/// The path to return to once OIDC authentication is complete. On success the "code" and "state" query strings will be set, containing a TGS token and "oidc.(scheme key)" strings respectively. On failure the "error" query string will be set with a user readable error message.
/// </summary>
public string? ReturnPath { get; set; } = "/app";
/// <summary>
/// Colour that should be used to theme this OIDC provider.
/// </summary>
public string? ThemeColour { get; set; }
/// <summary>
/// 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;
}
}
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using Swashbuckle.AspNetCore.SwaggerGen;
@@ -62,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>
@@ -87,5 +93,23 @@ namespace Tgstation.Server.Host.Configuration
/// Backing field for <see cref="OAuth"/>.
/// </summary>
IDictionary<OAuthProvider, OAuthConfiguration>? oAuth;
/// <summary>
/// OIDC provider settings keyed by scheme name.
/// </summary>
public IDictionary<string, OidcConfiguration>? OpenIDConnect { get; set; }
/// <summary>
/// Get the <see cref="OidcProviderInfo"/>s from the <see cref="SecurityConfiguration"/>.
/// </summary>
/// <returns>An <see cref="IEnumerable{T}"/> of <see cref="OidcProviderInfo"/>s.</returns>
public IEnumerable<OidcProviderInfo> OidcProviderInfos()
=> OpenIDConnect?.Select(oidcConfig => new OidcProviderInfo
{
SchemeKey = oidcConfig.Key,
FriendlyName = oidcConfig.Value.FriendlyName ?? oidcConfig.Key,
ThemeColour = oidcConfig.Value.ThemeColour,
ThemeIconUrl = oidcConfig.Value.ThemeIconUrl,
}) ?? Enumerable.Empty<OidcProviderInfo>();
}
}
@@ -70,6 +70,11 @@ namespace Tgstation.Server.Host.Controllers
/// </summary>
readonly GeneralConfiguration generalConfiguration;
/// <summary>
/// The <see cref="SecurityConfiguration"/> for the <see cref="ApiRootController"/>.
/// </summary>
readonly SecurityConfiguration securityConfiguration;
/// <summary>
/// Initializes a new instance of the <see cref="ApiRootController"/> class.
/// </summary>
@@ -81,6 +86,7 @@ namespace Tgstation.Server.Host.Controllers
/// <param name="swarmService">The value of <see cref="swarmService"/>.</param>
/// <param name="serverControl">The value of <see cref="serverControl"/>.</param>
/// <param name="generalConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="generalConfiguration"/>.</param>
/// <param name="securityConfigurationOptions">The <see cref="IOptionsSnapshot{TOptions}"/> containing the value of <see cref="securityConfiguration"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/>.</param>
/// <param name="apiHeadersProvider">The <see cref="IApiHeadersProvider"/> for the <see cref="ApiController"/>.</param>
/// <param name="loginAuthority">The value of <see cref="loginAuthority"/>.</param>
@@ -93,6 +99,7 @@ namespace Tgstation.Server.Host.Controllers
ISwarmService swarmService,
IServerControl serverControl,
IOptions<GeneralConfiguration> generalConfigurationOptions,
IOptionsSnapshot<SecurityConfiguration> securityConfigurationOptions,
ILogger<ApiRootController> logger,
IApiHeadersProvider apiHeadersProvider,
IRestAuthorityInvoker<ILoginAuthority> loginAuthority)
@@ -109,6 +116,7 @@ namespace Tgstation.Server.Host.Controllers
this.swarmService = swarmService ?? throw new ArgumentNullException(nameof(swarmService));
this.serverControl = serverControl ?? throw new ArgumentNullException(nameof(serverControl));
generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
securityConfiguration = securityConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(securityConfigurationOptions));
this.loginAuthority = loginAuthority ?? throw new ArgumentNullException(nameof(loginAuthority));
}
@@ -162,7 +170,9 @@ namespace Tgstation.Server.Host.Controllers
?.Select(swarmServerInfo => new SwarmServerResponse(swarmServerInfo))
.ToList(),
OAuthProviderInfos = oAuthProviders.ProviderInfos(),
OidcProviderInfos = securityConfiguration.OidcProviderInfos().ToList(),
UpdateInProgress = serverControl.UpdateInProgress,
OidcStrictMode = securityConfiguration.OidcStrictMode,
});
}
+143 -8
View File
@@ -3,6 +3,7 @@ using System.Collections.Frozen;
using System.Collections.Generic;
using System.Globalization;
using System.Threading.Tasks;
using System.Web;
using Cyberboss.AspNetCore.AsyncInitializer;
@@ -13,7 +14,9 @@ using HotChocolate.Subscriptions;
using HotChocolate.Types;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Cors.Infrastructure;
using Microsoft.AspNetCore.Hosting;
@@ -27,6 +30,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Newtonsoft.Json;
@@ -121,6 +125,14 @@ namespace Tgstation.Server.Host.Core
services.AddSingleton<IWatchdogFactory, TSystemWatchdogFactory>();
}
/// <summary>
/// Get the OIDC authentication scheme name for a given <paramref name="schemeKey"/>.
/// </summary>
/// <param name="schemeKey">The scheme key for the <see cref="OidcConfiguration"/>.</param>
/// <returns>The authentication scheme name.</returns>
static string GetOidcScheme(string schemeKey)
=> AuthenticationContextFactory.OpenIDConnectAuthenticationSchemePrefix + schemeKey;
/// <summary>
/// Initializes a new instance of the <see cref="Application"/> class.
/// </summary>
@@ -239,7 +251,7 @@ namespace Tgstation.Server.Host.Core
postSetupServices.FileLoggingConfiguration);
// configure authentication pipeline
ConfigureAuthenticationPipeline(services);
ConfigureAuthenticationPipeline(services, postSetupServices.SecurityConfiguration);
// add mvc, configure the json serializer settings
var jsonVersionConverterList = new List<JsonConverter>
@@ -316,9 +328,21 @@ namespace Tgstation.Server.Host.Core
.AddScoped<GraphQL.Subscriptions.ITopicEventReceiver, ShutdownAwareTopicEventReceiver>()
.AddGraphQLServer()
.AddAuthorization(
options => options.AddPolicy(
TgsAuthorizeAttribute.PolicyName,
builder => builder.RequireRole(TgsAuthorizeAttribute.UserEnabledRole)))
options =>
{
options.AddPolicy(
TgsAuthorizeAttribute.PolicyName,
builder => builder
.RequireAuthenticatedUser()
.RequireRole(TgsAuthorizeAttribute.UserEnabledRole));
options.AddPolicy(
"testingasdf",
builder =>
{
builder.RequireAuthenticatedUser();
builder.AuthenticationSchemes.Add(CookieAuthenticationDefaults.AuthenticationScheme);
});
})
.ModifyOptions(options =>
{
options.EnsureAllNodesCanBeResolved = true;
@@ -548,6 +572,7 @@ namespace Tgstation.Server.Host.Core
/// <param name="controlPanelConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the <see cref="ControlPanelConfiguration"/> to use.</param>
/// <param name="generalConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the <see cref="GeneralConfiguration"/> to use.</param>
/// <param name="databaseConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the <see cref="DatabaseConfiguration"/> to use.</param>
/// <param name="securityConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the <see cref="SecurityConfiguration"/> to use.</param>
/// <param name="swarmConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the <see cref="SwarmConfiguration"/> to use.</param>
/// <param name="internalConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the <see cref="InternalConfiguration"/> to use.</param>
/// <param name="logger">The <see cref="Microsoft.Extensions.Logging.ILogger"/> for the <see cref="Application"/>.</param>
@@ -560,6 +585,7 @@ namespace Tgstation.Server.Host.Core
IOptions<ControlPanelConfiguration> controlPanelConfigurationOptions,
IOptions<GeneralConfiguration> generalConfigurationOptions,
IOptions<DatabaseConfiguration> databaseConfigurationOptions,
IOptions<SecurityConfiguration> securityConfigurationOptions,
IOptions<SwarmConfiguration> swarmConfigurationOptions,
IOptions<InternalConfiguration> internalConfigurationOptions,
ILogger<Application> logger)
@@ -728,6 +754,20 @@ namespace Tgstation.Server.Host.Core
logger.LogTrace("Prometheus disabled");
endpoints.MapHealthChecks("/health");
var oidcConfig = securityConfigurationOptions.Value.OpenIDConnect;
if (oidcConfig == null)
return;
foreach (var kvp in oidcConfig)
endpoints.MapGet(
$"/oidc/{kvp.Key}/signin",
context => context.ChallengeAsync(
GetOidcScheme(kvp.Key),
new AuthenticationProperties
{
RedirectUri = $"/oidc/{kvp.Key}/landing",
}));
});
// 404 anything that gets this far
@@ -748,7 +788,8 @@ namespace Tgstation.Server.Host.Core
/// Configure the <paramref name="services"/> for the authentication pipeline.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to configure.</param>
void ConfigureAuthenticationPipeline(IServiceCollection services)
/// <param name="securityConfiguration">The <see cref="SecurityConfiguration"/>.</param>
void ConfigureAuthenticationPipeline(IServiceCollection services, SecurityConfiguration securityConfiguration)
{
services.AddHttpContextAccessor();
services.AddScoped<IApiHeadersProvider, ApiHeadersProvider>();
@@ -768,8 +809,11 @@ namespace Tgstation.Server.Host.Core
.CurrentAuthenticationContext);
services.AddScoped<IClaimsTransformation, AuthenticationContextClaimsTransformation>();
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
var authBuilder = services
.AddAuthentication(options =>
{
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(jwtBearerOptions =>
{
// this line isn't actually run until the first request is made
@@ -798,13 +842,104 @@ namespace Tgstation.Server.Host.Core
.HttpContext
.RequestServices
.GetRequiredService<ITokenValidator>()
.ValidateToken(
.ValidateTgsToken(
context,
context
.HttpContext
.RequestAborted),
};
});
var oidcConfig = securityConfiguration.OpenIDConnect;
if (oidcConfig == null || oidcConfig.Count == 0)
return;
authBuilder.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme);
foreach (var kvp in oidcConfig)
{
var configName = kvp.Key;
authBuilder
.AddOpenIdConnect(
GetOidcScheme(configName),
options =>
{
var config = kvp.Value;
options.Authority = config.Authority?.ToString();
options.ClientId = config.ClientId;
options.ClientSecret = config.ClientSecret;
options.Events = new OpenIdConnectEvents
{
OnRemoteFailure = context =>
{
context.HandleResponse();
context.HttpContext.Response.Redirect($"{config.ReturnPath}?error={HttpUtility.UrlEncode(context.Failure?.Message ?? $"{options.Events.OnRemoteFailure} was called without an {nameof(Exception)}!")}&state=oidc.{HttpUtility.UrlEncode(configName)}");
return Task.CompletedTask;
},
OnTicketReceived = context =>
{
var services = context
.HttpContext
.RequestServices;
var tokenFactory = services
.GetRequiredService<ITokenFactory>();
var authenticationContext = services
.GetRequiredService<IAuthenticationContext>();
context.HandleResponse();
context.HttpContext.Response.Redirect($"{config.ReturnPath}?code={HttpUtility.UrlEncode(tokenFactory.CreateToken(authenticationContext.User, true))}&state=oidc.{HttpUtility.UrlEncode(configName)}");
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");
});
}
}
}
}
@@ -98,6 +98,11 @@ namespace Tgstation.Server.Host.Database
/// </summary>
public DbSet<OAuthConnection> OAuthConnections { get; set; }
/// <summary>
/// The <see cref="OidcConnection"/>s in the <see cref="DatabaseContext"/>.
/// </summary>
public DbSet<OidcConnection> OidcConnections { get; set; }
/// <summary>
/// The <see cref="PermissionSet"/>s in the <see cref="DatabaseContext"/>.
/// </summary>
@@ -153,6 +158,9 @@ namespace Tgstation.Server.Host.Database
/// <inheritdoc />
IDatabaseCollection<OAuthConnection> IDatabaseContext.OAuthConnections => oAuthConnections;
/// <inheritdoc />
IDatabaseCollection<OidcConnection> IDatabaseContext.OidcConnections => oidcConnections;
/// <inheritdoc />
IDatabaseCollection<UserGroup> IDatabaseContext.Groups => groups;
@@ -239,6 +247,11 @@ namespace Tgstation.Server.Host.Database
/// </summary>
readonly IDatabaseCollection<OAuthConnection> oAuthConnections;
/// <summary>
/// Backing field for <see cref="IDatabaseContext.OidcConnections"/>.
/// </summary>
readonly IDatabaseCollection<OidcConnection> oidcConnections;
/// <summary>
/// Backing field for <see cref="IDatabaseContext.Groups"/>.
/// </summary>
@@ -288,6 +301,7 @@ namespace Tgstation.Server.Host.Database
jobsCollection = new DatabaseCollection<Job>(Jobs!);
reattachInformationsCollection = new DatabaseCollection<ReattachInformation>(ReattachInformations!);
oAuthConnections = new DatabaseCollection<OAuthConnection>(OAuthConnections!);
oidcConnections = new DatabaseCollection<OidcConnection>(OidcConnections!);
groups = new DatabaseCollection<UserGroup>(Groups!);
permissionSets = new DatabaseCollection<PermissionSet>(PermissionSets!);
}
@@ -387,8 +401,10 @@ namespace Tgstation.Server.Host.Database
userModel.HasIndex(x => x.SystemIdentifier).IsUnique();
userModel.HasMany(x => x.TestMerges).WithOne(x => x.MergedBy).OnDelete(DeleteBehavior.Restrict);
userModel.HasMany(x => x.OAuthConnections).WithOne(x => x.User).OnDelete(DeleteBehavior.Cascade);
userModel.HasMany(x => x.OidcConnections).WithOne(x => x.User).OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<OAuthConnection>().HasIndex(x => new { x.Provider, x.ExternalUserId }).IsUnique();
modelBuilder.Entity<OidcConnection>().HasIndex(x => new { x.SchemeKey, x.ExternalUserId }).IsUnique();
var groupsModel = modelBuilder.Entity<UserGroup>();
groupsModel.HasIndex(x => x.Name).IsUnique();
@@ -451,22 +467,22 @@ namespace Tgstation.Server.Host.Database
/// <summary>
/// Used by unit tests to remind us to setup the correct MSSQL migration downgrades.
/// </summary>
internal static readonly Type MSLatestMigration = typeof(MSAddAutoStartAndStop);
internal static readonly Type MSLatestMigration = typeof(MSAddOidcConnections);
/// <summary>
/// Used by unit tests to remind us to setup the correct MYSQL migration downgrades.
/// </summary>
internal static readonly Type MYLatestMigration = typeof(MYAddAutoStartAndStop);
internal static readonly Type MYLatestMigration = typeof(MYAddOidcConnections);
/// <summary>
/// Used by unit tests to remind us to setup the correct PostgresSQL migration downgrades.
/// </summary>
internal static readonly Type PGLatestMigration = typeof(PGAddAutoStartAndStop);
internal static readonly Type PGLatestMigration = typeof(PGAddOidcConnections);
/// <summary>
/// Used by unit tests to remind us to setup the correct SQLite migration downgrades.
/// </summary>
internal static readonly Type SLLatestMigration = typeof(SLAddAutoStartAndStop);
internal static readonly Type SLLatestMigration = typeof(SLAddOidcConnections);
/// <summary>
/// Gets the name of the migration to run for migrating down to a given <paramref name="targetVersion"/> for the <paramref name="currentDatabaseType"/>.
@@ -482,6 +498,16 @@ namespace Tgstation.Server.Host.Database
string BadDatabaseType() => throw new ArgumentException($"Invalid DatabaseType: {currentDatabaseType}", nameof(currentDatabaseType));
// !!! DON'T FORGET TO UPDATE THE SWARM PROTOCOL MAJOR VERSION !!!
if (targetVersion < new Version(6, 15, 0))
targetMigration = currentDatabaseType switch
{
DatabaseType.MySql => nameof(MYAddAutoStartAndStop),
DatabaseType.PostgresSql => nameof(PGAddAutoStartAndStop),
DatabaseType.SqlServer => nameof(MSAddAutoStartAndStop),
DatabaseType.Sqlite => nameof(SLAddAutoStartAndStop),
_ => BadDatabaseType(),
};
if (targetVersion < new Version(6, 12, 0))
targetMigration = currentDatabaseType switch
{
@@ -90,6 +90,11 @@ namespace Tgstation.Server.Host.Database
/// </summary>
IDatabaseCollection<OAuthConnection> OAuthConnections { get; }
/// <summary>
/// The <see cref="DbSet{TEntity}"/> for <see cref="OidcConnection"/>s.
/// </summary>
IDatabaseCollection<OidcConnection> OidcConnections { get; }
/// <summary>
/// The <see cref="DbSet{TEntity}"/> for <see cref="UserGroup"/>s.
/// </summary>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,57 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
namespace Tgstation.Server.Host.Database.Migrations
{
/// <inheritdoc />
public partial class MSAddOidcConnections : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.CreateTable(
name: "OidcConnections",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
UserId = table.Column<long>(type: "bigint", nullable: false),
SchemeKey = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
ExternalUserId = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_OidcConnections", x => x.Id);
table.ForeignKey(
name: "FK_OidcConnections_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_OidcConnections_SchemeKey_ExternalUserId",
table: "OidcConnections",
columns: new[] { "SchemeKey", "ExternalUserId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OidcConnections_UserId",
table: "OidcConnections",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.DropTable(
name: "OidcConnections");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
namespace Tgstation.Server.Host.Database.Migrations
{
/// <inheritdoc />
public partial class MYAddOidcConnections : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.CreateTable(
name: "OidcConnections",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
UserId = table.Column<long>(type: "bigint", nullable: false),
SchemeKey = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
ExternalUserId = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
},
constraints: table =>
{
table.PrimaryKey("PK_OidcConnections", x => x.Id);
table.ForeignKey(
name: "FK_OidcConnections_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_OidcConnections_SchemeKey_ExternalUserId",
table: "OidcConnections",
columns: new[] { "SchemeKey", "ExternalUserId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OidcConnections_UserId",
table: "OidcConnections",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.DropTable(
name: "OidcConnections");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,59 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace Tgstation.Server.Host.Database.Migrations
{
/// <inheritdoc />
public partial class PGAddOidcConnections : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.CreateTable(
name: "OidcConnections",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<long>(type: "bigint", nullable: false),
SchemeKey = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
ExternalUserId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_OidcConnections", x => x.Id);
table.ForeignKey(
name: "FK_OidcConnections_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_OidcConnections_SchemeKey_ExternalUserId",
table: "OidcConnections",
columns: new[] { "SchemeKey", "ExternalUserId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OidcConnections_UserId",
table: "OidcConnections",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.DropTable(
name: "OidcConnections");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,57 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
namespace Tgstation.Server.Host.Database.Migrations
{
/// <inheritdoc />
public partial class SLAddOidcConnections : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.CreateTable(
name: "OidcConnections",
columns: table => new
{
Id = table.Column<long>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
UserId = table.Column<long>(type: "INTEGER", nullable: false),
SchemeKey = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
ExternalUserId = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_OidcConnections", x => x.Id);
table.ForeignKey(
name: "FK_OidcConnections_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_OidcConnections_SchemeKey_ExternalUserId",
table: "OidcConnections",
columns: new[] { "SchemeKey", "ExternalUserId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OidcConnections_UserId",
table: "OidcConnections",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.DropTable(
name: "OidcConnections");
}
}
}
@@ -13,7 +13,7 @@ namespace Tgstation.Server.Host.Database
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("ProductVersion", "8.0.13")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
@@ -512,6 +512,37 @@ namespace Tgstation.Server.Host.Database
b.ToTable("OAuthConnections");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.OidcConnection", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("ExternalUserId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("SchemeKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("SchemeKey", "ExternalUserId")
.IsUnique();
b.ToTable("OidcConnections");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b =>
{
b.Property<long?>("Id")
@@ -996,6 +1027,17 @@ namespace Tgstation.Server.Host.Database
b.Navigation("User");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.OidcConnection", b =>
{
b.HasOne("Tgstation.Server.Host.Models.User", "User")
.WithMany("OidcConnections")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b =>
{
b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group")
@@ -1152,6 +1194,8 @@ namespace Tgstation.Server.Host.Database
b.Navigation("OAuthConnections");
b.Navigation("OidcConnections");
b.Navigation("PermissionSet");
b.Navigation("TestMerges");
@@ -13,7 +13,7 @@ namespace Tgstation.Server.Host.Database
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("ProductVersion", "8.0.13")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -471,6 +471,37 @@ namespace Tgstation.Server.Host.Database
b.ToTable("OAuthConnections");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.OidcConnection", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ExternalUserId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("SchemeKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("SchemeKey", "ExternalUserId")
.IsUnique();
b.ToTable("OidcConnections");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b =>
{
b.Property<long?>("Id")
@@ -919,6 +950,17 @@ namespace Tgstation.Server.Host.Database
b.Navigation("User");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.OidcConnection", b =>
{
b.HasOne("Tgstation.Server.Host.Models.User", "User")
.WithMany("OidcConnections")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b =>
{
b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group")
@@ -1075,6 +1117,8 @@ namespace Tgstation.Server.Host.Database
b.Navigation("OAuthConnections");
b.Navigation("OidcConnections");
b.Navigation("PermissionSet");
b.Navigation("TestMerges");
@@ -13,7 +13,7 @@ namespace Tgstation.Server.Host.Database
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("ProductVersion", "8.0.13")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
@@ -474,6 +474,37 @@ namespace Tgstation.Server.Host.Database
b.ToTable("OAuthConnections");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.OidcConnection", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("ExternalUserId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("SchemeKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("SchemeKey", "ExternalUserId")
.IsUnique();
b.ToTable("OidcConnections");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b =>
{
b.Property<long?>("Id")
@@ -925,6 +956,17 @@ namespace Tgstation.Server.Host.Database
b.Navigation("User");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.OidcConnection", b =>
{
b.HasOne("Tgstation.Server.Host.Models.User", "User")
.WithMany("OidcConnections")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b =>
{
b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group")
@@ -1081,6 +1123,8 @@ namespace Tgstation.Server.Host.Database
b.Navigation("OAuthConnections");
b.Navigation("OidcConnections");
b.Navigation("PermissionSet");
b.Navigation("TestMerges");
@@ -12,7 +12,7 @@ namespace Tgstation.Server.Host.Database
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.10");
modelBuilder.HasAnnotation("ProductVersion", "8.0.13");
modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b =>
{
@@ -460,6 +460,35 @@ namespace Tgstation.Server.Host.Database
b.ToTable("OAuthConnections");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.OidcConnection", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ExternalUserId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("SchemeKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<long>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("SchemeKey", "ExternalUserId")
.IsUnique();
b.ToTable("OidcConnections");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b =>
{
b.Property<long?>("Id")
@@ -892,6 +921,17 @@ namespace Tgstation.Server.Host.Database
b.Navigation("User");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.OidcConnection", b =>
{
b.HasOne("Tgstation.Server.Host.Models.User", "User")
.WithMany("OidcConnections")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b =>
{
b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group")
@@ -1048,6 +1088,8 @@ namespace Tgstation.Server.Host.Database
b.Navigation("OAuthConnections");
b.Navigation("OidcConnections");
b.Navigation("PermissionSet");
b.Navigation("TestMerges");
@@ -66,6 +66,7 @@ namespace Tgstation.Server.Host.Extensions
applicationBuilder.ApplicationServices.GetRequiredService<IOptions<ControlPanelConfiguration>>(),
applicationBuilder.ApplicationServices.GetRequiredService<IOptions<GeneralConfiguration>>(),
applicationBuilder.ApplicationServices.GetRequiredService<IOptions<DatabaseConfiguration>>(),
applicationBuilder.ApplicationServices.GetRequiredService<IOptions<SecurityConfiguration>>(),
applicationBuilder.ApplicationServices.GetRequiredService<IOptions<SwarmConfiguration>>(),
applicationBuilder.ApplicationServices.GetRequiredService<IOptions<InternalConfiguration>>(),
applicationBuilder.ApplicationServices.GetRequiredService<ILogger<Application>>());
@@ -33,6 +33,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="password">The password of the <see cref="User"/>.</param>
/// <param name="enabled">If the <see cref="User"/> is <see cref="User.Enabled"/>.</param>
/// <param name="oAuthConnections">The <see cref="OAuthConnection"/>s for the user.</param>
/// <param name="oidcConnections">The <see cref="OidcConnection"/>s for the user.</param>
/// <param name="permissionSet">The owned <see cref="PermissionSet"/> of the user.</param>
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
@@ -44,6 +45,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
string password,
bool? enabled,
IEnumerable<OAuthConnection>? oAuthConnections,
IEnumerable<OidcConnection>? oidcConnections,
PermissionSetInput? permissionSet,
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority,
CancellationToken cancellationToken)
@@ -73,6 +75,13 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
Provider = oAuthConnection.Provider,
})
.ToList(),
OidcConnections = oidcConnections
?.Select(oidcConnection => new Api.Models.OidcConnection
{
ExternalUserId = oidcConnection.ExternalUserId,
SchemeKey = oidcConnection.SchemeKey,
})
.ToList(),
},
false,
cancellationToken));
@@ -85,6 +94,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="password">The password of the <see cref="User"/>.</param>
/// <param name="enabled">If the <see cref="User"/> is <see cref="User.Enabled"/>.</param>
/// <param name="oAuthConnections">The <see cref="OAuthConnection"/>s for the user.</param>
/// <param name="oidcConnections">The <see cref="OidcConnection"/>s for the user.</param>
/// <param name="groupId">The <see cref="Entity.Id"/> of the <see cref="UserGroup"/> the <see cref="User"/> will belong to.</param>
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
@@ -96,6 +106,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
string password,
bool? enabled,
IEnumerable<OAuthConnection>? oAuthConnections,
IEnumerable<OidcConnection>? oidcConnections,
[ID(nameof(UserGroup))] long groupId,
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority,
CancellationToken cancellationToken)
@@ -122,16 +133,24 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
Provider = oAuthConnection.Provider,
})
.ToList(),
OidcConnections = oidcConnections
?.Select(oidcConnection => new Api.Models.OidcConnection
{
ExternalUserId = oidcConnection.ExternalUserId,
SchemeKey = oidcConnection.SchemeKey,
})
.ToList(),
},
false,
cancellationToken));
}
/// <summary>
/// Creates a TGS user authenticated with one or mor <see cref="OAuthConnection"/>s specifying a personal <see cref="PermissionSet"/>.
/// Creates a TGS user authenticated with one or more <see cref="OAuthConnection"/>s or <see cref="OidcConnection"/>s specifying a personal <see cref="PermissionSet"/>.
/// </summary>
/// <param name="name">The <see cref="NamedEntity.Name"/> of the <see cref="User"/>.</param>
/// <param name="oAuthConnections">The <see cref="OAuthConnection"/>s for the user.</param>
/// <param name="oidcConnections">The <see cref="OidcConnection"/>s for the user.</param>
/// <param name="enabled">If the <see cref="User"/> is <see cref="User.Enabled"/>.</param>
/// <param name="permissionSet">The owned <see cref="PermissionSet"/> of the user.</param>
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
@@ -139,9 +158,10 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <returns>The created <see cref="User"/>.</returns>
[TgsGraphQLAuthorize<IUserAuthority>(nameof(IUserAuthority.Create))]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> CreateUserByOAuthAndPermissionSet(
public ValueTask<User> CreateUserByServiceConnectionAndPermissionSet(
string name,
IEnumerable<OAuthConnection> oAuthConnections,
IEnumerable<OAuthConnection>? oAuthConnections,
IEnumerable<OidcConnection>? oidcConnections,
bool? enabled,
PermissionSetInput? permissionSet,
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority,
@@ -172,16 +192,24 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
Provider = oAuthConnection.Provider,
})
.ToList(),
OidcConnections = oidcConnections
?.Select(oidcConnection => new Api.Models.OidcConnection
{
ExternalUserId = oidcConnection.ExternalUserId,
SchemeKey = oidcConnection.SchemeKey,
})
.ToList(),
},
true,
cancellationToken));
}
/// <summary>
/// Creates a TGS user specifying the <see cref="UserGroup"/> they will belong to.
/// Creates a TGS user using <see cref="OAuthConnection"/>s and/or <see cref="OidcConnection"/>s specifying the <see cref="UserGroup"/> they will belong to.
/// </summary>
/// <param name="name">The <see cref="NamedEntity.Name"/> of the <see cref="User"/>.</param>
/// <param name="oAuthConnections">The <see cref="OAuthConnection"/>s for the user.</param>
/// <param name="oidcConnections">The <see cref="OidcConnection"/>s for the user.</param>
/// <param name="groupId">The <see cref="Entity.Id"/> of the <see cref="UserGroup"/> the <see cref="User"/> will belong to.</param>
/// <param name="enabled">If the <see cref="User"/> is <see cref="User.Enabled"/>.</param>
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
@@ -189,9 +217,10 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <returns>The created <see cref="User"/>.</returns>
[TgsGraphQLAuthorize<IUserAuthority>(nameof(IUserAuthority.Create))]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> CreateUserByOAuthAndGroup(
public ValueTask<User> CreateUserByServiceConnectionAndGroup(
string name,
IEnumerable<OAuthConnection> oAuthConnections,
IEnumerable<OidcConnection> oidcConnections,
[ID(nameof(UserGroup))] long groupId,
bool? enabled,
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority,
@@ -219,6 +248,13 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
Provider = oAuthConnection.Provider,
})
.ToList(),
OidcConnections = oidcConnections
?.Select(oidcConnection => new Api.Models.OidcConnection
{
ExternalUserId = oidcConnection.ExternalUserId,
SchemeKey = oidcConnection.SchemeKey,
})
.ToList(),
},
true,
cancellationToken));
@@ -230,6 +266,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="systemIdentifier">The <see cref="User.SystemIdentifier"/> of the <see cref="User"/>.</param>
/// <param name="enabled">If the <see cref="User"/> is <see cref="User.Enabled"/>.</param>
/// <param name="oAuthConnections">The <see cref="OAuthConnection"/>s for the user.</param>
/// <param name="oidcConnections">The <see cref="OidcConnection"/>s for the user.</param>
/// <param name="permissionSet">The owned <see cref="PermissionSet"/> of the user.</param>
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
@@ -240,6 +277,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
string systemIdentifier,
bool? enabled,
IEnumerable<OAuthConnection>? oAuthConnections,
IEnumerable<OidcConnection>? oidcConnections,
PermissionSetInput permissionSet,
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority,
CancellationToken cancellationToken)
@@ -267,6 +305,13 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
Provider = oAuthConnection.Provider,
})
.ToList(),
OidcConnections = oidcConnections
?.Select(oidcConnection => new Api.Models.OidcConnection
{
ExternalUserId = oidcConnection.ExternalUserId,
SchemeKey = oidcConnection.SchemeKey,
})
.ToList(),
},
false,
cancellationToken));
@@ -279,6 +324,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="enabled">If the <see cref="User"/> is <see cref="User.Enabled"/>.</param>
/// <param name="groupId">The <see cref="Entity.Id"/> of the <see cref="UserGroup"/> the <see cref="User"/> will belong to.</param>
/// <param name="oAuthConnections">The <see cref="OAuthConnection"/>s for the user.</param>
/// <param name="oidcConnections">The <see cref="OidcConnection"/>s for the user.</param>
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>The created <see cref="User"/>.</returns>
@@ -289,6 +335,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
bool? enabled,
[ID(nameof(UserGroup))] long groupId,
IEnumerable<OAuthConnection>? oAuthConnections,
IEnumerable<OidcConnection>? oidcConnections,
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority,
CancellationToken cancellationToken)
{
@@ -312,6 +359,13 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
Provider = oAuthConnection.Provider,
})
.ToList(),
OidcConnections = oidcConnections
?.Select(oidcConnection => new Api.Models.OidcConnection
{
ExternalUserId = oidcConnection.ExternalUserId,
SchemeKey = oidcConnection.SchemeKey,
})
.ToList(),
},
false,
cancellationToken));
@@ -346,17 +400,19 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
}
/// <summary>
/// Sets the current user's <see cref="OAuthConnection"/>s.
/// Sets the current user's <see cref="OAuthConnection"/>s and <see cref="OidcConnection"/>s.
/// </summary>
/// <param name="newOAuthConnections">The new <see cref="OAuthConnection"/>s for the current user.</param>
/// <param name="newOAuthConnections">Optional new <see cref="OAuthConnection"/>s for the current <see cref="User"/>.</param>
/// <param name="newOidcConnections">Optional new <see cref="OidcConnection"/>s for the current <see cref="User"/>.</param>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> to get the <see cref="Entity.Id"/> of the user.</param>
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>The updated current <see cref="User"/>.</returns>
[TgsGraphQLAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnOAuthConnections)]
[TgsGraphQLAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnServiceConnections)]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> SetCurrentOAuthConnections(
IEnumerable<OAuthConnection> newOAuthConnections,
public ValueTask<User> SetCurrentServiceConnections(
IEnumerable<OAuthConnection>? newOAuthConnections,
IEnumerable<OidcConnection>? newOidcConnections,
[Service] IAuthenticationContext authenticationContext,
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority,
CancellationToken cancellationToken)
@@ -375,6 +431,13 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
Provider = oAuthConnection.Provider,
})
.ToList(),
OidcConnections = newOidcConnections
?.Select(oidcConnection => new Api.Models.OidcConnection
{
ExternalUserId = oidcConnection.ExternalUserId,
SchemeKey = oidcConnection.SchemeKey,
})
.ToList(),
},
cancellationToken));
}
@@ -387,6 +450,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="newPassword">Optional new password for the <see cref="User"/>. Only applicable to TGS users.</param>
/// <param name="enabled">Optional new <see cref="User.Enabled"/> status for the <see cref="User"/>.</param>
/// <param name="newOAuthConnections">Optional new <see cref="OAuthConnection"/>s for the <see cref="User"/>.</param>
/// <param name="newOidcConnections">Optional new <see cref="OidcConnection"/>s for the <see cref="User"/>.</param>
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>The updated <see cref="User"/>.</returns>
@@ -398,6 +462,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
string? newPassword,
bool? enabled,
IEnumerable<OAuthConnection>? newOAuthConnections,
IEnumerable<OidcConnection>? newOidcConnections,
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority,
CancellationToken cancellationToken)
{
@@ -410,6 +475,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
null,
null,
newOAuthConnections,
newOidcConnections,
userAuthority,
cancellationToken);
}
@@ -422,7 +488,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="newPassword">Optional new password for the <see cref="User"/>. Only applicable to TGS users.</param>
/// <param name="enabled">Optional new <see cref="User.Enabled"/> status for the <see cref="User"/>.</param>
/// <param name="newPermissionSet">Updated owned <see cref="PermissionSet"/> for the user. Note that setting this on a <see cref="User"/> in a <see cref="UserGroup"/> will remove them from that group.</param>
/// <param name="newOAuthConnections">The new <see cref="OAuthConnection"/>s for the <see cref="User"/>.</param>
/// <param name="newOAuthConnections">Optional new <see cref="OAuthConnection"/>s for the <see cref="User"/>.</param>
/// <param name="newOidcConnections">Optional new <see cref="OidcConnection"/>s for the <see cref="User"/>.</param>
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>The updated <see cref="User"/>.</returns>
@@ -435,6 +502,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
bool? enabled,
PermissionSetInput newPermissionSet,
IEnumerable<OAuthConnection>? newOAuthConnections,
IEnumerable<OidcConnection>? newOidcConnections,
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority,
CancellationToken cancellationToken)
{
@@ -447,6 +515,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
newPermissionSet,
null,
newOAuthConnections,
newOidcConnections,
userAuthority,
cancellationToken);
}
@@ -459,7 +528,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="newPassword">Optional new password for the <see cref="User"/>. Only applicable to TGS users.</param>
/// <param name="enabled">Optional new <see cref="User.Enabled"/> status for the <see cref="User"/>.</param>
/// <param name="newGroupId"><see cref="Entity.Id"/> of the <see cref="UserGroup"/> to move the <see cref="User"/> to.</param>
/// <param name="newOAuthConnections">The new <see cref="OAuthConnection"/>s for the <see cref="User"/>.</param>
/// <param name="newOAuthConnections">Optional new <see cref="OAuthConnection"/>s for the <see cref="User"/>.</param>
/// <param name="newOidcConnections">Optional new <see cref="OidcConnection"/>s for the <see cref="User"/>.</param>
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>The updated <see cref="User"/>.</returns>
@@ -472,6 +542,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
bool? enabled,
[ID(nameof(UserGroup))] long newGroupId,
IEnumerable<OAuthConnection>? newOAuthConnections,
IEnumerable<OidcConnection>? newOidcConnections,
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority,
CancellationToken cancellationToken)
{
@@ -484,6 +555,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
null,
newGroupId,
newOAuthConnections,
newOidcConnections,
userAuthority,
cancellationToken);
}
@@ -498,6 +570,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="newPermissionSet">Optional updated new owned <see cref="PermissionSet"/> for the user. Note that setting this on a <see cref="User"/> in a <see cref="UserGroup"/> will remove them from that group.</param>
/// <param name="newGroupId">Optional <see cref="Entity.Id"/> of the <see cref="UserGroup"/> to move the <see cref="User"/> to.</param>
/// <param name="newOAuthConnections">Optional new <see cref="OAuthConnection"/>s for the <see cref="User"/>.</param>
/// <param name="newOidcConnections">Optional new <see cref="OidcConnection"/>s for the <see cref="User"/>.</param>
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>The updated <see cref="User"/>.</returns>
@@ -509,6 +582,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
PermissionSetInput? newPermissionSet,
long? newGroupId,
IEnumerable<OAuthConnection>? newOAuthConnections,
IEnumerable<OidcConnection>? newOidcConnections,
IGraphQLAuthorityInvoker<IUserAuthority> userAuthority,
CancellationToken cancellationToken)
=> userAuthority.InvokeTransformable<Models.User, User, UserGraphQLTransformer>(
@@ -539,6 +613,13 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
Provider = oAuthConnection.Provider,
})
.ToList(),
OidcConnections = newOidcConnections
?.Select(oidcConnection => new Api.Models.OidcConnection
{
ExternalUserId = oidcConnection.ExternalUserId,
SchemeKey = oidcConnection.SchemeKey,
})
.ToList(),
},
cancellationToken));
}
@@ -11,7 +11,7 @@ namespace Tgstation.Server.Host.GraphQL.Types.OAuth
{
/// <summary>
/// The <see cref="OAuthProvider"/> of the <see cref="OAuthConnection"/>.
/// </summary>]
/// </summary>
public OAuthProvider Provider { get; }
/// <summary>
@@ -20,21 +20,6 @@ namespace Tgstation.Server.Host.GraphQL.Types.OAuth
/// </summary>
public RedirectOAuthProviderInfo? GitHub { get; }
/// <summary>
/// https://tgstation13.org.
/// </summary>
public RedirectOAuthProviderInfo? TGForums { get; }
/// <summary>
/// https://www.keycloak.org.
/// </summary>
public FullOAuthProviderInfo? Keycloak { get; }
/// <summary>
/// https://invisioncommunity.com.
/// </summary>
public FullOAuthProviderInfo? InvisionCommunity { get; }
/// <summary>
/// Initializes a new instance of the <see cref="OAuthProviderInfos"/> class.
/// </summary>
@@ -58,9 +43,6 @@ namespace Tgstation.Server.Host.GraphQL.Types.OAuth
Discord = TryBuild(OAuthProvider.Discord, info => new BasicOAuthProviderInfo(info));
GitHub = TryBuild(OAuthProvider.GitHub, info => new RedirectOAuthProviderInfo(info));
TGForums = TryBuild(OAuthProvider.TGForums, info => new RedirectOAuthProviderInfo(info));
Keycloak = TryBuild(OAuthProvider.Keycloak, info => new FullOAuthProviderInfo(info));
InvisionCommunity = TryBuild(OAuthProvider.InvisionCommunity, info => new FullOAuthProviderInfo(info));
}
}
}
@@ -0,0 +1,31 @@
using System;
namespace Tgstation.Server.Host.GraphQL.Types.OAuth
{
/// <summary>
/// Represents a valid OAuth connection.
/// </summary>
public sealed class OidcConnection
{
/// <summary>
/// The scheme key of the <see cref="OidcConnection"/>.
/// </summary>]
public string SchemeKey { get; }
/// <summary>
/// The ID of the user in the OIDC provider ("sub" claim).
/// </summary>
public string ExternalUserId { get; }
/// <summary>
/// Initializes a new instance of the <see cref="OidcConnection"/> class.
/// </summary>
/// <param name="externalUserId">The value of <see cref="ExternalUserId"/>.</param>
/// <param name="schemeKey">The value of <see cref="SchemeKey"/>.</param>
public OidcConnection(string externalUserId, string schemeKey)
{
ExternalUserId = externalUserId ?? throw new ArgumentNullException(nameof(externalUserId));
SchemeKey = schemeKey ?? throw new ArgumentNullException(nameof(schemeKey));
}
}
}
@@ -105,6 +105,21 @@ namespace Tgstation.Server.Host.GraphQL.Types
authority => authority.OAuthConnections(Id, cancellationToken));
}
/// <summary>
/// List of <see cref="OidcConnection"/>s associated with the user if OIDC is configured.
/// </summary>
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new <see cref="Array"/> of <see cref="OidcConnection"/>s for the <see cref="User"/> if OAuth is configured.</returns>
public ValueTask<OidcConnection[]> OidcConnections(
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(userAuthority);
return userAuthority.Invoke<OidcConnection[], OidcConnection[]>(
authority => authority.OidcConnections(Id, cancellationToken));
}
/// <summary>
/// The <see cref="PermissionSet"/> associated with the <see cref="User"/>.
/// </summary>
@@ -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>
@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
namespace Tgstation.Server.Host.Models
{
/// <inheritdoc cref="Api.Models.OidcConnection" />
public sealed class OidcConnection : Api.Models.OidcConnection, ILegacyApiTransformable<Api.Models.OidcConnection>
{
/// <summary>
/// The row Id.
/// </summary>
public long Id { get; set; }
/// <summary>
/// The <see cref="Api.Models.EntityId.Id"/> of <see cref="User"/>.
/// </summary>
public long UserId { get; set; }
/// <summary>
/// The owning <see cref="Models.User"/>.
/// </summary>
[Required]
public User? User { get; set; }
/// <inheritdoc />
public Api.Models.OidcConnection ToApi() => new()
{
SchemeKey = SchemeKey,
ExternalUserId = ExternalUserId,
};
}
}
+9 -1
View File
@@ -73,10 +73,15 @@ namespace Tgstation.Server.Host.Models
public ICollection<TestMerge>? TestMerges { get; set; }
/// <summary>
/// The <see cref="TestMerge"/>s made by the <see cref="User"/>.
/// The <see cref="OAuthConnection"/>s for the <see cref="User"/>.
/// </summary>
public ICollection<OAuthConnection>? OAuthConnections { get; set; }
/// <summary>
/// The <see cref="OidcConnection"/>s for the <see cref="User"/>.
/// </summary>
public ICollection<OidcConnection>? OidcConnections { get; set; }
/// <summary>
/// Change a <see cref="UserName.Name"/> into a <see cref="CanonicalName"/>.
/// </summary>
@@ -104,6 +109,9 @@ namespace Tgstation.Server.Host.Models
result.OAuthConnections = OAuthConnections
?.Select(x => x.ToApi())
.ToList();
result.OidcConnections = OidcConnections
?.Select(x => x.ToApi())
.ToList();
result.Group = Group?.ToApi(false);
result.PermissionSet = PermissionSet?.ToApi();
return result;
@@ -1,11 +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.JwtBearer;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -13,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;
@@ -23,6 +27,16 @@ namespace Tgstation.Server.Host.Security
/// <inheritdoc cref="IAuthenticationContext" />
sealed class AuthenticationContextFactory : ITokenValidator, IDisposable
{
/// <summary>
/// Internal scheme prefix for OIDC schemes.
/// </summary>
public const string OpenIDConnectAuthenticationSchemePrefix = $"{OpenIdConnectDefaults.AuthenticationScheme}.";
/// <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>
@@ -48,6 +62,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>
@@ -63,6 +82,30 @@ namespace Tgstation.Server.Host.Security
/// </summary>
int initialized;
/// <summary>
/// Parse a <see cref="DateTimeOffset"/> out of a <see cref="Claim"/> in a given <paramref name="principal"/>.
/// </summary>
/// <param name="principal">The <see cref="ClaimsPrincipal"/> containing claims.</param>
/// <param name="key">The <see cref="Claim"/> name to parse from.</param>
/// <returns>The parsed <see cref="DateTimeOffset"/>.</returns>
static DateTimeOffset ParseTime(ClaimsPrincipal principal, string key)
{
var claim = principal.FindFirst(key);
if (claim == default)
throw new InvalidOperationException($"Missing '{key}' claim!");
try
{
return new DateTimeOffset(
EpochTime.DateTime(
Int64.Parse(claim.Value, CultureInfo.InvariantCulture)));
}
catch (Exception ex)
{
throw new InvalidOperationException($"Failed to parse '{key}'!", ex);
}
}
/// <summary>
/// Initializes a new instance of the <see cref="AuthenticationContextFactory"/> class.
/// </summary>
@@ -70,12 +113,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));
@@ -84,6 +129,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();
@@ -94,7 +141,7 @@ namespace Tgstation.Server.Host.Security
/// <inheritdoc />
#pragma warning disable CA1506 // TODO: Decomplexify
public async Task ValidateToken(TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken)
public async Task ValidateTgsToken(Microsoft.AspNetCore.Authentication.JwtBearer.TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken)
#pragma warning restore CA1506
{
ArgumentNullException.ThrowIfNull(tokenValidatedContext);
@@ -121,26 +168,8 @@ namespace Tgstation.Server.Host.Security
throw new InvalidOperationException("Failed to parse user ID!", e);
}
DateTimeOffset ParseTime(string key)
{
var claim = principal.FindFirst(key);
if (claim == default)
throw new InvalidOperationException($"Missing '{key}' claim!");
try
{
return new DateTimeOffset(
EpochTime.DateTime(
Int64.Parse(claim.Value, CultureInfo.InvariantCulture)));
}
catch (Exception ex)
{
throw new InvalidOperationException($"Failed to parse '{key}'!", ex);
}
}
var notBefore = ParseTime(JwtRegisteredClaimNames.Nbf);
var expires = ParseTime(JwtRegisteredClaimNames.Exp);
var notBefore = ParseTime(principal, JwtRegisteredClaimNames.Nbf);
var expires = ParseTime(principal, JwtRegisteredClaimNames.Exp);
var user = await databaseContext
.Users
@@ -202,5 +231,158 @@ namespace Tgstation.Server.Host.Security
throw;
}
}
/// <inheritdoc />
public async Task ValidateOidcToken(RemoteAuthenticationContext<OpenIdConnectOptions> tokenValidatedContext, string schemeKey, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(tokenValidatedContext);
var principal = tokenValidatedContext.Principal;
if (principal == null)
throw new InvalidOperationException("Expected a valid principal here!");
var userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub);
if (userIdClaim == default)
throw new InvalidOperationException($"Missing '{JwtRegisteredClaimNames.Sub}' claim!");
var userId = userIdClaim.Value;
var scheme = tokenValidatedContext.Scheme.Name;
var deprefixedScheme = scheme.Substring(OpenIDConnectAuthenticationSchemePrefix.Length);
var connection = await databaseContext
.OidcConnections
.AsQueryable()
.Where(oidcConnection => oidcConnection.ExternalUserId == userId && oidcConnection.SchemeKey == deprefixedScheme)
.Include(oidcConnection => oidcConnection.User)
.ThenInclude(user => user!.Group)
.ThenInclude(group => group!.PermissionSet)
.FirstOrDefaultAsync(cancellationToken);
User user;
if (!securityConfiguration.OidcStrictMode)
{
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 (username.Contains(':', StringComparison.Ordinal))
{
tokenValidatedContext.Fail("Cannot create users with the ':' in their name!");
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,
},
cancellationToken);
user = new User
{
CreatedAt = DateTimeOffset.UtcNow,
CanonicalName = User.CanonicalizeName(username),
Name = username,
CreatedById = tgsUser.Id,
Enabled = true,
GroupId = group.Id,
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(
user,
expires,
Guid.NewGuid().ToString(),
null,
null);
}
}
}
@@ -25,8 +25,8 @@ namespace Tgstation.Server.Host.Security
/// Create a <see cref="TokenResponse"/> for a given <paramref name="user"/>.
/// </summary>
/// <param name="user">The <see cref="Models.User"/> to create the token for. Must have the <see cref="Api.Models.EntityId.Id"/> field available.</param>
/// <param name="oAuth">Whether or not this is an OAuth login.</param>
/// <param name="serviceLogin">Whether or not this is an external service login.</param>
/// <returns>A new token <see cref="string"/>.</returns>
string CreateToken(Models.User user, bool oAuth);
string CreateToken(Models.User user, bool serviceLogin);
}
}
@@ -1,21 +1,31 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
namespace Tgstation.Server.Host.Security
{
/// <summary>
/// Handles <see cref="TokenValidatedContext"/>s.
/// Handles validating authentication tokens.
/// </summary>
public interface ITokenValidator
{
/// <summary>
/// Handles <paramref name="tokenValidatedContext"/>.
/// Handles TGS <paramref name="tokenValidatedContext"/>s.
/// </summary>
/// <param name="tokenValidatedContext">The <see cref="TokenValidatedContext"/>.</param>
/// <param name="tokenValidatedContext">The <see cref="Microsoft.AspNetCore.Authentication.JwtBearer.TokenValidatedContext"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task"/> representing the running operation.</returns>
Task ValidateToken(TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken);
Task ValidateTgsToken(Microsoft.AspNetCore.Authentication.JwtBearer.TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken);
/// <summary>
/// Handles OIDC <paramref name="tokenValidatedContext"/>s.
/// </summary>
/// <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(RemoteAuthenticationContext<OpenIdConnectOptions> tokenValidatedContext, string schemeKey, CancellationToken cancellationToken);
}
}
@@ -14,7 +14,9 @@ namespace Tgstation.Server.Host.Security.OAuth
sealed class InvisionCommunityOAuthValidator : GenericOAuthValidator
{
/// <inheritdoc />
#pragma warning disable CS0618 // Type or member is obsolete
public override OAuthProvider Provider => OAuthProvider.InvisionCommunity;
#pragma warning restore CS0618 // Type or member is obsolete
/// <inheritdoc />
protected override Uri TokenUrl => new($"{OAuthConfiguration.ServerUrl}/oauth/token/"); // This needs the trailing slash or it doesnt get the token. Do not remove.
@@ -14,7 +14,9 @@ namespace Tgstation.Server.Host.Security.OAuth
sealed class KeycloakOAuthValidator : GenericOAuthValidator
{
/// <inheritdoc />
#pragma warning disable CS0618 // Type or member is obsolete
public override OAuthProvider Provider => OAuthProvider.Keycloak;
#pragma warning restore CS0618 // Type or member is obsolete
/// <inheritdoc />
protected override Uri TokenUrl => new($"{BaseProtocolPath}/token");
@@ -57,21 +57,18 @@ namespace Tgstation.Server.Host.Security.OAuth
loggerFactory.CreateLogger<DiscordOAuthValidator>(),
discordConfig));
if (securityConfiguration.OAuth.TryGetValue(OAuthProvider.TGForums, out var tgConfig))
validatorsBuilder.Add(
new TGForumsOAuthValidator(
httpClientFactory,
loggerFactory.CreateLogger<TGForumsOAuthValidator>(),
tgConfig));
#pragma warning disable CS0618 // Type or member is obsolete
if (securityConfiguration.OAuth.TryGetValue(OAuthProvider.Keycloak, out var keyCloakConfig))
#pragma warning restore CS0618 // Type or member is obsolete
validatorsBuilder.Add(
new KeycloakOAuthValidator(
httpClientFactory,
loggerFactory.CreateLogger<KeycloakOAuthValidator>(),
keyCloakConfig));
#pragma warning disable CS0618 // Type or member is obsolete
if (securityConfiguration.OAuth.TryGetValue(OAuthProvider.InvisionCommunity, out var invisionConfig))
#pragma warning restore CS0618 // Type or member is obsolete
validatorsBuilder.Add(
new InvisionCommunityOAuthValidator(
httpClientFactory,
@@ -1,51 +0,0 @@
using System;
using Microsoft.Extensions.Logging;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Common.Http;
using Tgstation.Server.Host.Configuration;
namespace Tgstation.Server.Host.Security.OAuth
{
/// <summary>
/// <see cref="IOAuthValidator"/> for /tg/ forums.
/// </summary>
sealed class TGForumsOAuthValidator : GenericOAuthValidator
{
/// <inheritdoc />
public override OAuthProvider Provider => OAuthProvider.TGForums;
/// <inheritdoc />
protected override Uri TokenUrl => new("https://tgstation13.org/phpBB/app.php/tgapi/oauth/token");
/// <inheritdoc />
protected override Uri UserInformationUrl => new("https://tgstation13.org/phpBB/app.php/tgapi/user/me");
/// <summary>
/// Initializes a new instance of the <see cref="TGForumsOAuthValidator"/> class.
/// </summary>
/// <param name="httpClientFactory">The <see cref="IAbstractHttpClientFactory"/> for the <see cref="GenericOAuthValidator"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="GenericOAuthValidator"/>.</param>
/// <param name="oAuthConfiguration">The <see cref="OAuthConfiguration"/> for the <see cref="GenericOAuthValidator"/>.</param>
public TGForumsOAuthValidator(
IAbstractHttpClientFactory httpClientFactory,
ILogger<TGForumsOAuthValidator> logger,
OAuthConfiguration oAuthConfiguration)
: base(
httpClientFactory,
logger,
oAuthConfiguration)
{
}
/// <inheritdoc />
protected override string DecodeTokenPayload(dynamic responseJson) => responseJson.access_token;
/// <inheritdoc />
protected override string DecodeUserInformationPayload(dynamic responseJson) => responseJson.phpbb_username;
/// <inheritdoc />
protected override OAuthTokenRequest CreateTokenRequest(string code) => new(OAuthConfiguration, code, "user");
}
}
@@ -101,7 +101,7 @@ namespace Tgstation.Server.Host.Security
}
/// <inheritdoc />
public string CreateToken(User user, bool oAuth)
public string CreateToken(User user, bool serviceLogin)
{
ArgumentNullException.ThrowIfNull(user);
@@ -121,7 +121,7 @@ namespace Tgstation.Server.Host.Security
else
notBefore = now;
var expiry = now.AddMinutes(oAuth
var expiry = now.AddMinutes(serviceLogin
? securityConfiguration.OAuthTokenExpiryMinutes
: securityConfiguration.TokenExpiryMinutes);
@@ -109,6 +109,8 @@
<PackageReference Include="HotChocolate.Types.Scalars" Version="15.0.3" />
<!-- Usage: git interop -->
<PackageReference Include="LibGit2Sharp" Version="0.31.0" />
<!-- Usage: OpenID Connect support -->
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.13" />
<!-- Usage: Support ""legacy"" Newotonsoft.Json in HTTP pipeline. The rest of our codebase uses Newtonsoft. -->
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.14" />
<!-- Usage: Using target JSON serializer for API -->
@@ -127,6 +129,8 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.3" />
<!-- Usage: Database connectivity health check -->
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.14" />
<!-- Usage: OIDC support prerequisite. See https://stackoverflow.com/a/78650835/3976486 -->
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.6.0" />
<!-- Usage: POSIX support for syscalls, signals, and symlinks -->
<PackageReference Include="Mono.Posix.NETStandard" Version="1.0.0" />
<!-- Usage: Cron string parsing -->
@@ -1,6 +1,6 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -10,7 +10,7 @@ using Moq;
using System;
using System.Threading;
using Tgstation.Server.Host.Components;
using Tgstation.Server.Api;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.System;
@@ -40,43 +40,47 @@ namespace Tgstation.Server.Host.Core.Tests
Assert.ThrowsException<ArgumentNullException>(() => app.ConfigureServices(mockServiceCollection, null, null));
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(null, null, null, null, null, null, null, null, null, null, null));
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(null, null, null, null, null, null, null, null, null, null, null, null));
var mockAppBuilder = new Mock<IApplicationBuilder>();
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, null, null, null, null, null, null, null, null, null, null));
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, null, null, null, null, null, null, null, null, null, null, null));
var mockServerControl = new Mock<IServerControl>();
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, null, null, null, null, null, null, null, null, null));
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, null, null, null, null, null, null, null, null, null, null));
var mockTokenFactory = new Mock<ITokenFactory>();
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, null, null, null, null, null, null, null, null));
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, null, null, null, null, null, null, null, null, null));
var mockServerPortProvider = new Mock<IServerPortProvider>();
mockServerPortProvider.SetupGet(x => x.HttpApiPort).Returns(5345);
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockServerPortProvider.Object, null, null, null, null, null, null, null));
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockServerPortProvider.Object, null, null, null, null, null, null, null, null));
var mockAssemblyInformationProvider = Mock.Of<IAssemblyInformationProvider>();
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockServerPortProvider.Object, mockAssemblyInformationProvider, null, null, null, null, null, null));
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockServerPortProvider.Object, mockAssemblyInformationProvider, null, null, null, null, null, null, null));
var mockControlPanelOptions = new Mock<IOptions<ControlPanelConfiguration>>();
mockControlPanelOptions.SetupGet(x => x.Value).Returns(new ControlPanelConfiguration()).Verifiable();
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockServerPortProvider.Object, mockAssemblyInformationProvider, mockControlPanelOptions.Object, null, null, null, null, null));
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockServerPortProvider.Object, mockAssemblyInformationProvider, mockControlPanelOptions.Object, null, null, null, null, null, null));
var mockGeneralOptions = new Mock<IOptions<GeneralConfiguration>>();
mockGeneralOptions.SetupGet(x => x.Value).Returns(new GeneralConfiguration()).Verifiable();
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockServerPortProvider.Object, mockAssemblyInformationProvider, mockControlPanelOptions.Object, mockGeneralOptions.Object, null, null, null, null));
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockServerPortProvider.Object, mockAssemblyInformationProvider, mockControlPanelOptions.Object, mockGeneralOptions.Object, null, null, null, null, null));
var mockDatabaseOptions = new Mock<IOptions<DatabaseConfiguration>>();
mockDatabaseOptions.SetupGet(x => x.Value).Returns(new DatabaseConfiguration()).Verifiable();
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockServerPortProvider.Object, mockAssemblyInformationProvider, mockControlPanelOptions.Object, mockGeneralOptions.Object, mockDatabaseOptions.Object, null, null, null));
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockServerPortProvider.Object, mockAssemblyInformationProvider, mockControlPanelOptions.Object, mockGeneralOptions.Object, mockDatabaseOptions.Object, null, null, null, null));
var mockSecurityOptions = new Mock<IOptions<SecurityConfiguration>>();
mockDatabaseOptions.SetupGet(x => x.Value).Returns(new DatabaseConfiguration()).Verifiable();
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockServerPortProvider.Object, mockAssemblyInformationProvider, mockControlPanelOptions.Object, mockGeneralOptions.Object, mockDatabaseOptions.Object, mockSecurityOptions.Object, null, null, null));
var mockSwarmOptions = new Mock<IOptions<SwarmConfiguration>>();
mockSwarmOptions.SetupGet(x => x.Value).Returns(new SwarmConfiguration()).Verifiable();
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockServerPortProvider.Object, mockAssemblyInformationProvider, mockControlPanelOptions.Object, mockGeneralOptions.Object, mockDatabaseOptions.Object, mockSwarmOptions.Object, null, null));
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockServerPortProvider.Object, mockAssemblyInformationProvider, mockControlPanelOptions.Object, mockGeneralOptions.Object, mockDatabaseOptions.Object, mockSecurityOptions.Object, mockSwarmOptions.Object, null, null));
var mockInternalOptions = new Mock<IOptions<InternalConfiguration>>();
mockInternalOptions.SetupGet(x => x.Value).Returns(new InternalConfiguration()).Verifiable();
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockServerPortProvider.Object, mockAssemblyInformationProvider, mockControlPanelOptions.Object, mockGeneralOptions.Object, mockDatabaseOptions.Object, mockSwarmOptions.Object, mockInternalOptions.Object, null));
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockServerPortProvider.Object, mockAssemblyInformationProvider, mockControlPanelOptions.Object, mockGeneralOptions.Object, mockDatabaseOptions.Object, mockSecurityOptions.Object, mockSwarmOptions.Object, mockInternalOptions.Object, null));
mockControlPanelOptions.VerifyAll();
mockInternalOptions.VerifyAll();
@@ -89,7 +89,7 @@ namespace Tgstation.Server.Host.Swarm.Tests
public TokenValidationParameters ValidationParameters => throw new NotSupportedException();
public string CreateToken(User user, bool oAuth)
public string CreateToken(User user, bool serviceLogin)
{
throw new NotSupportedException();
}
@@ -19,7 +19,6 @@ using Microsoft.Extensions.Logging;
using Tgstation.Server.Api;
using Tgstation.Server.Api.Hubs;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Request;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Client;
@@ -27,8 +26,6 @@ using Tgstation.Server.Client.Extensions;
using Tgstation.Server.Common.Extensions;
using Tgstation.Server.Host;
using Tgstation.Server.Client.GraphQL;
using System.Linq;
using StrawberryShake;
namespace Tgstation.Server.Tests.Live
{
@@ -127,7 +127,7 @@ namespace Tgstation.Server.Tests.Live
{
Id = restUser.Id,
OAuthConnections = sampleOAuthConnections
}, cancellationToken), Api.Models.ErrorCode.AdminUserCannotOAuth);
}, cancellationToken), Api.Models.ErrorCode.AdminUserCannotHaveServiceConnection);
var testUser = await client.Users.Create(
new UserCreateRequest
@@ -288,7 +288,7 @@ namespace Tgstation.Server.Tests.Live
sampleOAuthConnections,
cancellationToken),
data => data.UpdateUser,
Client.GraphQL.ErrorCode.AdminUserCannotOAuth,
Client.GraphQL.ErrorCode.AdminUserCannotHaveServiceConnection,
cancellationToken);
var testUserResult = await client.RunMutationEnsureNoErrors(
@@ -334,7 +334,7 @@ namespace Tgstation.Server.Tests.Live
Assert.IsFalse(group.PermissionSet.AdministrationRights.CanChangeVersion);
Assert.IsFalse(group.PermissionSet.AdministrationRights.CanDownloadLogs);
Assert.IsFalse(group.PermissionSet.AdministrationRights.CanEditOwnOAuthConnections);
Assert.IsFalse(group.PermissionSet.AdministrationRights.CanEditOwnServiceConnections);
Assert.IsFalse(group.PermissionSet.AdministrationRights.CanEditOwnPassword);
Assert.IsFalse(group.PermissionSet.AdministrationRights.CanReadUsers);
Assert.IsFalse(group.PermissionSet.AdministrationRights.CanRestartHost);
@@ -363,7 +363,7 @@ namespace Tgstation.Server.Tests.Live
Assert.IsFalse(group2.PermissionSet.AdministrationRights.CanChangeVersion);
Assert.IsFalse(group2.PermissionSet.AdministrationRights.CanDownloadLogs);
Assert.IsFalse(group2.PermissionSet.AdministrationRights.CanEditOwnOAuthConnections);
Assert.IsFalse(group2.PermissionSet.AdministrationRights.CanEditOwnServiceConnections);
Assert.IsFalse(group2.PermissionSet.AdministrationRights.CanEditOwnPassword);
Assert.IsFalse(group2.PermissionSet.AdministrationRights.CanReadUsers);
Assert.IsFalse(group2.PermissionSet.AdministrationRights.CanRestartHost);
@@ -416,7 +416,7 @@ namespace Tgstation.Server.Tests.Live
Assert.IsTrue(group3.PermissionSet.AdministrationRights.CanChangeVersion);
Assert.IsTrue(group3.PermissionSet.AdministrationRights.CanDownloadLogs);
Assert.IsTrue(group3.PermissionSet.AdministrationRights.CanEditOwnOAuthConnections);
Assert.IsTrue(group3.PermissionSet.AdministrationRights.CanEditOwnServiceConnections);
Assert.IsTrue(group3.PermissionSet.AdministrationRights.CanEditOwnPassword);
Assert.IsTrue(group3.PermissionSet.AdministrationRights.CanReadUsers);
Assert.IsTrue(group3.PermissionSet.AdministrationRights.CanRestartHost);
@@ -448,7 +448,7 @@ namespace Tgstation.Server.Tests.Live
}
],
cancellationToken),
data => data.CreateUserByOAuthAndPermissionSet,
data => data.CreateUserByServiceConnectionAndPermissionSet,
cancellationToken);
var testUser2 = oAuthCreateResult.User;
@@ -483,7 +483,7 @@ namespace Tgstation.Server.Tests.Live
{
CanChangeVersion = true,
CanDownloadLogs = true,
CanEditOwnOAuthConnections = true,
CanEditOwnServiceConnections = true,
CanEditOwnPassword = true,
CanReadUsers = true,
CanRestartHost = true,