Further GraphQL API Implementation

- Introduce the concept of `IAuthority`s to handle business logic for both REST and GraphQL.
- Add new authorize attributes so roles can be defined on `IAuthority`s and inherited on REST/GraphQL API surface.
- Implement `ILoginAuthority`, converting REST methods to use it.
- Partially implement `IUserAuthority`, converting REST methods to use it.
- Setup `ErrorMessageResponse` filtering for GraphQL fields.
This commit is contained in:
Jordan Dominion
2024-09-10 21:52:18 -04:00
parent 6ab17dd3e7
commit 9ac92d1abc
28 changed files with 1384 additions and 240 deletions
@@ -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
{
/// <summary>
/// Base implementation of <see cref="IAuthority"/>.
/// </summary>
abstract class AuthorityBase : IAuthority
{
/// <summary>
/// Gets the <see cref="ILogger"/> for the <see cref="AuthorityBase"/>.
/// </summary>
protected ILogger<AuthorityBase> Logger { get; }
/// <summary>
/// Generates a <see cref="HttpFailureResponse.BadRequest"/> type <see cref="AuthorityResponse{TResult}"/>.
/// </summary>
/// <typeparam name="TResult">The <see cref="Type"/> of the <see cref="AuthorityResponse{TResult}.Result"/>.</typeparam>
/// <param name="errorCode">The <see cref="ErrorCode"/>.</param>
/// <returns>A new, errored <see cref="AuthorityResponse{TResult}"/>.</returns>
protected static AuthorityResponse<TResult> BadRequest<TResult>(ErrorCode errorCode)
=> new(
new ErrorMessageResponse(errorCode),
HttpFailureResponse.BadRequest);
/// <summary>
/// Generates a <see cref="HttpFailureResponse.Unauthorized"/> type <see cref="AuthorityResponse{TResult}"/>.
/// </summary>
/// <typeparam name="TResult">The <see cref="Type"/> of the <see cref="AuthorityResponse{TResult}.Result"/>.</typeparam>
/// <returns>A new, errored <see cref="AuthorityResponse{TResult}"/>.</returns>
protected static AuthorityResponse<TResult> Unauthorized<TResult>()
=> new(
new ErrorMessageResponse(),
HttpFailureResponse.Unauthorized);
/// <summary>
/// Generates a <see cref="HttpFailureResponse.Forbidden"/> type <see cref="AuthorityResponse{TResult}"/>.
/// </summary>
/// <typeparam name="TResult">The <see cref="Type"/> of the <see cref="AuthorityResponse{TResult}.Result"/>.</typeparam>
/// <returns>A new, errored <see cref="AuthorityResponse{TResult}"/>.</returns>
protected static AuthorityResponse<TResult> Forbid<TResult>()
=> new(
new ErrorMessageResponse(),
HttpFailureResponse.Forbidden);
/// <summary>
/// Generates a <see cref="HttpFailureResponse.NotFound"/> type <see cref="AuthorityResponse{TResult}"/>.
/// </summary>
/// <typeparam name="TResult">The <see cref="Type"/> of the <see cref="AuthorityResponse{TResult}.Result"/>.</typeparam>
/// <returns>A new, errored <see cref="AuthorityResponse{TResult}"/>.</returns>
protected static AuthorityResponse<TResult> NotFound<TResult>()
=> new(
new ErrorMessageResponse(),
HttpFailureResponse.NotFound);
/// <summary>
/// Initializes a new instance of the <see cref="AuthorityBase"/> class.
/// </summary>
/// <param name="logger">The value of <see cref="Logger"/>.</param>
protected AuthorityBase(ILogger<AuthorityBase> logger)
{
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Generates a <see cref="HttpFailureResponse.RateLimited"/> type <see cref="AuthorityResponse"/>.
/// </summary>
/// <typeparam name="TResult">The <see cref="Type"/> of the <see cref="AuthorityResponse{TResult}.Result"/>.</typeparam>
/// <param name="rateLimitException">The thrown <see cref="RateLimitExceededException"/>.</param>
/// <returns>A new, errored <see cref="AuthorityResponse{TResult}"/>.</returns>
protected AuthorityResponse<TResult> RateLimit<TResult>(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);
}
}
}
@@ -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
{
/// <summary>
/// Invokes <typeparamref name="TAuthority"/>s.
/// </summary>
/// <typeparam name="TAuthority">The <see cref="IAuthority"/> invoked.</typeparam>
sealed class AuthorityInvoker<TAuthority> : IRestAuthorityInvoker<TAuthority>, IGraphQLAuthorityInvoker<TAuthority>
where TAuthority : IAuthority
{
/// <summary>
/// The <see cref="IAuthority"/> being invoked.
/// </summary>
readonly TAuthority authority;
/// <summary>
/// Throws a <see cref="QueryException"/> for errored <paramref name="authorityResponse"/>s.
/// </summary>
/// <param name="authorityResponse">The potentially errored <paramref name="authorityResponse"/>.</param>
static void ThrowGraphQLErrorIfNecessary(AuthorityResponse authorityResponse)
{
if (authorityResponse.Success)
return;
var fallbackString = authorityResponse.FailureResponse.ToString()!;
throw new ErrorMessageException(authorityResponse.ErrorMessage, fallbackString);
}
/// <summary>
/// Initializes a new instance of the <see cref="AuthorityInvoker{TAuthority}"/> class.
/// </summary>
/// <param name="authority">The value of <see cref="authority"/>.</param>
public AuthorityInvoker(TAuthority authority)
{
this.authority = authority ?? throw new ArgumentNullException(nameof(authority));
}
/// <inheritdoc />
public async ValueTask<IActionResult> Invoke(ApiController controller, Func<TAuthority, ValueTask<AuthorityResponse>> authorityInvoker)
{
var authorityResponse = await authorityInvoker(authority);
return CreateErroredActionResult(controller, authorityResponse) ?? controller.NoContent();
}
/// <inheritdoc />
public async ValueTask<IActionResult> InvokeTransformable<TResult, TApiModel>(ApiController controller, Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : notnull, IApiTransformable<TApiModel>
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);
}
/// <inheritdoc />
async ValueTask<IActionResult> IRestAuthorityInvoker<TAuthority>.Invoke<TResult, TApiModel>(ApiController controller, Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> 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);
}
/// <inheritdoc />
async ValueTask IGraphQLAuthorityInvoker<TAuthority>.Invoke(Func<TAuthority, ValueTask<AuthorityResponse>> authorityInvoker)
{
var authorityResponse = await authorityInvoker(authority);
ThrowGraphQLErrorIfNecessary(authorityResponse);
}
/// <inheritdoc />
public async ValueTask<TApiModel> Invoke<TResult, TApiModel>(Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : TApiModel
where TApiModel : notnull
{
var authorityResponse = await authorityInvoker(authority);
ThrowGraphQLErrorIfNecessary(authorityResponse);
return authorityResponse.Result!;
}
/// <inheritdoc />
async ValueTask<TApiModel> IGraphQLAuthorityInvoker<TAuthority>.InvokeTransformable<TResult, TApiModel>(Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
{
var authorityResponse = await authorityInvoker(authority);
ThrowGraphQLErrorIfNecessary(authorityResponse);
return authorityResponse.Result!.ToApi();
}
/// <summary>
/// Create an <see cref="IActionResult"/> for a given <paramref name="authorityResponse"/> if it is erroring.
/// </summary>
/// <param name="controller">The <see cref="ApiController"/> to use.</param>
/// <param name="authorityResponse">The <see cref="AuthorityResponse"/>.</param>
/// <returns>An <see cref="IActionResult"/> if the <paramref name="authorityResponse"/> is not successful, <see langword="null"/> otherwise.</returns>
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}"),
};
}
/// <summary>
/// Create an <see cref="IActionResult"/> for a given successfuly <paramref name="authorityResponse"/> API <paramref name="result"/>.
/// </summary>
/// <param name="controller">The <see cref="ApiController"/> to use.</param>
/// <param name="result">The resulting <typeparamref name="TApiModel"/> from the <paramref name="authorityResponse"/>.</param>
/// <param name="authorityResponse">The <see cref="AuthorityResponse{TResult}"/>.</param>
/// <returns>An <see cref="IActionResult"/> for the <paramref name="authorityResponse"/>.</returns>
/// <typeparam name="TResult">The result <see cref="Type"/> returned in the <paramref name="authorityResponse"/>.</typeparam>
/// <typeparam name="TApiModel">The REST API result model built from <paramref name="authorityResponse"/>.</typeparam>
IActionResult CreateSuccessfulActionResult<TResult, TApiModel>(ApiController controller, TApiModel result, AuthorityResponse<TResult> 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}"),
};
}
}
}
@@ -0,0 +1,48 @@
using System;
using System.Diagnostics.CodeAnalysis;
using Tgstation.Server.Api.Models.Response;
namespace Tgstation.Server.Host.Authority.Core
{
/// <summary>
/// Represents a response from an authority.
/// </summary>
public class AuthorityResponse
{
/// <summary>
/// Checks if the <see cref="AuthorityResponse"/> was successful.
/// </summary>
[MemberNotNullWhen(false, nameof(ErrorMessage))]
[MemberNotNullWhen(false, nameof(FailureResponse))]
public virtual bool Success => ErrorMessage == null;
/// <summary>
/// Gets the associated <see cref="ErrorMessageResponse"/>. Must only be used if <see cref="Success"/> is <see langword="false"/>.
/// </summary>
public ErrorMessageResponse? ErrorMessage { get; }
/// <summary>
/// The <see cref="HttpFailureResponse"/>.
/// </summary>
public HttpFailureResponse? FailureResponse { get; }
/// <summary>
/// Initializes a new instance of the <see cref="AuthorityResponse"/> class.
/// </summary>
public AuthorityResponse()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AuthorityResponse"/> class.
/// </summary>
/// <param name="errorMessage">The value of <see cref="ErrorMessage"/>.</param>
/// <param name="failureResponse">The value of <see cref="FailureResponse"/>.</param>
public AuthorityResponse(ErrorMessageResponse errorMessage, HttpFailureResponse failureResponse)
{
ErrorMessage = errorMessage ?? throw new ArgumentNullException(nameof(errorMessage));
FailureResponse = failureResponse;
}
}
}
@@ -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
{
/// <summary>
/// An <see cref="AuthorityResponse"/> with a <see cref="Result"/>.
/// </summary>
/// <typeparam name="TResult">The <see cref="Type"/> of success response.</typeparam>
public sealed class AuthorityResponse<TResult> : AuthorityResponse
{
/// <inheritdoc />
[MemberNotNullWhen(true, nameof(Result))]
[MemberNotNullWhen(true, nameof(SuccessResponse))]
public override bool Success => base.Success;
/// <summary>
/// The success <typeparamref name="TResult"/>.
/// </summary>
public TResult? Result { get; }
/// <summary>
/// The <see cref="HttpSuccessResponse"/> for generating the <see cref="IActionResult"/>s.
/// </summary>
public HttpSuccessResponse? SuccessResponse { get; }
/// <summary>
/// Initializes a new instance of the <see cref="AuthorityResponse{TResult}"/> class.
/// </summary>
/// <param name="errorMessage">The <see cref="ErrorMessageResponse"/>.</param>
/// <param name="httpResponse">The <see cref="HttpFailureResponse"/>.</param>
public AuthorityResponse(ErrorMessageResponse errorMessage, HttpFailureResponse httpResponse)
: base(errorMessage, httpResponse)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AuthorityResponse{TResult}"/> class.
/// </summary>
/// <param name="result">The value of <see cref="Result"/>.</param>
/// <param name="httpResponse">The value of <see cref="SuccessResponse"/>.</param>
public AuthorityResponse(TResult result, HttpSuccessResponse httpResponse = HttpSuccessResponse.Ok)
{
Result = result ?? throw new ArgumentNullException(nameof(result));
SuccessResponse = httpResponse;
}
}
}
@@ -0,0 +1,63 @@
namespace Tgstation.Server.Host.Authority.Core
{
/// <summary>
/// Indicates the type of HTTP status code an failing <see cref="AuthorityResponse"/> should generate.
/// </summary>
public enum HttpFailureResponse
{
/// <summary>
/// HTTP 400.
/// </summary>
BadRequest,
/// <summary>
/// HTTP 401.
/// </summary>
Unauthorized,
/// <summary>
/// HTTP 403.
/// </summary>
Forbidden,
/// <summary>
/// HTTP 404.
/// </summary>
NotFound,
/// <summary>
/// HTTP 406.
/// </summary>
NotAcceptable,
/// <summary>
/// HTTP 409.
/// </summary>
Conflict,
/// <summary>
/// HTTP 410.
/// </summary>
Gone,
/// <summary>
/// HTTP 422.
/// </summary>
UnprocessableEntity,
/// <summary>
/// HTTP 424.
/// </summary>
FailedDependency,
/// <summary>
/// HTTP 429.
/// </summary>
RateLimited,
/// <summary>
/// HTTP 501.
/// </summary>
NotImplemented,
}
}
@@ -0,0 +1,23 @@
namespace Tgstation.Server.Host.Authority.Core
{
/// <summary>
/// Indicates the type of HTTP status code a successful <see cref="AuthorityResponse{TResult}"/> should generate.
/// </summary>
public enum HttpSuccessResponse
{
/// <summary>
/// HTTP 200.
/// </summary>
Ok,
/// <summary>
/// HTTP 201.
/// </summary>
Created,
/// <summary>
/// HTTP 202.
/// </summary>
Accepted,
}
}
@@ -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
{
/// <summary>
/// Business logic for interating with the server.
/// </summary>
public interface IAuthority
{
}
}
@@ -0,0 +1,44 @@
using System;
using System.Threading.Tasks;
using Tgstation.Server.Host.Models;
namespace Tgstation.Server.Host.Authority.Core
{
/// <summary>
/// Invokes <typeparamref name="TAuthority"/>s from GraphQL endpoints.
/// </summary>
/// <typeparam name="TAuthority">The <see cref="IAuthority"/> invoked.</typeparam>
public interface IGraphQLAuthorityInvoker<TAuthority>
where TAuthority : IAuthority
{
/// <summary>
/// Invoke a <typeparamref name="TAuthority"/> method with no success result.
/// </summary>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse"/>.</param>
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
ValueTask Invoke(Func<TAuthority, ValueTask<AuthorityResponse>> authorityInvoker);
/// <summary>
/// Invoke a <typeparamref name="TAuthority"/> method and get the result.
/// </summary>
/// <typeparam name="TResult">The <see cref="AuthorityResponse{TResult}.Result"/> <see cref="Type"/>.</typeparam>
/// <typeparam name="TApiModel">The resulting <see cref="Type"/> of the return value.</typeparam>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse{TResult}"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <typeparamref name="TApiModel"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<TApiModel> Invoke<TResult, TApiModel>(Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : TApiModel
where TApiModel : notnull;
/// <summary>
/// Invoke a <typeparamref name="TAuthority"/> method and get the result.
/// </summary>
/// <typeparam name="TResult">The <see cref="AuthorityResponse{TResult}.Result"/> <see cref="Type"/>.</typeparam>
/// <typeparam name="TApiModel">The resulting <see cref="Type"/> of the return value.</typeparam>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse{TResult}"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <typeparamref name="TApiModel"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<TApiModel> InvokeTransformable<TResult, TApiModel>(Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : notnull, IApiTransformable<TApiModel>
where TApiModel : notnull;
}
}
@@ -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
{
/// <summary>
/// Invokes <typeparamref name="TAuthority"/> methods and generates <see cref="IActionResult"/> responses.
/// </summary>
/// <typeparam name="TAuthority">The type of <see cref="IAuthority"/>.</typeparam>
public interface IRestAuthorityInvoker<TAuthority>
where TAuthority : IAuthority
{
/// <summary>
/// Invoke a <typeparamref name="TAuthority"/> method with no success result.
/// </summary>
/// <param name="controller">The <see cref="ApiController"/> invoking the <typeparamref name="TAuthority"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> generated for the resulting <see cref="AuthorityResponse"/>.</returns>
ValueTask<IActionResult> Invoke(ApiController controller, Func<TAuthority, ValueTask<AuthorityResponse>> authorityInvoker);
/// <summary>
/// Invoke a <typeparamref name="TAuthority"/> method and get the result.
/// </summary>
/// <typeparam name="TResult">The <see cref="AuthorityResponse{TResult}.Result"/> <see cref="Type"/>.</typeparam>
/// <typeparam name="TApiModel">The resulting <see cref="Type"/> of the <see cref="IActionResult"/>.</typeparam>
/// <param name="controller">The <see cref="ApiController"/> invoking the <typeparamref name="TAuthority"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse{TResult}"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<IActionResult> Invoke<TResult, TApiModel>(ApiController controller, Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : TApiModel
where TApiModel : notnull;
/// <summary>
/// Invoke a <typeparamref name="TAuthority"/> method and get the result.
/// </summary>
/// <typeparam name="TResult">The <see cref="AuthorityResponse{TResult}.Result"/> <see cref="Type"/>.</typeparam>
/// <typeparam name="TApiModel">The returned REST <see cref="Type"/>.</typeparam>
/// <param name="controller">The <see cref="ApiController"/> invoking the <typeparamref name="TAuthority"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse{TResult}"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<IActionResult> InvokeTransformable<TResult, TApiModel>(ApiController controller, Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : notnull, IApiTransformable<TApiModel>
where TApiModel : notnull;
}
}
@@ -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
{
/// <summary>
/// <see cref="IAuthority"/> for authenticating with the server.
/// </summary>
public interface ILoginAuthority : IAuthority
{
/// <summary>
/// Attempt to login to the server with the current crentials.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="TokenResponse"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<AuthorityResponse<TokenResponse>> AttemptLogin(CancellationToken cancellationToken);
}
}
@@ -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
{
/// <summary>
/// <see cref="IAuthority"/> for managing <see cref="User"/>s.
/// </summary>
public interface IUserAuthority : IAuthority
{
/// <summary>
/// Gets the currently authenticated user.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize]
public ValueTask<AuthorityResponse<User>> Read(CancellationToken cancellationToken);
/// <summary>
/// Gets the <see cref="User"/> with a given <paramref name="id"/>.
/// </summary>
/// <param name="id">The <see cref="EntityId.Id"/> of the <see cref="User"/>.</param>
/// <param name="includeJoins">If relevant entities should be loaded.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize(AdministrationRights.ReadUsers)]
public ValueTask<AuthorityResponse<User>> GetId(long id, bool includeJoins, CancellationToken cancellationToken);
/// <summary>
/// Gets all registered <see cref="User"/>s.
/// </summary>
/// <param name="includeJoins">If relevant entities should be loaded.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="IQueryable{T}"/> <see cref="User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize(AdministrationRights.ReadUsers)]
public ValueTask<AuthorityResponse<IQueryable<User>>> List(bool includeJoins, CancellationToken cancellationToken);
}
}
@@ -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
{
/// <inheritdoc />
sealed class LoginAuthority : AuthorityBase, ILoginAuthority
{
/// <summary>
/// The <see cref="IApiHeadersProvider"/> for the <see cref="LoginAuthority"/>.
/// </summary>
readonly IApiHeadersProvider apiHeadersProvider;
/// <summary>
/// The <see cref="ISystemIdentityFactory"/> for the <see cref="LoginAuthority"/>.
/// </summary>
readonly ISystemIdentityFactory systemIdentityFactory;
/// <summary>
/// The <see cref="IDatabaseContext"/> for the <see cref="LoginAuthority"/>.
/// </summary>
readonly IDatabaseContext databaseContext;
/// <summary>
/// The <see cref="IOAuthProviders"/> for the <see cref="LoginAuthority"/>.
/// </summary>
readonly IOAuthProviders oAuthProviders;
/// <summary>
/// The <see cref="ITokenFactory"/> for the <see cref="LoginAuthority"/>.
/// </summary>
readonly ITokenFactory tokenFactory;
/// <summary>
/// The <see cref="ICryptographySuite"/> for the <see cref="LoginAuthority"/>.
/// </summary>
readonly ICryptographySuite cryptographySuite;
/// <summary>
/// The <see cref="IIdentityCache"/> for the <see cref="LoginAuthority"/>.
/// </summary>
readonly IIdentityCache identityCache;
/// <summary>
/// Generate an <see cref="AuthorityResponse{TResult}"/> for a given <paramref name="headersException"/>.
/// </summary>
/// <param name="headersException">The <see cref="HeadersException"/> to generate a response for.</param>
/// <returns>A new, errored <see cref="TokenResponse"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
static AuthorityResponse<TokenResponse> GenerateHeadersExceptionResponse(HeadersException headersException)
=> new(
new ErrorMessageResponse(ErrorCode.BadHeaders)
{
AdditionalData = headersException.Message,
},
headersException.ParseErrors.HasFlag(HeaderErrorTypes.Accept)
? HttpFailureResponse.NotAcceptable
: HttpFailureResponse.BadRequest);
/// <summary>
/// Select the details needed to generate a <see cref="TokenResponse"/> from a given <paramref name="query"/>.
/// </summary>
/// <param name="query">The <see cref="IQueryable{T}"/> of <see cref="User"/>s.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="User"/> returned after selecting, if any.</returns>
static async ValueTask<User?> SelectUserInfoFromQuery(IQueryable<User> 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;
}
/// <summary>
/// Initializes a new instance of the <see cref="LoginAuthority"/> class.
/// </summary>
/// <param name="logger">The <see cref="ILogger"/> to use.</param>
/// <param name="apiHeadersProvider">The value of <see cref="apiHeadersProvider"/>.</param>
/// <param name="systemIdentityFactory">The value of <see cref="systemIdentityFactory"/>.</param>
/// <param name="databaseContext">The value of <see cref="databaseContext"/>.</param>
/// <param name="oAuthProviders">The value of <see cref="oAuthProviders"/>.</param>
/// <param name="tokenFactory">The value of <see cref="tokenFactory"/>.</param>
/// <param name="cryptographySuite">The value of <see cref="cryptographySuite"/>.</param>
/// <param name="identityCache">The value of <see cref="identityCache"/>.</param>
public LoginAuthority(
ILogger<LoginAuthority> 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));
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse<TokenResponse>> AttemptLogin(CancellationToken cancellationToken)
{
var headers = apiHeadersProvider.ApiHeaders;
if (headers == null)
return GenerateHeadersExceptionResponse(apiHeadersProvider.HeadersException!);
if (headers.IsTokenAuthentication)
return BadRequest<TokenResponse>(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<User> query = databaseContext.Users.AsQueryable();
if (oAuthLogin)
{
var oAuthProvider = headers.OAuthProvider!.Value;
string? externalUserId;
try
{
var validator = oAuthProviders
.GetValidator(oAuthProvider);
if (validator == null)
return BadRequest<TokenResponse>(ErrorCode.OAuthProviderDisabled);
externalUserId = await validator
.ValidateResponseCode(headers.OAuthCode!, cancellationToken);
Logger.LogTrace("External {oAuthProvider} UID: {externalUserId}", oAuthProvider, externalUserId);
}
catch (Octokit.RateLimitExceededException ex)
{
return RateLimit<TokenResponse>(ex);
}
if (externalUserId == null)
return Unauthorized<TokenResponse>();
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<TokenResponse>();
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<TokenResponse>();
// 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<TokenResponse>();
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<TokenResponse>();
}
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<TokenResponse>(token);
}
}
/// <summary>
/// Add a given <paramref name="systemIdentity"/> to the <see cref="identityCache"/>.
/// </summary>
/// <param name="systemIdentity">The <see cref="ISystemIdentity"/> to cache.</param>
/// <param name="user">The <see cref="User"/> the <paramref name="systemIdentity"/> was generated for.</param>
/// <param name="token">The <see cref="TokenResponse"/> for the <paramref name="user"/>.</param>
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
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);
}
}
}
@@ -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
{
/// <inheritdoc />
sealed class UserAuthority : AuthorityBase, IUserAuthority
{
/// <summary>
/// The <see cref="IDatabaseContext"/> for the <see cref="UserAuthority"/>.
/// </summary>
readonly IDatabaseContext databaseContext;
/// <summary>
/// The <see cref="IAuthenticationContext"/> for the <see cref="UserAuthority"/>.
/// </summary>
readonly IAuthenticationContext authenticationContext;
/// <summary>
/// Initializes a new instance of the <see cref="UserAuthority"/> class.
/// </summary>
/// <param name="logger">The <see cref="ILogger"/> to use.</param>
/// <param name="databaseContext">The value of <see cref="databaseContext"/>.</param>
/// <param name="authenticationContext">The value of <see cref="authenticationContext"/>.</param>
public UserAuthority(
ILogger<UserAuthority> logger,
IDatabaseContext databaseContext,
IAuthenticationContext authenticationContext)
: base(logger)
{
this.databaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext));
this.authenticationContext = authenticationContext ?? throw new ArgumentNullException(nameof(authenticationContext));
}
/// <inheritdoc />
public ValueTask<AuthorityResponse<User>> Read(CancellationToken cancellationToken)
=> ValueTask.FromResult(new AuthorityResponse<User>(authenticationContext.User));
/// <inheritdoc />
public async ValueTask<AuthorityResponse<User>> 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<User>();
if (user.CanonicalName == User.CanonicalizeName(User.TgsSystemUserName))
return Forbid<User>();
return new AuthorityResponse<User>(user);
}
/// <inheritdoc />
public ValueTask<AuthorityResponse<IQueryable<User>>> List(bool includeJoins, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
}
}
@@ -104,6 +104,19 @@ namespace Tgstation.Server.Host.Controllers
this.requireHeaders = requireHeaders;
}
/// <summary>
/// Generic 201 response with a given <paramref name="payload"/>.
/// </summary>
/// <param name="payload">The accompanying API payload.</param>
/// <returns>A <see cref="HttpStatusCode.Created"/> <see cref="ObjectResult"/> with the given <paramref name="payload"/>.</returns>
public ObjectResult Created(object payload) => StatusCode((int)HttpStatusCode.Created, payload);
/// <summary>
/// Generic 401 response.
/// </summary>
/// <returns>An <see cref="ObjectResult"/> with <see cref="HttpStatusCode.NotFound"/>.</returns>
public new ObjectResult Unauthorized() => this.StatusCode(HttpStatusCode.Unauthorized, null);
/// <inheritdoc />
#pragma warning disable CA1506 // TODO: Decomplexify
protected override async ValueTask<IActionResult?> HookExecuteAction(Func<Task> executeAction, CancellationToken cancellationToken)
@@ -186,12 +199,6 @@ namespace Tgstation.Server.Host.Controllers
/// <returns>A <see cref="NotFoundObjectResult"/> with an appropriate <see cref="ErrorMessageResponse"/>.</returns>
protected new NotFoundObjectResult NotFound() => NotFound(new ErrorMessageResponse(ErrorCode.ResourceNeverPresent));
/// <summary>
/// Generic 401 response.
/// </summary>
/// <returns>An <see cref="ObjectResult"/> with <see cref="HttpStatusCode.NotFound"/>.</returns>
protected new ObjectResult Unauthorized() => this.StatusCode(HttpStatusCode.Unauthorized, null);
/// <summary>
/// Generic 501 response.
/// </summary>
@@ -210,13 +217,6 @@ namespace Tgstation.Server.Host.Controllers
/// <returns>A <see cref="StatusCodeResult"/> with the given <paramref name="statusCode"/>.</returns>
protected StatusCodeResult StatusCode(HttpStatusCode statusCode) => StatusCode((int)statusCode);
/// <summary>
/// Generic 201 response with a given <paramref name="payload"/>.
/// </summary>
/// <param name="payload">The accompanying API payload.</param>
/// <returns>A <see cref="HttpStatusCode.Created"/> <see cref="ObjectResult"/> with the given <paramref name="payload"/>.</returns>
protected ObjectResult Created(object payload) => StatusCode((int)HttpStatusCode.Created, payload);
/// <summary>
/// 429 response for a given <paramref name="rateLimitException"/>.
/// </summary>
@@ -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
{
/// <summary>
/// The <see cref="ITokenFactory"/> for the <see cref="ApiRootController"/>.
/// </summary>
readonly ITokenFactory tokenFactory;
/// <summary>
/// The <see cref="ISystemIdentityFactory"/> for the <see cref="ApiRootController"/>.
/// </summary>
readonly ISystemIdentityFactory systemIdentityFactory;
/// <summary>
/// The <see cref="ICryptographySuite"/> for the <see cref="ApiRootController"/>.
/// </summary>
readonly ICryptographySuite cryptographySuite;
/// <summary>
/// The <see cref="IAssemblyInformationProvider"/> for the <see cref="ApiRootController"/>.
/// </summary>
readonly IAssemblyInformationProvider assemblyInformationProvider;
/// <summary>
/// The <see cref="IIdentityCache"/> for the <see cref="ApiRootController"/>.
/// </summary>
readonly IIdentityCache identityCache;
/// <summary>
/// The <see cref="IOAuthProviders"/> for the <see cref="ApiRootController"/>.
/// </summary>
@@ -80,6 +60,11 @@ namespace Tgstation.Server.Host.Controllers
/// </summary>
readonly IServerControl serverControl;
/// <summary>
/// The <see cref="IRestAuthorityInvoker{TAuthority}"/> for the <see cref="ILoginAuthority"/>.
/// </summary>
readonly IRestAuthorityInvoker<ILoginAuthority> loginAuthority;
/// <summary>
/// The <see cref="GeneralConfiguration"/> for the <see cref="ApiRootController"/>.
/// </summary>
@@ -90,11 +75,7 @@ namespace Tgstation.Server.Host.Controllers
/// </summary>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> for the <see cref="ApiController"/>.</param>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> for the <see cref="ApiController"/>.</param>
/// <param name="tokenFactory">The value of <see cref="tokenFactory"/>.</param>
/// <param name="systemIdentityFactory">The value of <see cref="systemIdentityFactory"/>.</param>
/// <param name="cryptographySuite">The value of <see cref="cryptographySuite"/>.</param>
/// <param name="assemblyInformationProvider">The value of <see cref="assemblyInformationProvider"/>.</param>
/// <param name="identityCache">The value of <see cref="identityCache"/>.</param>
/// <param name="oAuthProviders">The value of <see cref="oAuthProviders"/>.</param>
/// <param name="platformIdentifier">The value of <see cref="platformIdentifier"/>.</param>
/// <param name="swarmService">The value of <see cref="swarmService"/>.</param>
@@ -102,21 +83,19 @@ namespace Tgstation.Server.Host.Controllers
/// <param name="generalConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="generalConfiguration"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/>.</param>
/// <param name="apiHeadersProvider">The <see cref="IApiHeadersProvider"/> for the <see cref="ApiController"/>.</param>
/// <param name="loginAuthority">The value of <see cref="loginAuthority"/>.</param>
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<GeneralConfiguration> generalConfigurationOptions,
ILogger<ApiRootController> logger,
IApiHeadersProvider apiHeadersProvider)
IApiHeadersProvider apiHeadersProvider,
IRestAuthorityInvoker<ILoginAuthority> 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));
}
/// <summary>
@@ -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<IActionResult> CreateToken(CancellationToken cancellationToken)
public ValueTask<IActionResult> 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<Models.User> 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<TokenResponse, TokenResponse>(this, authority => authority.AttemptLogin(cancellationToken));
}
#pragma warning restore CA1506
}
}
@@ -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
/// </summary>
readonly IPermissionsUpdateNotifyee permissionsUpdateNotifyee;
/// <summary>
/// The <see cref="IRestAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.
/// </summary>
readonly IRestAuthorityInvoker<IUserAuthority> userAuthority;
/// <summary>
/// The <see cref="GeneralConfiguration"/> for the <see cref="UserController"/>.
/// </summary>
@@ -57,6 +64,7 @@ namespace Tgstation.Server.Host.Controllers
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> for the <see cref="ApiController"/>.</param>
/// <param name="systemIdentityFactory">The value of <see cref="systemIdentityFactory"/>.</param>
/// <param name="cryptographySuite">The value of <see cref="cryptographySuite"/>.</param>
/// <param name="userAuthority">The value of <see cref="userAuthority"/>.</param>
/// <param name="permissionsUpdateNotifyee">The value of <see cref="permissionsUpdateNotifyee"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/>.</param>
/// <param name="generalConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="generalConfiguration"/>.</param>
@@ -67,6 +75,7 @@ namespace Tgstation.Server.Host.Controllers
ISystemIdentityFactory systemIdentityFactory,
ICryptographySuite cryptographySuite,
IPermissionsUpdateNotifyee permissionsUpdateNotifyee,
IRestAuthorityInvoker<IUserAuthority> userAuthority,
ILogger<UserController> logger,
IOptions<GeneralConfiguration> 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
/// <summary>
/// Get information about the current <see cref="User"/>.
/// </summary>
/// <returns>The <see cref="IActionResult"/> of the operation.</returns>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation.</returns>
/// <response code="200">The <see cref="User"/> was retrieved successfully.</response>
[HttpGet]
[TgsAuthorize]
[TgsRestAuthorize<IUserAuthority>(nameof(IUserAuthority.Read))]
[ProducesResponseType(typeof(UserResponse), 200)]
public IActionResult Read() => Json(AuthenticationContext.User.ToApi());
public ValueTask<IActionResult> Read(CancellationToken cancellationToken)
=> userAuthority.InvokeTransformable<User, UserResponse>(this, authority => authority.Read(cancellationToken));
/// <summary>
/// List all <see cref="User"/>s in the server.
@@ -395,27 +407,14 @@ namespace Tgstation.Server.Host.Controllers
public async ValueTask<IActionResult> 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<User, UserResponse>(
this,
authority => authority.GetId(id, true, cancellationToken));
}
/// <summary>
+19 -2
View File
@@ -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<ErrorMessageFilter>()
.AddType<UnsignedIntType>()
.BindRuntimeType<Version, SemverType>()
.AddQueryType<Query>();
.AddQueryType<Query>()
.AddMutationType<Mutation>();
void AddTypedContext<TContext>()
where TContext : DatabaseContext
@@ -431,6 +437,12 @@ namespace Tgstation.Server.Host.Core
services.AddSingleton<IServerUpdateInitiator, ServerUpdateInitiator>();
services.AddSingleton<IDotnetDumpService, DotnetDumpService>();
// configure authorities
services.AddScoped(typeof(IRestAuthorityInvoker<>), typeof(AuthorityInvoker<>));
services.AddScoped(typeof(IGraphQLAuthorityInvoker<>), typeof(AuthorityInvoker<>));
services.AddScoped<ILoginAuthority, LoginAuthority>();
services.AddScoped<IUserAuthority, UserAuthority>();
// configure misc services
services.AddSingleton<IProcessExecutor, ProcessExecutor>();
services.AddSingleton<ISynchronousIOManager, SynchronousIOManager>();
@@ -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,
});
}
});
@@ -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
{
/// <summary>
/// <see cref="Exception"/> representing <see cref="ErrorMessageResponse"/>s.
/// </summary>
public sealed class ErrorMessageException : Exception
{
/// <summary>
/// The <see cref="ErrorMessageResponse.ErrorCode"/>.
/// </summary>
public ErrorCode? ErrorCode { get; }
/// <summary>
/// The <see cref="ErrorMessageResponse.AdditionalData"/>.
/// </summary>
public string? AdditionalData { get; }
/// <summary>
/// Initializes a new instance of the <see cref="ErrorMessageException"/> class.
/// </summary>
/// <param name="errorMessage">The <see cref="ErrorMessageResponse"/>.</param>
/// <param name="fallbackMessage">Fallback <see cref="Exception.Message"/>.</param>
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;
}
}
}
@@ -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
{
/// <summary>
/// <see cref="IErrorFilter"/> for transforming <see cref="ErrorMessageResponse"/>-like <see cref="Exception"/>.
/// </summary>
sealed class ErrorMessageFilter : IErrorFilter
{
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="ErrorMessageFilter"/>.
/// </summary>
readonly ILogger<ErrorMessageFilter> logger;
/// <summary>
/// Initializes a new instance of the <see cref="ErrorMessageFilter"/> class.
/// </summary>
/// <param name="logger">The value of <see cref="logger"/>.</param>
public ErrorMessageFilter(ILogger<ErrorMessageFilter> logger)
{
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
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();
}
}
}
+31 -3
View File
@@ -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
{
/// <summary>
/// Root type for GraphQL mutations.
/// </summary>
/// <remarks>Intentionally left mostly empty, use type extensions to properly scope operations to domains.</remarks>
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
/// <summary>
/// Generate JWT for authenticating with server.
/// </summary>
/// <param name="loginAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> <see cref="ILoginAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A Bearer token to be used with further communication with the server.</returns>
[Error(typeof(ErrorMessageException))]
public async ValueTask<string> Login(
[Service] IGraphQLAuthorityInvoker<ILoginAuthority> loginAuthority,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(loginAuthority);
var tokenResponse = await loginAuthority.Invoke<TokenResponse, TokenResponse>(
authority => authority.AttemptLogin(cancellationToken));
return tokenResponse.Bearer!;
}
}
}
@@ -1,4 +1,6 @@
namespace Tgstation.Server.Host.GraphQL.Types
using HotChocolate.Types.Relay;
namespace Tgstation.Server.Host.GraphQL.Types
{
/// <summary>
/// Represents a database entity.
@@ -8,6 +10,7 @@
/// <summary>
/// The ID of the <see cref="Entity"/>.
/// </summary>
[ID]
public long Id { get; }
/// <summary>
@@ -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
/// <returns>A new <see cref="Types.LocalServer"/>.</returns>
public LocalServer LocalServer() => new();
/// <summary>
/// Gets the swarm's <see cref="Types.Users"/>.
/// </summary>
/// <returns>A new <see cref="Types.Users"/>.</returns>
public Users Users() => new();
/// <summary>
/// Gets the <see cref="SwarmServerInformation"/> for all servers in a swarm.
/// </summary>
@@ -14,6 +14,11 @@ namespace Tgstation.Server.Host.GraphQL.Types
/// </summary>
public bool Enabled { get; }
/// <summary>
/// The user's canonical (Uppercase) name.
/// </summary>
public string CanonicalName { get; }
/// <summary>
/// When the <see cref="User"/> was created.
/// </summary>
@@ -27,30 +32,41 @@ namespace Tgstation.Server.Host.GraphQL.Types
/// <summary>
/// The <see cref="Entity.Id"/> of the <see cref="CreatedBy"/> <see cref="User"/>.
/// </summary>
readonly long createdById;
readonly long? createdById;
/// <summary>
/// The <see cref="Entity.Id"/> of the <see cref="Group"/>.
/// </summary>
readonly long? groupId;
/// <summary>
/// Initializes a new instance of the <see cref="User"/> class.
/// </summary>
/// <param name="id">The <see cref="Entity.Id"/>.</param>
/// <param name="name">The <see cref="NamedEntity.Name"/>.</param>
/// <param name="canonicalName">The value of <see cref="CanonicalName"/>.</param>
/// <param name="systemIdentifier">The value of <see cref="SystemIdentifier"/>.</param>
/// <param name="createdById">The value of <see cref="createdById"/>.</param>
/// <param name="createdAt">The value of <see cref="CreatedAt"/>.</param>
/// <param name="createdById">The value of <see cref="createdById"/>.</param>
/// <param name="groupId">The value of <see cref="groupId"/>.</param>
/// <param name="enabled">The value of <see cref="Enabled"/>.</param>
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;
}
/// <summary>
@@ -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
{
/// <summary>
/// Wrapper for accessing <see cref="User"/>s.
/// </summary>
public sealed class Users
{
/// <summary>
/// Gets the current <see cref="User"/>.
/// </summary>
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the current <see cref="User"/>.</returns>
[TgsGraphQLAuthorize<IUserAuthority>(nameof(IUserAuthority.Read))]
public ValueTask<User> Current(
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(userAuthority);
return userAuthority.InvokeTransformable<Models.User, User>(authority => authority.Read(cancellationToken));
}
/// <summary>
/// Gets a user by <see cref="Entity.Id"/>.
/// </summary>
/// <param name="id">The <see cref="Entity.Id"/> of the <see cref="User"/>.</param>
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="User"/>.</returns>
[Error(typeof(ErrorMessageException))]
[TgsGraphQLAuthorize<IUserAuthority>(nameof(IUserAuthority.GetId))]
public async ValueTask<User?> ById(
[ID(nameof(User))] long id,
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(userAuthority);
return await userAuthority.InvokeTransformable<Models.User, User>(authority => authority.GetId(id, false, cancellationToken));
}
}
}
+19 -2
View File
@@ -9,7 +9,7 @@ using Tgstation.Server.Api.Models.Response;
namespace Tgstation.Server.Host.Models
{
/// <inheritdoc cref="Api.Models.Internal.UserModelBase" />
public sealed class User : Api.Models.Internal.UserModelBase, IApiTransformable<UserResponse>
public sealed class User : Api.Models.Internal.UserModelBase, IApiTransformable<UserResponse>, IApiTransformable<GraphQL.Types.User>
{
/// <summary>
/// Username used when creating jobs automatically.
@@ -26,13 +26,18 @@ namespace Tgstation.Server.Host.Models
/// </summary>
public User? CreatedBy { get; set; }
/// <summary>
/// The <see cref="EntityId.Id"/> of the <see cref="User"/>'s <see cref="CreatedBy"/> <see cref="User"/>.
/// </summary>
public long? CreatedById { get; set; }
/// <summary>
/// The <see cref="UserGroup"/> the <see cref="User"/> belongs to, if any.
/// </summary>
public UserGroup? Group { get; set; }
/// <summary>
/// The ID of the <see cref="User"/>'s <see cref="UserGroup"/>.
/// The <see cref="EntityId.Id"/> of the <see cref="User"/>'s <see cref="Group"/>.
/// </summary>
public long? GroupId { get; set; }
@@ -78,6 +83,18 @@ namespace Tgstation.Server.Host.Models
/// <inheritdoc />
public UserResponse ToApi() => CreateUserResponse(true);
/// <inheritdoc />
GraphQL.Types.User IApiTransformable<GraphQL.Types.User>.ToApi()
=> new(
Id!.Value,
Name!,
CanonicalName!,
SystemIdentifier,
CreatedAt!.Value,
CreatedById,
GroupId,
Enabled!.Value);
/// <summary>
/// Generate a <see cref="UserResponse"/> from <see langword="this"/>.
/// </summary>
@@ -0,0 +1,40 @@
using System;
using System.Reflection;
using HotChocolate.Authorization;
using Tgstation.Server.Host.Authority.Core;
namespace Tgstation.Server.Host.Security
{
/// <summary>
/// Inherits the roles of <see cref="TgsAuthorizeAttribute"/>s for GraphQL endpoints.
/// </summary>
/// <typeparam name="TAuthority">The <see cref="IAuthority"/> being wrapped.</typeparam>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
public sealed class TgsGraphQLAuthorizeAttribute<TAuthority> : AuthorizeAttribute
where TAuthority : IAuthority
{
/// <summary>
/// The name of the method targeted.
/// </summary>
public string MethodName { get; }
/// <summary>
/// Initializes a new instance of the <see cref="TgsGraphQLAuthorizeAttribute{TAuthority}"/> class.
/// </summary>
/// <param name="methodName">The <typeparamref name="TAuthority"/> method name to inherit roles from.</param>
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<TgsAuthorizeAttribute>()
?? throw new InvalidOperationException($"Could not find method {authorityType}.{methodName}() has no {nameof(TgsAuthorizeAttribute)}!");
MethodName = methodName;
Roles = authorizeAttribute.Roles?.Split(',');
}
}
}
@@ -0,0 +1,44 @@
using System;
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Tgstation.Server.Host.Authority.Core;
namespace Tgstation.Server.Host.Security
{
/// <summary>
/// Inherits the roles of <see cref="TgsAuthorizeAttribute"/>s for REST endpoints.
/// </summary>
/// <typeparam name="TAuthority">The <see cref="IAuthority"/> being wrapped.</typeparam>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
public sealed class TgsRestAuthorizeAttribute<TAuthority> : AuthorizeAttribute
where TAuthority : IAuthority
{
/// <summary>
/// The name of the method targeted.
/// </summary>
public string MethodName { get; }
/// <summary>
/// Initializes a new instance of the <see cref="TgsRestAuthorizeAttribute{TAuthority}"/> class.
/// </summary>
/// <param name="methodName">The <typeparamref name="TAuthority"/> method name to inherit roles from.</param>
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<TgsAuthorizeAttribute>();
if (authorizeAttribute == null)
throw new InvalidOperationException($"Could not find method {authorityType}.{methodName}() has no {nameof(TgsAuthorizeAttribute)}!");
MethodName = methodName;
Roles = authorizeAttribute.Roles;
}
}
}
@@ -180,8 +180,8 @@
</ItemGroup>
<ItemGroup>
<Folder Include="Authority\" />
<Folder Include="GraphQL\DataLoaders\" />
<Folder Include="GraphQL\Mutations\" />
<Folder Include="wwwroot\" />
</ItemGroup>
</Project>