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 @@ - +