mirror of
https://github.com/tgstation/tgstation-server.git
synced 2026-08-23 21:16:52 +01:00
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:
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user