diff --git a/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs b/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs
new file mode 100644
index 0000000000..45121dd8ff
--- /dev/null
+++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs
@@ -0,0 +1,91 @@
+using System;
+using System.Globalization;
+
+using Microsoft.Extensions.Logging;
+
+using Octokit;
+
+using Tgstation.Server.Api.Models;
+using Tgstation.Server.Api.Models.Response;
+
+namespace Tgstation.Server.Host.Authority.Core
+{
+ ///
+ /// Base implementation of .
+ ///
+ abstract class AuthorityBase : IAuthority
+ {
+ ///
+ /// Gets the for the .
+ ///
+ protected ILogger Logger { get; }
+
+ ///
+ /// Generates a type .
+ ///
+ /// The of the .
+ /// The .
+ /// A new, errored .
+ protected static AuthorityResponse BadRequest(ErrorCode errorCode)
+ => new(
+ new ErrorMessageResponse(errorCode),
+ HttpFailureResponse.BadRequest);
+
+ ///
+ /// Generates a type .
+ ///
+ /// The of the .
+ /// A new, errored .
+ protected static AuthorityResponse Unauthorized()
+ => new(
+ new ErrorMessageResponse(),
+ HttpFailureResponse.Unauthorized);
+
+ ///
+ /// Generates a type .
+ ///
+ /// The of the .
+ /// A new, errored .
+ protected static AuthorityResponse Forbid()
+ => new(
+ new ErrorMessageResponse(),
+ HttpFailureResponse.Forbidden);
+
+ ///
+ /// Generates a type .
+ ///
+ /// The of the .
+ /// A new, errored .
+ protected static AuthorityResponse NotFound()
+ => new(
+ new ErrorMessageResponse(),
+ HttpFailureResponse.NotFound);
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The value of .
+ protected AuthorityBase(ILogger logger)
+ {
+ Logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// Generates a type .
+ ///
+ /// The of the .
+ /// The thrown .
+ /// A new, errored .
+ protected AuthorityResponse RateLimit(RateLimitExceededException rateLimitException)
+ {
+ Logger.LogWarning(rateLimitException, "Exceeded GitHub rate limit!");
+ var secondsString = Math.Ceiling(rateLimitException.GetRetryAfterTimeSpan().TotalSeconds).ToString(CultureInfo.InvariantCulture);
+ return new(
+ new ErrorMessageResponse(ErrorCode.GitHubApiRateLimit)
+ {
+ AdditionalData = $"Retry-After: {secondsString}s",
+ },
+ HttpFailureResponse.RateLimited);
+ }
+ }
+}
diff --git a/src/Tgstation.Server.Host/Authority/Core/AuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/AuthorityInvoker{TAuthority}.cs
new file mode 100644
index 0000000000..1756c62cf8
--- /dev/null
+++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityInvoker{TAuthority}.cs
@@ -0,0 +1,161 @@
+using System;
+using System.Net;
+using System.Threading.Tasks;
+
+using HotChocolate.Execution;
+
+using Microsoft.AspNetCore.Mvc;
+using Tgstation.Server.Host.Controllers;
+using Tgstation.Server.Host.Extensions;
+using Tgstation.Server.Host.GraphQL;
+using Tgstation.Server.Host.Models;
+
+namespace Tgstation.Server.Host.Authority.Core
+{
+ ///
+ /// Invokes s.
+ ///
+ /// The invoked.
+ sealed class AuthorityInvoker : IRestAuthorityInvoker, IGraphQLAuthorityInvoker
+ where TAuthority : IAuthority
+ {
+ ///
+ /// The being invoked.
+ ///
+ readonly TAuthority authority;
+
+ ///
+ /// Throws a for errored s.
+ ///
+ /// The potentially errored .
+ static void ThrowGraphQLErrorIfNecessary(AuthorityResponse authorityResponse)
+ {
+ if (authorityResponse.Success)
+ return;
+
+ var fallbackString = authorityResponse.FailureResponse.ToString()!;
+ throw new ErrorMessageException(authorityResponse.ErrorMessage, fallbackString);
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The value of .
+ public AuthorityInvoker(TAuthority authority)
+ {
+ this.authority = authority ?? throw new ArgumentNullException(nameof(authority));
+ }
+
+ ///
+ public async ValueTask Invoke(ApiController controller, Func> authorityInvoker)
+ {
+ var authorityResponse = await authorityInvoker(authority);
+ return CreateErroredActionResult(controller, authorityResponse) ?? controller.NoContent();
+ }
+
+ ///
+ public async ValueTask InvokeTransformable(ApiController controller, Func>> authorityInvoker)
+
+ where TResult : notnull, IApiTransformable
+ where TApiModel : notnull
+ {
+ var authorityResponse = await authorityInvoker(authority);
+ var erroredResult = CreateErroredActionResult(controller, authorityResponse);
+ if (erroredResult != null)
+ return erroredResult;
+
+ var result = authorityResponse.Result!;
+ var apiModel = result.ToApi();
+ return CreateSuccessfulActionResult(controller, apiModel, authorityResponse);
+ }
+
+ ///
+ async ValueTask IRestAuthorityInvoker.Invoke(ApiController controller, Func>> authorityInvoker)
+ {
+ var authorityResponse = await authorityInvoker(authority);
+ var erroredResult = CreateErroredActionResult(controller, authorityResponse);
+ if (erroredResult != null)
+ return erroredResult;
+
+ var result = authorityResponse.Result!;
+ return CreateSuccessfulActionResult(controller, result, authorityResponse);
+ }
+
+ ///
+ async ValueTask IGraphQLAuthorityInvoker.Invoke(Func> authorityInvoker)
+ {
+ var authorityResponse = await authorityInvoker(authority);
+ ThrowGraphQLErrorIfNecessary(authorityResponse);
+ }
+
+ ///
+ public async ValueTask Invoke(Func>> authorityInvoker)
+ where TResult : TApiModel
+ where TApiModel : notnull
+ {
+ var authorityResponse = await authorityInvoker(authority);
+ ThrowGraphQLErrorIfNecessary(authorityResponse);
+ return authorityResponse.Result!;
+ }
+
+ ///
+ async ValueTask IGraphQLAuthorityInvoker.InvokeTransformable(Func>> authorityInvoker)
+ {
+ var authorityResponse = await authorityInvoker(authority);
+ ThrowGraphQLErrorIfNecessary(authorityResponse);
+ return authorityResponse.Result!.ToApi();
+ }
+
+ ///
+ /// Create an for a given if it is erroring.
+ ///
+ /// The to use.
+ /// The .
+ /// An if the is not successful, otherwise.
+ IActionResult? CreateErroredActionResult(ApiController controller, AuthorityResponse authorityResponse)
+ {
+ if (authorityResponse.Success)
+ return null;
+
+ var errorMessage = authorityResponse.ErrorMessage;
+ var failureResponse = authorityResponse.FailureResponse;
+ return failureResponse switch
+ {
+ HttpFailureResponse.BadRequest => controller.BadRequest(errorMessage),
+ HttpFailureResponse.Unauthorized => controller.Unauthorized(errorMessage),
+ HttpFailureResponse.Forbidden => controller.Forbid(),
+ HttpFailureResponse.NotFound => controller.NotFound(errorMessage),
+ HttpFailureResponse.NotAcceptable => controller.StatusCode(HttpStatusCode.NotAcceptable, errorMessage),
+ HttpFailureResponse.Conflict => controller.Conflict(errorMessage),
+ HttpFailureResponse.Gone => controller.StatusCode(HttpStatusCode.Gone, errorMessage),
+ HttpFailureResponse.UnprocessableEntity => controller.UnprocessableEntity(errorMessage),
+ HttpFailureResponse.FailedDependency => controller.StatusCode(HttpStatusCode.FailedDependency, errorMessage),
+ HttpFailureResponse.RateLimited => controller.StatusCode(HttpStatusCode.TooManyRequests, errorMessage),
+ HttpFailureResponse.NotImplemented => controller.StatusCode(HttpStatusCode.NotImplemented, errorMessage),
+ _ => throw new InvalidOperationException($"Invalid {nameof(HttpFailureResponse)}: {failureResponse}"),
+ };
+ }
+
+ ///
+ /// Create an for a given successfuly API .
+ ///
+ /// The to use.
+ /// The resulting from the .
+ /// The .
+ /// An for the .
+ /// The result returned in the .
+ /// The REST API result model built from .
+ IActionResult CreateSuccessfulActionResult(ApiController controller, TApiModel result, AuthorityResponse authorityResponse)
+ where TApiModel : notnull
+ {
+ var successResponse = authorityResponse.SuccessResponse;
+ return successResponse switch
+ {
+ HttpSuccessResponse.Ok => controller.Json(result),
+ HttpSuccessResponse.Created => controller.Created(result),
+ HttpSuccessResponse.Accepted => controller.Accepted(result),
+ _ => throw new InvalidOperationException($"Invalid {nameof(HttpSuccessResponse)}: {successResponse}"),
+ };
+ }
+ }
+}
diff --git a/src/Tgstation.Server.Host/Authority/Core/AuthorityResponse.cs b/src/Tgstation.Server.Host/Authority/Core/AuthorityResponse.cs
new file mode 100644
index 0000000000..61b388dc83
--- /dev/null
+++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityResponse.cs
@@ -0,0 +1,48 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+
+using Tgstation.Server.Api.Models.Response;
+
+namespace Tgstation.Server.Host.Authority.Core
+{
+ ///
+ /// Represents a response from an authority.
+ ///
+ public class AuthorityResponse
+ {
+ ///
+ /// Checks if the was successful.
+ ///
+ [MemberNotNullWhen(false, nameof(ErrorMessage))]
+ [MemberNotNullWhen(false, nameof(FailureResponse))]
+ public virtual bool Success => ErrorMessage == null;
+
+ ///
+ /// Gets the associated . Must only be used if is .
+ ///
+ public ErrorMessageResponse? ErrorMessage { get; }
+
+ ///
+ /// The .
+ ///
+ public HttpFailureResponse? FailureResponse { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public AuthorityResponse()
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The value of .
+ /// The value of .
+ public AuthorityResponse(ErrorMessageResponse errorMessage, HttpFailureResponse failureResponse)
+ {
+ ErrorMessage = errorMessage ?? throw new ArgumentNullException(nameof(errorMessage));
+ FailureResponse = failureResponse;
+ }
+ }
+}
diff --git a/src/Tgstation.Server.Host/Authority/Core/AuthorityResponse{TResult}.cs b/src/Tgstation.Server.Host/Authority/Core/AuthorityResponse{TResult}.cs
new file mode 100644
index 0000000000..44376f488c
--- /dev/null
+++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityResponse{TResult}.cs
@@ -0,0 +1,51 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+
+using Microsoft.AspNetCore.Mvc;
+using Tgstation.Server.Api.Models.Response;
+
+namespace Tgstation.Server.Host.Authority.Core
+{
+ ///
+ /// An with a .
+ ///
+ /// The of success response.
+ public sealed class AuthorityResponse : AuthorityResponse
+ {
+ ///
+ [MemberNotNullWhen(true, nameof(Result))]
+ [MemberNotNullWhen(true, nameof(SuccessResponse))]
+ public override bool Success => base.Success;
+
+ ///
+ /// The success .
+ ///
+ public TResult? Result { get; }
+
+ ///
+ /// The for generating the s.
+ ///
+ public HttpSuccessResponse? SuccessResponse { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The .
+ /// The .
+ public AuthorityResponse(ErrorMessageResponse errorMessage, HttpFailureResponse httpResponse)
+ : base(errorMessage, httpResponse)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The value of .
+ /// The value of .
+ public AuthorityResponse(TResult result, HttpSuccessResponse httpResponse = HttpSuccessResponse.Ok)
+ {
+ Result = result ?? throw new ArgumentNullException(nameof(result));
+ SuccessResponse = httpResponse;
+ }
+ }
+}
diff --git a/src/Tgstation.Server.Host/Authority/Core/HttpFailureResponse.cs b/src/Tgstation.Server.Host/Authority/Core/HttpFailureResponse.cs
new file mode 100644
index 0000000000..5aa3c18089
--- /dev/null
+++ b/src/Tgstation.Server.Host/Authority/Core/HttpFailureResponse.cs
@@ -0,0 +1,63 @@
+namespace Tgstation.Server.Host.Authority.Core
+{
+ ///
+ /// Indicates the type of HTTP status code an failing should generate.
+ ///
+ public enum HttpFailureResponse
+ {
+ ///
+ /// HTTP 400.
+ ///
+ BadRequest,
+
+ ///
+ /// HTTP 401.
+ ///
+ Unauthorized,
+
+ ///
+ /// HTTP 403.
+ ///
+ Forbidden,
+
+ ///
+ /// HTTP 404.
+ ///
+ NotFound,
+
+ ///
+ /// HTTP 406.
+ ///
+ NotAcceptable,
+
+ ///
+ /// HTTP 409.
+ ///
+ Conflict,
+
+ ///
+ /// HTTP 410.
+ ///
+ Gone,
+
+ ///
+ /// HTTP 422.
+ ///
+ UnprocessableEntity,
+
+ ///
+ /// HTTP 424.
+ ///
+ FailedDependency,
+
+ ///
+ /// HTTP 429.
+ ///
+ RateLimited,
+
+ ///
+ /// HTTP 501.
+ ///
+ NotImplemented,
+ }
+}
diff --git a/src/Tgstation.Server.Host/Authority/Core/HttpSuccessResponse.cs b/src/Tgstation.Server.Host/Authority/Core/HttpSuccessResponse.cs
new file mode 100644
index 0000000000..cb7df2a963
--- /dev/null
+++ b/src/Tgstation.Server.Host/Authority/Core/HttpSuccessResponse.cs
@@ -0,0 +1,23 @@
+namespace Tgstation.Server.Host.Authority.Core
+{
+ ///
+ /// Indicates the type of HTTP status code a successful should generate.
+ ///
+ public enum HttpSuccessResponse
+ {
+ ///
+ /// HTTP 200.
+ ///
+ Ok,
+
+ ///
+ /// HTTP 201.
+ ///
+ Created,
+
+ ///
+ /// HTTP 202.
+ ///
+ Accepted,
+ }
+}
diff --git a/src/Tgstation.Server.Host/Authority/Core/IAuthority.cs b/src/Tgstation.Server.Host/Authority/Core/IAuthority.cs
new file mode 100644
index 0000000000..e9b86411f8
--- /dev/null
+++ b/src/Tgstation.Server.Host/Authority/Core/IAuthority.cs
@@ -0,0 +1,11 @@
+#pragma warning disable CA1040 // it's helpful to have a common base class that indicates something is an authority, may remove
+
+namespace Tgstation.Server.Host.Authority.Core
+{
+ ///
+ /// Business logic for interating with the server.
+ ///
+ public interface IAuthority
+ {
+ }
+}
diff --git a/src/Tgstation.Server.Host/Authority/Core/IGraphQLAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/IGraphQLAuthorityInvoker{TAuthority}.cs
new file mode 100644
index 0000000000..7ce01fa637
--- /dev/null
+++ b/src/Tgstation.Server.Host/Authority/Core/IGraphQLAuthorityInvoker{TAuthority}.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Threading.Tasks;
+
+using Tgstation.Server.Host.Models;
+
+namespace Tgstation.Server.Host.Authority.Core
+{
+ ///
+ /// Invokes s from GraphQL endpoints.
+ ///
+ /// The invoked.
+ public interface IGraphQLAuthorityInvoker
+ where TAuthority : IAuthority
+ {
+ ///
+ /// Invoke a method with no success result.
+ ///
+ /// The returning a resulting in the .
+ /// A representing the running operation.
+ ValueTask Invoke(Func> authorityInvoker);
+
+ ///
+ /// Invoke a method and get the result.
+ ///
+ /// The .
+ /// The resulting of the return value.
+ /// The returning a resulting in the .
+ /// A resulting in the generated for the resulting .
+ ValueTask Invoke(Func>> authorityInvoker)
+ where TResult : TApiModel
+ where TApiModel : notnull;
+
+ ///
+ /// Invoke a method and get the result.
+ ///
+ /// The .
+ /// The resulting of the return value.
+ /// The returning a resulting in the .
+ /// A resulting in the generated for the resulting .
+ ValueTask InvokeTransformable(Func>> authorityInvoker)
+ where TResult : notnull, IApiTransformable
+ where TApiModel : notnull;
+ }
+}
diff --git a/src/Tgstation.Server.Host/Authority/Core/IRestAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/IRestAuthorityInvoker{TAuthority}.cs
new file mode 100644
index 0000000000..55e61f34cf
--- /dev/null
+++ b/src/Tgstation.Server.Host/Authority/Core/IRestAuthorityInvoker{TAuthority}.cs
@@ -0,0 +1,49 @@
+using System;
+using System.Threading.Tasks;
+
+using Microsoft.AspNetCore.Mvc;
+using Tgstation.Server.Host.Controllers;
+using Tgstation.Server.Host.Models;
+
+namespace Tgstation.Server.Host.Authority.Core
+{
+ ///
+ /// Invokes methods and generates responses.
+ ///
+ /// The type of .
+ public interface IRestAuthorityInvoker
+ where TAuthority : IAuthority
+ {
+ ///
+ /// Invoke a method with no success result.
+ ///
+ /// The invoking the .
+ /// The returning a resulting in the .
+ /// A resulting in the generated for the resulting .
+ ValueTask Invoke(ApiController controller, Func> authorityInvoker);
+
+ ///
+ /// Invoke a method and get the result.
+ ///
+ /// The .
+ /// The resulting of the .
+ /// The invoking the .
+ /// The returning a resulting in the .
+ /// A resulting in the generated for the resulting .
+ ValueTask Invoke(ApiController controller, Func>> authorityInvoker)
+ where TResult : TApiModel
+ where TApiModel : notnull;
+
+ ///
+ /// Invoke a method and get the result.
+ ///
+ /// The .
+ /// The returned REST .
+ /// The invoking the .
+ /// The returning a resulting in the .
+ /// A resulting in the generated for the resulting .
+ ValueTask InvokeTransformable(ApiController controller, Func>> authorityInvoker)
+ where TResult : notnull, IApiTransformable
+ where TApiModel : notnull;
+ }
+}
diff --git a/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs b/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs
new file mode 100644
index 0000000000..bdb799c5e7
--- /dev/null
+++ b/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs
@@ -0,0 +1,21 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+using Tgstation.Server.Api.Models.Response;
+using Tgstation.Server.Host.Authority.Core;
+
+namespace Tgstation.Server.Host.Authority
+{
+ ///
+ /// for authenticating with the server.
+ ///
+ public interface ILoginAuthority : IAuthority
+ {
+ ///
+ /// Attempt to login to the server with the current crentials.
+ ///
+ /// The for the operation.
+ /// A resulting in a .
+ ValueTask> AttemptLogin(CancellationToken cancellationToken);
+ }
+}
diff --git a/src/Tgstation.Server.Host/Authority/IUserAuthority.cs b/src/Tgstation.Server.Host/Authority/IUserAuthority.cs
new file mode 100644
index 0000000000..29eebfab5b
--- /dev/null
+++ b/src/Tgstation.Server.Host/Authority/IUserAuthority.cs
@@ -0,0 +1,45 @@
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Tgstation.Server.Api.Models;
+using Tgstation.Server.Api.Rights;
+using Tgstation.Server.Host.Authority.Core;
+using Tgstation.Server.Host.Models;
+using Tgstation.Server.Host.Security;
+
+namespace Tgstation.Server.Host.Authority
+{
+ ///
+ /// for managing s.
+ ///
+ public interface IUserAuthority : IAuthority
+ {
+ ///
+ /// Gets the currently authenticated user.
+ ///
+ /// The for the operation.
+ /// A resulting in a .
+ [TgsAuthorize]
+ public ValueTask> Read(CancellationToken cancellationToken);
+
+ ///
+ /// Gets the with a given .
+ ///
+ /// The of the .
+ /// If relevant entities should be loaded.
+ /// The for the operation.
+ /// A resulting in a .
+ [TgsAuthorize(AdministrationRights.ReadUsers)]
+ public ValueTask> GetId(long id, bool includeJoins, CancellationToken cancellationToken);
+
+ ///
+ /// Gets all registered s.
+ ///
+ /// If relevant entities should be loaded.
+ /// The for the operation.
+ /// A resulting in a .
+ [TgsAuthorize(AdministrationRights.ReadUsers)]
+ public ValueTask>> List(bool includeJoins, CancellationToken cancellationToken);
+ }
+}
diff --git a/src/Tgstation.Server.Host/Authority/LoginAuthority.cs b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs
new file mode 100644
index 0000000000..57f331a21c
--- /dev/null
+++ b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs
@@ -0,0 +1,293 @@
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+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.Database;
+using Tgstation.Server.Host.Models;
+using Tgstation.Server.Host.Security;
+using Tgstation.Server.Host.Security.OAuth;
+using Tgstation.Server.Host.Utils;
+
+namespace Tgstation.Server.Host.Authority
+{
+ ///
+ sealed class LoginAuthority : AuthorityBase, ILoginAuthority
+ {
+ ///
+ /// The for the .
+ ///
+ readonly IApiHeadersProvider apiHeadersProvider;
+
+ ///
+ /// The for the .
+ ///
+ readonly ISystemIdentityFactory systemIdentityFactory;
+
+ ///
+ /// The for the .
+ ///
+ readonly IDatabaseContext databaseContext;
+
+ ///
+ /// The for the .
+ ///
+ readonly IOAuthProviders oAuthProviders;
+
+ ///
+ /// The for the .
+ ///
+ readonly ITokenFactory tokenFactory;
+
+ ///
+ /// The for the .
+ ///
+ readonly ICryptographySuite cryptographySuite;
+
+ ///
+ /// The for the .
+ ///
+ readonly IIdentityCache identityCache;
+
+ ///
+ /// Generate an for a given .
+ ///
+ /// The to generate a response for.
+ /// A new, errored .
+ static AuthorityResponse GenerateHeadersExceptionResponse(HeadersException headersException)
+ => new(
+ new ErrorMessageResponse(ErrorCode.BadHeaders)
+ {
+ AdditionalData = headersException.Message,
+ },
+ headersException.ParseErrors.HasFlag(HeaderErrorTypes.Accept)
+ ? HttpFailureResponse.NotAcceptable
+ : HttpFailureResponse.BadRequest);
+
+ ///
+ /// Select the details needed to generate a from a given .
+ ///
+ /// The of s.
+ /// The for the operation.
+ /// A resulting in the returned after selecting, if any.
+ static async ValueTask SelectUserInfoFromQuery(IQueryable query, CancellationToken cancellationToken)
+ {
+ var users = await query
+ .Select(x => new User
+ {
+ Id = x.Id,
+ PasswordHash = x.PasswordHash,
+ Enabled = x.Enabled,
+ Name = x.Name,
+ SystemIdentifier = x.SystemIdentifier,
+ })
+ .ToListAsync(cancellationToken);
+
+ // Pick the DB user first
+ var user = users
+ .OrderByDescending(dbUser => dbUser.SystemIdentifier == null)
+ .FirstOrDefault();
+
+ return user;
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The to use.
+ /// The value of .
+ /// The value of .
+ /// The value of .
+ /// The value of .
+ /// The value of .
+ /// The value of .
+ /// The value of .
+ public LoginAuthority(
+ ILogger logger,
+ IApiHeadersProvider apiHeadersProvider,
+ ISystemIdentityFactory systemIdentityFactory,
+ IDatabaseContext databaseContext,
+ IOAuthProviders oAuthProviders,
+ ITokenFactory tokenFactory,
+ ICryptographySuite cryptographySuite,
+ IIdentityCache identityCache)
+ : base(logger)
+ {
+ this.apiHeadersProvider = apiHeadersProvider ?? throw new ArgumentNullException(nameof(apiHeadersProvider));
+ this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory));
+ this.databaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext));
+ this.oAuthProviders = oAuthProviders ?? throw new ArgumentNullException(nameof(oAuthProviders));
+ this.tokenFactory = tokenFactory ?? throw new ArgumentNullException(nameof(tokenFactory));
+ this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite));
+ this.identityCache = identityCache ?? throw new ArgumentNullException(nameof(identityCache));
+ }
+
+ ///
+ public async ValueTask> AttemptLogin(CancellationToken cancellationToken)
+ {
+ var headers = apiHeadersProvider.ApiHeaders;
+ if (headers == null)
+ return GenerateHeadersExceptionResponse(apiHeadersProvider.HeadersException!);
+
+ if (headers.IsTokenAuthentication)
+ return BadRequest(ErrorCode.TokenWithToken);
+
+ var oAuthLogin = headers.OAuthProvider.HasValue;
+
+ ISystemIdentity? systemIdentity = null;
+ if (!oAuthLogin)
+ try
+ {
+ // trust the system over the database because a user's name can change while still having the same SID
+ systemIdentity = await systemIdentityFactory.CreateSystemIdentity(headers.Username!, headers.Password!, cancellationToken);
+ }
+ catch (NotImplementedException)
+ {
+ // Intentionally suppressed
+ }
+
+ using (systemIdentity)
+ {
+ // Get the user from the database
+ IQueryable query = databaseContext.Users.AsQueryable();
+ if (oAuthLogin)
+ {
+ var oAuthProvider = headers.OAuthProvider!.Value;
+ string? externalUserId;
+ try
+ {
+ var validator = oAuthProviders
+ .GetValidator(oAuthProvider);
+
+ if (validator == null)
+ return BadRequest(ErrorCode.OAuthProviderDisabled);
+
+ externalUserId = await validator
+ .ValidateResponseCode(headers.OAuthCode!, cancellationToken);
+
+ Logger.LogTrace("External {oAuthProvider} UID: {externalUserId}", oAuthProvider, externalUserId);
+ }
+ catch (Octokit.RateLimitExceededException ex)
+ {
+ return RateLimit(ex);
+ }
+
+ if (externalUserId == null)
+ return Unauthorized();
+
+ query = query.Where(
+ x => x.OAuthConnections!.Any(
+ y => y.Provider == oAuthProvider
+ && y.ExternalUserId == externalUserId));
+ }
+ else
+ {
+ var canonicalUserName = User.CanonicalizeName(headers.Username!);
+ if (canonicalUserName == User.CanonicalizeName(User.TgsSystemUserName))
+ return Unauthorized();
+
+ if (systemIdentity == null)
+ query = query.Where(x => x.CanonicalName == canonicalUserName);
+ else
+ query = query.Where(x => x.CanonicalName == canonicalUserName || x.SystemIdentifier == systemIdentity.Uid);
+ }
+
+ var user = await SelectUserInfoFromQuery(query, cancellationToken);
+
+ // No user? You're not allowed
+ if (user == null)
+ return Unauthorized();
+
+ // A system user may have had their name AND password changed to one in our DB...
+ // Or a DB user was created that had the same user/pass as a system user
+ // Dumb admins...
+ // FALLBACK TO THE DB USER HERE, DO NOT REVEAL A SYSTEM LOGIN!!!
+ // This of course, allows system users to discover TGS users in this (HIGHLY IMPROBABLE) case but that is not our fault
+ var originalHash = user.PasswordHash;
+ var isLikelyDbUser = originalHash != null;
+ var usingSystemIdentity = systemIdentity != null && !isLikelyDbUser;
+ if (!oAuthLogin)
+ if (!usingSystemIdentity)
+ {
+ // DB User password check and update
+ if (!isLikelyDbUser || !cryptographySuite.CheckUserPassword(user, headers.Password!))
+ return Unauthorized();
+ if (user.PasswordHash != originalHash)
+ {
+ Logger.LogDebug("User ID {userId}'s password hash needs a refresh, updating database.", user.Id);
+ var updatedUser = new User
+ {
+ Id = user.Id,
+ };
+ databaseContext.Users.Attach(updatedUser);
+ updatedUser.PasswordHash = user.PasswordHash;
+ await databaseContext.Save(cancellationToken);
+ }
+ }
+ else
+ {
+ var usernameMismatch = systemIdentity!.Username != user.Name;
+ if (isLikelyDbUser || usernameMismatch)
+ {
+ databaseContext.Users.Attach(user);
+ if (isLikelyDbUser)
+ {
+ // cleanup from https://github.com/tgstation/tgstation-server/issues/1528
+ Logger.LogDebug("System user ID {userId}'s PasswordHash is polluted, updating database.", user.Id);
+ user.PasswordHash = null;
+ user.LastPasswordUpdate = DateTimeOffset.UtcNow;
+ }
+
+ if (usernameMismatch)
+ {
+ // System identity username change update
+ Logger.LogDebug("User ID {userId}'s system identity needs a refresh, updating database.", user.Id);
+ user.Name = systemIdentity.Username;
+ user.CanonicalName = User.CanonicalizeName(user.Name);
+ }
+
+ await databaseContext.Save(cancellationToken);
+ }
+ }
+
+ // Now that the bookeeping is done, tell them to fuck off if necessary
+ if (!user.Enabled!.Value)
+ {
+ Logger.LogTrace("Not logging in disabled user {userId}.", user.Id);
+ return Forbid();
+ }
+
+ var token = tokenFactory.CreateToken(user, oAuthLogin);
+ if (usingSystemIdentity)
+ await CacheSystemIdentity(systemIdentity!, user, token);
+
+ Logger.LogDebug("Successfully logged in user {userId}!", user.Id);
+
+ return new AuthorityResponse(token);
+ }
+ }
+
+ ///
+ /// Add a given to the .
+ ///
+ /// The to cache.
+ /// The the was generated for.
+ /// The for the .
+ /// A representing the running operation.
+ private async ValueTask CacheSystemIdentity(ISystemIdentity systemIdentity, User user, TokenResponse token)
+ {
+ // expire the identity slightly after the auth token in case of lag
+ var identExpiry = token.ParseJwt().ValidTo;
+ identExpiry += tokenFactory.ValidationParameters.ClockSkew;
+ identExpiry += TimeSpan.FromSeconds(15);
+ await identityCache.CacheSystemIdentity(user, systemIdentity!, identExpiry);
+ }
+ }
+}
diff --git a/src/Tgstation.Server.Host/Authority/UserAuthority.cs b/src/Tgstation.Server.Host/Authority/UserAuthority.cs
new file mode 100644
index 0000000000..a1d09a2afd
--- /dev/null
+++ b/src/Tgstation.Server.Host/Authority/UserAuthority.cs
@@ -0,0 +1,83 @@
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+using Tgstation.Server.Host.Authority.Core;
+using Tgstation.Server.Host.Database;
+using Tgstation.Server.Host.Models;
+using Tgstation.Server.Host.Security;
+
+namespace Tgstation.Server.Host.Authority
+{
+ ///
+ sealed class UserAuthority : AuthorityBase, IUserAuthority
+ {
+ ///
+ /// The for the .
+ ///
+ readonly IDatabaseContext databaseContext;
+
+ ///
+ /// The for the .
+ ///
+ readonly IAuthenticationContext authenticationContext;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The to use.
+ /// The value of .
+ /// The value of .
+ public UserAuthority(
+ ILogger logger,
+ IDatabaseContext databaseContext,
+ IAuthenticationContext authenticationContext)
+ : base(logger)
+ {
+ this.databaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext));
+ this.authenticationContext = authenticationContext ?? throw new ArgumentNullException(nameof(authenticationContext));
+ }
+
+ ///
+ public ValueTask> Read(CancellationToken cancellationToken)
+ => ValueTask.FromResult(new AuthorityResponse(authenticationContext.User));
+
+ ///
+ public async ValueTask> GetId(long id, bool includeJoins, CancellationToken cancellationToken)
+ {
+ var queryable = databaseContext
+ .Users
+ .AsQueryable();
+
+ if (includeJoins)
+ queryable = queryable
+ .Where(x => x.Id == id)
+ .Include(x => x.CreatedBy)
+ .Include(x => x.OAuthConnections)
+ .Include(x => x.Group!)
+ .ThenInclude(x => x.PermissionSet)
+ .Include(x => x.PermissionSet);
+
+ var user = await queryable.FirstOrDefaultAsync(
+ dbModel => dbModel.Id == id,
+ cancellationToken);
+ if (user == default)
+ return NotFound();
+
+ if (user.CanonicalName == User.CanonicalizeName(User.TgsSystemUserName))
+ return Forbid();
+
+ return new AuthorityResponse(user);
+ }
+
+ ///
+ public ValueTask>> List(bool includeJoins, CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+ }
+}
diff --git a/src/Tgstation.Server.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs
index bad2e6c10f..60eb6b9e54 100644
--- a/src/Tgstation.Server.Host/Controllers/ApiController.cs
+++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs
@@ -104,6 +104,19 @@ namespace Tgstation.Server.Host.Controllers
this.requireHeaders = requireHeaders;
}
+ ///
+ /// Generic 201 response with a given .
+ ///
+ /// The accompanying API payload.
+ /// A with the given .
+ public ObjectResult Created(object payload) => StatusCode((int)HttpStatusCode.Created, payload);
+
+ ///
+ /// Generic 401 response.
+ ///
+ /// An with .
+ public new ObjectResult Unauthorized() => this.StatusCode(HttpStatusCode.Unauthorized, null);
+
///
#pragma warning disable CA1506 // TODO: Decomplexify
protected override async ValueTask HookExecuteAction(Func executeAction, CancellationToken cancellationToken)
@@ -186,12 +199,6 @@ namespace Tgstation.Server.Host.Controllers
/// A with an appropriate .
protected new NotFoundObjectResult NotFound() => NotFound(new ErrorMessageResponse(ErrorCode.ResourceNeverPresent));
- ///
- /// Generic 401 response.
- ///
- /// An with .
- protected new ObjectResult Unauthorized() => this.StatusCode(HttpStatusCode.Unauthorized, null);
-
///
/// Generic 501 response.
///
@@ -210,13 +217,6 @@ namespace Tgstation.Server.Host.Controllers
/// A with the given .
protected StatusCodeResult StatusCode(HttpStatusCode statusCode) => StatusCode((int)statusCode);
- ///
- /// Generic 201 response with a given .
- ///
- /// The accompanying API payload.
- /// A with the given .
- protected ObjectResult Created(object payload) => StatusCode((int)HttpStatusCode.Created, payload);
-
///
/// 429 response for a given .
///
diff --git a/src/Tgstation.Server.Host/Controllers/ApiRootController.cs b/src/Tgstation.Server.Host/Controllers/ApiRootController.cs
index 626a0a71b1..2ac52cf28c 100644
--- a/src/Tgstation.Server.Host/Controllers/ApiRootController.cs
+++ b/src/Tgstation.Server.Host/Controllers/ApiRootController.cs
@@ -5,7 +5,6 @@ using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
@@ -14,8 +13,9 @@ using Microsoft.Net.Http.Headers;
using Octokit;
using Tgstation.Server.Api;
-using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Response;
+using Tgstation.Server.Host.Authority;
+using Tgstation.Server.Host.Authority.Core;
using Tgstation.Server.Host.Components.Interop;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Core;
@@ -35,31 +35,11 @@ namespace Tgstation.Server.Host.Controllers
[Route(Routes.ApiRoot)]
public sealed class ApiRootController : ApiController
{
- ///
- /// The for the .
- ///
- readonly ITokenFactory tokenFactory;
-
- ///
- /// The for the .
- ///
- readonly ISystemIdentityFactory systemIdentityFactory;
-
- ///
- /// The for the .
- ///
- readonly ICryptographySuite cryptographySuite;
-
///
/// The for the .
///
readonly IAssemblyInformationProvider assemblyInformationProvider;
- ///
- /// The for the .
- ///
- readonly IIdentityCache identityCache;
-
///
/// The for the .
///
@@ -80,6 +60,11 @@ namespace Tgstation.Server.Host.Controllers
///
readonly IServerControl serverControl;
+ ///
+ /// The for the .
+ ///
+ readonly IRestAuthorityInvoker loginAuthority;
+
///
/// The for the .
///
@@ -90,11 +75,7 @@ namespace Tgstation.Server.Host.Controllers
///
/// The for the .
/// The for the .
- /// The value of .
- /// The value of .
- /// The value of .
/// The value of .
- /// The value of .
/// The value of .
/// The value of .
/// The value of .
@@ -102,21 +83,19 @@ namespace Tgstation.Server.Host.Controllers
/// The containing the value of .
/// The for the .
/// The for the .
+ /// The value of .
public ApiRootController(
IDatabaseContext databaseContext,
IAuthenticationContext authenticationContext,
- ITokenFactory tokenFactory,
- ISystemIdentityFactory systemIdentityFactory,
- ICryptographySuite cryptographySuite,
IAssemblyInformationProvider assemblyInformationProvider,
- IIdentityCache identityCache,
IOAuthProviders oAuthProviders,
IPlatformIdentifier platformIdentifier,
ISwarmService swarmService,
IServerControl serverControl,
IOptions generalConfigurationOptions,
ILogger logger,
- IApiHeadersProvider apiHeadersProvider)
+ IApiHeadersProvider apiHeadersProvider,
+ IRestAuthorityInvoker loginAuthority)
: base(
databaseContext,
authenticationContext,
@@ -124,16 +103,13 @@ namespace Tgstation.Server.Host.Controllers
logger,
false)
{
- this.tokenFactory = tokenFactory ?? throw new ArgumentNullException(nameof(tokenFactory));
- this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory));
- this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite));
this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider));
- this.identityCache = identityCache ?? throw new ArgumentNullException(nameof(identityCache));
this.platformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier));
this.oAuthProviders = oAuthProviders ?? throw new ArgumentNullException(nameof(oAuthProviders));
this.swarmService = swarmService ?? throw new ArgumentNullException(nameof(swarmService));
this.serverControl = serverControl ?? throw new ArgumentNullException(nameof(serverControl));
generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
+ this.loginAuthority = loginAuthority ?? throw new ArgumentNullException(nameof(loginAuthority));
}
///
@@ -202,172 +178,15 @@ namespace Tgstation.Server.Host.Controllers
[HttpPost]
[ProducesResponseType(typeof(TokenResponse), 200)]
[ProducesResponseType(typeof(ErrorMessageResponse), 429)]
-#pragma warning disable CA1506 // TODO: Decomplexify
- public async ValueTask CreateToken(CancellationToken cancellationToken)
+ public ValueTask CreateToken(CancellationToken cancellationToken)
{
if (ApiHeaders == null)
{
Response.Headers.Add(HeaderNames.WWWAuthenticate, new StringValues($"basic realm=\"Create TGS {ApiHeaders.BearerAuthenticationScheme} token\""));
- return HeadersIssue(ApiHeadersProvider.HeadersException!);
+ return ValueTask.FromResult(HeadersIssue(ApiHeadersProvider.HeadersException!));
}
- if (ApiHeaders.IsTokenAuthentication)
- return BadRequest(new ErrorMessageResponse(ErrorCode.TokenWithToken));
-
- var oAuthLogin = ApiHeaders.OAuthProvider.HasValue;
-
- ISystemIdentity? systemIdentity = null;
- if (!oAuthLogin)
- try
- {
- // trust the system over the database because a user's name can change while still having the same SID
- systemIdentity = await systemIdentityFactory.CreateSystemIdentity(ApiHeaders.Username!, ApiHeaders.Password!, cancellationToken);
- }
- catch (NotImplementedException)
- {
- // Intentionally suppressed
- }
-
- using (systemIdentity)
- {
- // Get the user from the database
- IQueryable query = DatabaseContext.Users.AsQueryable();
- if (oAuthLogin)
- {
- var oAuthProvider = ApiHeaders.OAuthProvider!.Value;
- string? externalUserId;
- try
- {
- var validator = oAuthProviders
- .GetValidator(oAuthProvider);
-
- if (validator == null)
- return BadRequest(new ErrorMessageResponse(ErrorCode.OAuthProviderDisabled));
-
- externalUserId = await validator
- .ValidateResponseCode(ApiHeaders.OAuthCode!, cancellationToken);
-
- Logger.LogTrace("External {oAuthProvider} UID: {externalUserId}", oAuthProvider, externalUserId);
- }
- catch (RateLimitExceededException ex)
- {
- return RateLimit(ex);
- }
-
- if (externalUserId == null)
- return Unauthorized();
-
- query = query.Where(
- x => x.OAuthConnections!.Any(
- y => y.Provider == oAuthProvider
- && y.ExternalUserId == externalUserId));
- }
- else
- {
- var canonicalUserName = Models.User.CanonicalizeName(ApiHeaders.Username!);
- if (canonicalUserName == Models.User.CanonicalizeName(Models.User.TgsSystemUserName))
- return Unauthorized();
-
- if (systemIdentity == null)
- query = query.Where(x => x.CanonicalName == canonicalUserName);
- else
- query = query.Where(x => x.CanonicalName == canonicalUserName || x.SystemIdentifier == systemIdentity.Uid);
- }
-
- var users = await query
- .Select(x => new Models.User
- {
- Id = x.Id,
- PasswordHash = x.PasswordHash,
- Enabled = x.Enabled,
- Name = x.Name,
- SystemIdentifier = x.SystemIdentifier,
- })
- .ToListAsync(cancellationToken);
-
- // Pick the DB user first
- var user = users
- .OrderByDescending(dbUser => dbUser.SystemIdentifier == null)
- .FirstOrDefault();
-
- // No user? You're not allowed
- if (user == null)
- return Unauthorized();
-
- // A system user may have had their name AND password changed to one in our DB...
- // Or a DB user was created that had the same user/pass as a system user
- // Dumb admins...
- // FALLBACK TO THE DB USER HERE, DO NOT REVEAL A SYSTEM LOGIN!!!
- // This of course, allows system users to discover TGS users in this (HIGHLY IMPROBABLE) case but that is not our fault
- var originalHash = user.PasswordHash;
- var isLikelyDbUser = originalHash != null;
- bool usingSystemIdentity = systemIdentity != null && !isLikelyDbUser;
- if (!oAuthLogin)
- if (!usingSystemIdentity)
- {
- // DB User password check and update
- if (!isLikelyDbUser || !cryptographySuite.CheckUserPassword(user, ApiHeaders.Password!))
- return Unauthorized();
- if (user.PasswordHash != originalHash)
- {
- Logger.LogDebug("User ID {userId}'s password hash needs a refresh, updating database.", user.Id);
- var updatedUser = new Models.User
- {
- Id = user.Id,
- };
- DatabaseContext.Users.Attach(updatedUser);
- updatedUser.PasswordHash = user.PasswordHash;
- await DatabaseContext.Save(cancellationToken);
- }
- }
- else
- {
- var usernameMismatch = systemIdentity!.Username != user.Name;
- if (isLikelyDbUser || usernameMismatch)
- {
- DatabaseContext.Users.Attach(user);
- if (isLikelyDbUser)
- {
- // cleanup from https://github.com/tgstation/tgstation-server/issues/1528
- Logger.LogDebug("System user ID {userId}'s PasswordHash is polluted, updating database.", user.Id);
- user.PasswordHash = null;
- user.LastPasswordUpdate = DateTimeOffset.UtcNow;
- }
-
- if (usernameMismatch)
- {
- // System identity username change update
- Logger.LogDebug("User ID {userId}'s system identity needs a refresh, updating database.", user.Id);
- user.Name = systemIdentity.Username;
- user.CanonicalName = Models.User.CanonicalizeName(user.Name);
- }
-
- await DatabaseContext.Save(cancellationToken);
- }
- }
-
- // Now that the bookeeping is done, tell them to fuck off if necessary
- if (!user.Enabled!.Value)
- {
- Logger.LogTrace("Not logging in disabled user {userId}.", user.Id);
- return Forbid();
- }
-
- var token = tokenFactory.CreateToken(user, oAuthLogin);
- if (usingSystemIdentity)
- {
- // expire the identity slightly after the auth token in case of lag
- var identExpiry = token.ParseJwt().ValidTo;
- identExpiry += tokenFactory.ValidationParameters.ClockSkew;
- identExpiry += TimeSpan.FromSeconds(15);
- await identityCache.CacheSystemIdentity(user, systemIdentity!, identExpiry);
- }
-
- Logger.LogDebug("Successfully logged in user {userId}!", user.Id);
-
- return Json(token);
- }
+ return loginAuthority.Invoke(this, authority => authority.AttemptLogin(cancellationToken));
}
-#pragma warning restore CA1506
}
}
diff --git a/src/Tgstation.Server.Host/Controllers/UserController.cs b/src/Tgstation.Server.Host/Controllers/UserController.cs
index 68bccfa169..35c5021e5e 100644
--- a/src/Tgstation.Server.Host/Controllers/UserController.cs
+++ b/src/Tgstation.Server.Host/Controllers/UserController.cs
@@ -14,6 +14,8 @@ using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Request;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Api.Rights;
+using Tgstation.Server.Host.Authority;
+using Tgstation.Server.Host.Authority.Core;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Controllers.Results;
using Tgstation.Server.Host.Database;
@@ -45,6 +47,11 @@ namespace Tgstation.Server.Host.Controllers
///
readonly IPermissionsUpdateNotifyee permissionsUpdateNotifyee;
+ ///
+ /// The for the .
+ ///
+ readonly IRestAuthorityInvoker userAuthority;
+
///
/// The for the .
///
@@ -57,6 +64,7 @@ namespace Tgstation.Server.Host.Controllers
/// The for the .
/// The value of .
/// The value of .
+ /// The value of .
/// The value of .
/// The for the .
/// The containing the value of .
@@ -67,6 +75,7 @@ namespace Tgstation.Server.Host.Controllers
ISystemIdentityFactory systemIdentityFactory,
ICryptographySuite cryptographySuite,
IPermissionsUpdateNotifyee permissionsUpdateNotifyee,
+ IRestAuthorityInvoker userAuthority,
ILogger logger,
IOptions generalConfigurationOptions,
IApiHeadersProvider apiHeaders)
@@ -80,6 +89,7 @@ namespace Tgstation.Server.Host.Controllers
this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory));
this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite));
this.permissionsUpdateNotifyee = permissionsUpdateNotifyee ?? throw new ArgumentNullException(nameof(permissionsUpdateNotifyee));
+ this.userAuthority = userAuthority ?? throw new ArgumentNullException(nameof(userAuthority));
generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
}
@@ -343,12 +353,14 @@ namespace Tgstation.Server.Host.Controllers
///
/// Get information about the current .
///
- /// The of the operation.
+ /// The for the operation.
+ /// A resulting in the of the operation.
/// The was retrieved successfully.
[HttpGet]
- [TgsAuthorize]
+ [TgsRestAuthorize(nameof(IUserAuthority.Read))]
[ProducesResponseType(typeof(UserResponse), 200)]
- public IActionResult Read() => Json(AuthenticationContext.User.ToApi());
+ public ValueTask Read(CancellationToken cancellationToken)
+ => userAuthority.InvokeTransformable(this, authority => authority.Read(cancellationToken));
///
/// List all s in the server.
@@ -395,27 +407,14 @@ namespace Tgstation.Server.Host.Controllers
public async ValueTask GetId(long id, CancellationToken cancellationToken)
{
if (id == AuthenticationContext.User.Id)
- return Read();
+ return await Read(cancellationToken);
if (!((AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration)).HasFlag(AdministrationRights.ReadUsers))
return Forbid();
- var user = await DatabaseContext.Users
- .AsQueryable()
- .Where(x => x.Id == id)
- .Include(x => x.CreatedBy)
- .Include(x => x.OAuthConnections)
- .Include(x => x.Group!)
- .ThenInclude(x => x.PermissionSet)
- .Include(x => x.PermissionSet)
- .FirstOrDefaultAsync(cancellationToken);
- if (user == default)
- return NotFound();
-
- if (user.CanonicalName == Models.User.CanonicalizeName(Models.User.TgsSystemUserName))
- return Forbid();
-
- return Json(user.ToApi());
+ return await userAuthority.InvokeTransformable(
+ this,
+ authority => authority.GetId(id, true, cancellationToken));
}
///
diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs
index 0730eabd06..33e10e3d0d 100644
--- a/src/Tgstation.Server.Host/Core/Application.cs
+++ b/src/Tgstation.Server.Host/Core/Application.cs
@@ -8,6 +8,7 @@ using Cyberboss.AspNetCore.AsyncInitializer;
using Elastic.CommonSchema.Serilog;
+using HotChocolate.AspNetCore;
using HotChocolate.Types;
using Microsoft.AspNetCore.Authentication;
@@ -37,6 +38,8 @@ using Tgstation.Server.Api;
using Tgstation.Server.Api.Hubs;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Common.Http;
+using Tgstation.Server.Host.Authority;
+using Tgstation.Server.Host.Authority.Core;
using Tgstation.Server.Host.Components;
using Tgstation.Server.Host.Components.Chat;
using Tgstation.Server.Host.Components.Deployment.Remote;
@@ -292,9 +295,12 @@ namespace Tgstation.Server.Host.Core
services
.AddGraphQLServer()
.AddAuthorization()
+ .AddMutationConventions()
+ .AddErrorFilter()
.AddType()
.BindRuntimeType()
- .AddQueryType();
+ .AddQueryType()
+ .AddMutationType();
void AddTypedContext()
where TContext : DatabaseContext
@@ -431,6 +437,12 @@ namespace Tgstation.Server.Host.Core
services.AddSingleton();
services.AddSingleton();
+ // configure authorities
+ services.AddScoped(typeof(IRestAuthorityInvoker<>), typeof(AuthorityInvoker<>));
+ services.AddScoped(typeof(IGraphQLAuthorityInvoker<>), typeof(AuthorityInvoker<>));
+ services.AddScoped();
+ services.AddScoped();
+
// configure misc services
services.AddSingleton();
services.AddSingleton();
@@ -616,7 +628,12 @@ namespace Tgstation.Server.Host.Core
if (internalConfiguration.EnableGraphQL)
{
logger.LogWarning("Enabling GraphQL. This API is experimental and breaking changes may occur at any time!");
- endpoints.MapGraphQL(Routes.GraphQL);
+ endpoints
+ .MapGraphQL(Routes.GraphQL)
+ .WithOptions(new GraphQLServerOptions
+ {
+ EnableBatching = true,
+ });
}
});
diff --git a/src/Tgstation.Server.Host/GraphQL/ErrorMessageException.cs b/src/Tgstation.Server.Host/GraphQL/ErrorMessageException.cs
new file mode 100644
index 0000000000..20c8550b6b
--- /dev/null
+++ b/src/Tgstation.Server.Host/GraphQL/ErrorMessageException.cs
@@ -0,0 +1,37 @@
+using System;
+
+using Tgstation.Server.Api.Models;
+using Tgstation.Server.Api.Models.Response;
+
+#pragma warning disable CA1032 // Shitty unneeded additional Exception constructors
+
+namespace Tgstation.Server.Host.GraphQL
+{
+ ///
+ /// representing s.
+ ///
+ public sealed class ErrorMessageException : Exception
+ {
+ ///
+ /// The .
+ ///
+ public ErrorCode? ErrorCode { get; }
+
+ ///
+ /// The .
+ ///
+ public string? AdditionalData { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The .
+ /// Fallback .
+ public ErrorMessageException(ErrorMessageResponse errorMessage, string fallbackMessage)
+ : base((errorMessage ?? throw new ArgumentNullException(nameof(errorMessage))).Message ?? fallbackMessage)
+ {
+ ErrorCode = errorMessage.ErrorCode != default ? errorMessage.ErrorCode : null;
+ AdditionalData = errorMessage.AdditionalData;
+ }
+ }
+}
diff --git a/src/Tgstation.Server.Host/GraphQL/ErrorMessageFilter.cs b/src/Tgstation.Server.Host/GraphQL/ErrorMessageFilter.cs
new file mode 100644
index 0000000000..cbb9ac05e8
--- /dev/null
+++ b/src/Tgstation.Server.Host/GraphQL/ErrorMessageFilter.cs
@@ -0,0 +1,79 @@
+using System;
+
+using HotChocolate;
+
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+using Tgstation.Server.Api.Models;
+using Tgstation.Server.Api.Models.Response;
+
+namespace Tgstation.Server.Host.GraphQL
+{
+ ///
+ /// for transforming -like .
+ ///
+ sealed class ErrorMessageFilter : IErrorFilter
+ {
+ ///
+ /// The for the .
+ ///
+ readonly ILogger logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The value of .
+ public ErrorMessageFilter(ILogger logger)
+ {
+ this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ public IError OnError(IError error)
+ {
+ ArgumentNullException.ThrowIfNull(error);
+
+ if (error.Exception == null)
+ return error;
+
+ var errorBuilder = ErrorBuilder.FromError(error)
+ .RemoveException()
+ .ClearExtensions();
+
+ const string ErrorCodeFieldName = "errorCode";
+ const string AdditionalDataFieldName = "additionalData";
+
+ if (error.Exception is DbUpdateException dbUpdateException)
+ {
+ if (dbUpdateException.InnerException is OperationCanceledException)
+ {
+ logger.LogTrace(dbUpdateException, "Rethrowing DbUpdateException as OperationCanceledException");
+ throw dbUpdateException.InnerException;
+ }
+
+ logger.LogDebug(dbUpdateException, "Database conflict!");
+ return errorBuilder
+ .SetMessage(dbUpdateException.Message)
+ .SetExtension(ErrorCodeFieldName, ErrorCode.DatabaseIntegrityConflict)
+ .SetExtension(AdditionalDataFieldName, (dbUpdateException.InnerException ?? dbUpdateException).Message)
+ .Build();
+ }
+
+ if (error.Exception is not ErrorMessageException errorMessageException)
+ {
+ return errorBuilder
+ .SetMessage(error.Exception.Message)
+ .SetExtension(ErrorCodeFieldName, ErrorCode.InternalServerError)
+ .SetExtension(AdditionalDataFieldName, error.Exception.ToString())
+ .Build();
+ }
+
+ return errorBuilder
+ .SetMessage(errorMessageException.Message)
+ .SetExtension(ErrorCodeFieldName, errorMessageException.ErrorCode)
+ .SetExtension(AdditionalDataFieldName, errorMessageException.AdditionalData)
+ .Build();
+ }
+ }
+}
diff --git a/src/Tgstation.Server.Host/GraphQL/Mutation.cs b/src/Tgstation.Server.Host/GraphQL/Mutation.cs
index 7988affc6b..972612f885 100644
--- a/src/Tgstation.Server.Host/GraphQL/Mutation.cs
+++ b/src/Tgstation.Server.Host/GraphQL/Mutation.cs
@@ -1,11 +1,39 @@
-namespace Tgstation.Server.Host.GraphQL
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+using HotChocolate;
+using HotChocolate.Types;
+
+using Tgstation.Server.Api.Models.Response;
+using Tgstation.Server.Host.Authority;
+using Tgstation.Server.Host.Authority.Core;
+
+namespace Tgstation.Server.Host.GraphQL
{
///
/// Root type for GraphQL mutations.
///
+ /// Intentionally left mostly empty, use type extensions to properly scope operations to domains.
public sealed class Mutation
{
- // Intentionally left blank, use type extensions to properly scope operations to domains
- // https://chillicream.com/docs/hotchocolate/v13/defining-a-schema/extending-types
+ ///
+ /// Generate JWT for authenticating with server.
+ ///
+ /// The .
+ /// The for the operation.
+ /// A Bearer token to be used with further communication with the server.
+ [Error(typeof(ErrorMessageException))]
+ public async ValueTask Login(
+ [Service] IGraphQLAuthorityInvoker loginAuthority,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(loginAuthority);
+
+ var tokenResponse = await loginAuthority.Invoke(
+ authority => authority.AttemptLogin(cancellationToken));
+
+ return tokenResponse.Bearer!;
+ }
}
}
diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Entity.cs b/src/Tgstation.Server.Host/GraphQL/Types/Entity.cs
index 20c8a08b80..ab51d61a6f 100644
--- a/src/Tgstation.Server.Host/GraphQL/Types/Entity.cs
+++ b/src/Tgstation.Server.Host/GraphQL/Types/Entity.cs
@@ -1,4 +1,6 @@
-namespace Tgstation.Server.Host.GraphQL.Types
+using HotChocolate.Types.Relay;
+
+namespace Tgstation.Server.Host.GraphQL.Types
{
///
/// Represents a database entity.
@@ -8,6 +10,7 @@
///
/// The ID of the .
///
+ [ID]
public long Id { get; }
///
diff --git a/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs b/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs
index e4ddf59dd2..f2ec6c6151 100644
--- a/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs
+++ b/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using HotChocolate;
+
using Tgstation.Server.Api.Models.Internal;
using Tgstation.Server.Host.Core;
using Tgstation.Server.Host.Swarm;
@@ -35,6 +36,12 @@ namespace Tgstation.Server.Host.GraphQL.Types
/// A new .
public LocalServer LocalServer() => new();
+ ///
+ /// Gets the swarm's .
+ ///
+ /// A new .
+ public Users Users() => new();
+
///
/// Gets the for all servers in a swarm.
///
diff --git a/src/Tgstation.Server.Host/GraphQL/Types/User.cs b/src/Tgstation.Server.Host/GraphQL/Types/User.cs
index 93badc8165..f32c6ff2cd 100644
--- a/src/Tgstation.Server.Host/GraphQL/Types/User.cs
+++ b/src/Tgstation.Server.Host/GraphQL/Types/User.cs
@@ -14,6 +14,11 @@ namespace Tgstation.Server.Host.GraphQL.Types
///
public bool Enabled { get; }
+ ///
+ /// The user's canonical (Uppercase) name.
+ ///
+ public string CanonicalName { get; }
+
///
/// When the was created.
///
@@ -27,30 +32,41 @@ namespace Tgstation.Server.Host.GraphQL.Types
///
/// The of the .
///
- readonly long createdById;
+ readonly long? createdById;
+
+ ///
+ /// The of the .
+ ///
+ readonly long? groupId;
///
/// Initializes a new instance of the class.
///
/// The .
/// The .
+ /// The value of .
/// The value of .
- /// The value of .
/// The value of .
+ /// The value of .
+ /// The value of .
/// The value of .
public User(
long id,
string name,
+ string canonicalName,
string? systemIdentifier,
DateTimeOffset createdAt,
- long createdById,
+ long? createdById,
+ long? groupId,
bool enabled)
: base(id, name)
{
SystemIdentifier = systemIdentifier;
+ CanonicalName = canonicalName ?? throw new ArgumentNullException(nameof(canonicalName));
CreatedAt = createdAt;
this.createdById = createdById;
Enabled = enabled;
+ this.groupId = groupId;
}
///
diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Users.cs b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs
new file mode 100644
index 0000000000..30d683201c
--- /dev/null
+++ b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+using HotChocolate;
+using HotChocolate.Types;
+using HotChocolate.Types.Relay;
+
+using Tgstation.Server.Host.Authority;
+using Tgstation.Server.Host.Authority.Core;
+using Tgstation.Server.Host.Security;
+
+#pragma warning disable CA1724 // conflict with GitLabApiClient.Models.Users. They can fuck off
+
+namespace Tgstation.Server.Host.GraphQL.Types
+{
+ ///
+ /// Wrapper for accessing s.
+ ///
+ public sealed class Users
+ {
+ ///
+ /// Gets the current .
+ ///
+ /// The .
+ /// The for the operation.
+ /// A resulting in the current .
+ [TgsGraphQLAuthorize(nameof(IUserAuthority.Read))]
+ public ValueTask Current(
+ [Service] IGraphQLAuthorityInvoker userAuthority,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(userAuthority);
+ return userAuthority.InvokeTransformable(authority => authority.Read(cancellationToken));
+ }
+
+ ///
+ /// Gets a user by .
+ ///
+ /// The of the .
+ /// The .
+ /// The for the operation.
+ /// A resulting in a .
+ [Error(typeof(ErrorMessageException))]
+ [TgsGraphQLAuthorize(nameof(IUserAuthority.GetId))]
+ public async ValueTask ById(
+ [ID(nameof(User))] long id,
+ [Service] IGraphQLAuthorityInvoker userAuthority,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(userAuthority);
+ return await userAuthority.InvokeTransformable(authority => authority.GetId(id, false, cancellationToken));
+ }
+ }
+}
diff --git a/src/Tgstation.Server.Host/Models/User.cs b/src/Tgstation.Server.Host/Models/User.cs
index 0939dd8042..d45d579d60 100644
--- a/src/Tgstation.Server.Host/Models/User.cs
+++ b/src/Tgstation.Server.Host/Models/User.cs
@@ -9,7 +9,7 @@ using Tgstation.Server.Api.Models.Response;
namespace Tgstation.Server.Host.Models
{
///
- public sealed class User : Api.Models.Internal.UserModelBase, IApiTransformable
+ public sealed class User : Api.Models.Internal.UserModelBase, IApiTransformable, IApiTransformable
{
///
/// Username used when creating jobs automatically.
@@ -26,13 +26,18 @@ namespace Tgstation.Server.Host.Models
///
public User? CreatedBy { get; set; }
+ ///
+ /// The of the 's .
+ ///
+ public long? CreatedById { get; set; }
+
///
/// The the belongs to, if any.
///
public UserGroup? Group { get; set; }
///
- /// The ID of the 's .
+ /// The of the 's .
///
public long? GroupId { get; set; }
@@ -78,6 +83,18 @@ namespace Tgstation.Server.Host.Models
///
public UserResponse ToApi() => CreateUserResponse(true);
+ ///
+ GraphQL.Types.User IApiTransformable.ToApi()
+ => new(
+ Id!.Value,
+ Name!,
+ CanonicalName!,
+ SystemIdentifier,
+ CreatedAt!.Value,
+ CreatedById,
+ GroupId,
+ Enabled!.Value);
+
///
/// Generate a from .
///
diff --git a/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs
new file mode 100644
index 0000000000..f8e64ec448
--- /dev/null
+++ b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs
@@ -0,0 +1,40 @@
+using System;
+using System.Reflection;
+
+using HotChocolate.Authorization;
+
+using Tgstation.Server.Host.Authority.Core;
+
+namespace Tgstation.Server.Host.Security
+{
+ ///
+ /// Inherits the roles of s for GraphQL endpoints.
+ ///
+ /// The being wrapped.
+ [AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
+ public sealed class TgsGraphQLAuthorizeAttribute : AuthorizeAttribute
+ where TAuthority : IAuthority
+ {
+ ///
+ /// The name of the method targeted.
+ ///
+ public string MethodName { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The method name to inherit roles from.
+ public TgsGraphQLAuthorizeAttribute(string methodName)
+ {
+ ArgumentNullException.ThrowIfNull(methodName);
+
+ var authorityType = typeof(TAuthority);
+ var authorityMethod = authorityType.GetMethod(methodName)
+ ?? throw new InvalidOperationException($"Could not find method {methodName} on {authorityType}!");
+ var authorizeAttribute = authorityMethod.GetCustomAttribute()
+ ?? throw new InvalidOperationException($"Could not find method {authorityType}.{methodName}() has no {nameof(TgsAuthorizeAttribute)}!");
+ MethodName = methodName;
+ Roles = authorizeAttribute.Roles?.Split(',');
+ }
+ }
+}
diff --git a/src/Tgstation.Server.Host/Security/TgsRestAuthorizeAttribute{TAuthority}.cs b/src/Tgstation.Server.Host/Security/TgsRestAuthorizeAttribute{TAuthority}.cs
new file mode 100644
index 0000000000..989de21e8e
--- /dev/null
+++ b/src/Tgstation.Server.Host/Security/TgsRestAuthorizeAttribute{TAuthority}.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Reflection;
+
+using Microsoft.AspNetCore.Authorization;
+
+using Tgstation.Server.Host.Authority.Core;
+
+namespace Tgstation.Server.Host.Security
+{
+ ///
+ /// Inherits the roles of s for REST endpoints.
+ ///
+ /// The being wrapped.
+ [AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
+ public sealed class TgsRestAuthorizeAttribute : AuthorizeAttribute
+ where TAuthority : IAuthority
+ {
+ ///
+ /// The name of the method targeted.
+ ///
+ public string MethodName { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The method name to inherit roles from.
+ public TgsRestAuthorizeAttribute(string methodName)
+ {
+ ArgumentNullException.ThrowIfNull(methodName);
+
+ var authorityType = typeof(TAuthority);
+ var authorityMethod = authorityType.GetMethod(methodName);
+ if (authorityMethod == null)
+ throw new InvalidOperationException($"Could not find method {methodName} on {authorityType}!");
+
+ var authorizeAttribute = authorityMethod.GetCustomAttribute();
+ if (authorizeAttribute == null)
+ throw new InvalidOperationException($"Could not find method {authorityType}.{methodName}() has no {nameof(TgsAuthorizeAttribute)}!");
+
+ MethodName = methodName;
+ Roles = authorizeAttribute.Roles;
+ }
+ }
+}
diff --git a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj
index 9d41344040..3192e8faa9 100644
--- a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj
+++ b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj
@@ -180,8 +180,8 @@
-
+