Improve LoginPayload

This commit is contained in:
Jordan Dominion
2024-09-14 14:54:54 -04:00
parent 308a08d051
commit ff262b2584
11 changed files with 71 additions and 45 deletions
@@ -1,6 +1,6 @@
mutation Login {
login {
string
bearer
errors {
... on ErrorMessageError {
message
@@ -1,8 +1,8 @@
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Host.Authority.Core;
using Tgstation.Server.Host.GraphQL.Mutations;
namespace Tgstation.Server.Host.Authority
{
@@ -15,7 +15,7 @@ namespace Tgstation.Server.Host.Authority
/// 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);
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="LoginPayload"/> and <see cref="Models.User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<AuthorityResponse<LoginPayload>> AttemptLogin(CancellationToken cancellationToken);
}
}
@@ -11,7 +11,9 @@ 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.GraphQL.Mutations;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Models.Transformers;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.Security.OAuth;
using Tgstation.Server.Host.Utils;
@@ -55,8 +57,8 @@ namespace Tgstation.Server.Host.Authority
/// 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)
/// <returns>A new, errored <see cref="LoginPayload"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
static AuthorityResponse<LoginPayload> GenerateHeadersExceptionResponse(HeadersException headersException)
=> new(
new ErrorMessageResponse(ErrorCode.BadHeaders)
{
@@ -75,14 +77,6 @@ namespace Tgstation.Server.Host.Authority
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
@@ -129,14 +123,14 @@ namespace Tgstation.Server.Host.Authority
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse<TokenResponse>> AttemptLogin(CancellationToken cancellationToken)
public async ValueTask<AuthorityResponse<LoginPayload>> AttemptLogin(CancellationToken cancellationToken)
{
var headers = apiHeadersProvider.ApiHeaders;
if (headers == null)
return GenerateHeadersExceptionResponse(apiHeadersProvider.HeadersException!);
if (headers.IsTokenAuthentication)
return BadRequest<TokenResponse>(ErrorCode.TokenWithToken);
return BadRequest<LoginPayload>(ErrorCode.TokenWithToken);
var oAuthLogin = headers.OAuthProvider.HasValue;
@@ -166,7 +160,7 @@ namespace Tgstation.Server.Host.Authority
.GetValidator(oAuthProvider);
if (validator == null)
return BadRequest<TokenResponse>(ErrorCode.OAuthProviderDisabled);
return BadRequest<LoginPayload>(ErrorCode.OAuthProviderDisabled);
externalUserId = await validator
.ValidateResponseCode(headers.OAuthCode!, cancellationToken);
@@ -175,11 +169,11 @@ namespace Tgstation.Server.Host.Authority
}
catch (Octokit.RateLimitExceededException ex)
{
return RateLimit<TokenResponse>(ex);
return RateLimit<LoginPayload>(ex);
}
if (externalUserId == null)
return Unauthorized<TokenResponse>();
return Unauthorized<LoginPayload>();
query = query.Where(
x => x.OAuthConnections!.Any(
@@ -190,7 +184,7 @@ namespace Tgstation.Server.Host.Authority
{
var canonicalUserName = User.CanonicalizeName(headers.Username!);
if (canonicalUserName == User.CanonicalizeName(User.TgsSystemUserName))
return Unauthorized<TokenResponse>();
return Unauthorized<LoginPayload>();
if (systemIdentity == null)
query = query.Where(x => x.CanonicalName == canonicalUserName);
@@ -202,7 +196,7 @@ namespace Tgstation.Server.Host.Authority
// No user? You're not allowed
if (user == null)
return Unauthorized<TokenResponse>();
return Unauthorized<LoginPayload>();
// 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
@@ -217,7 +211,7 @@ namespace Tgstation.Server.Host.Authority
{
// DB User password check and update
if (!isLikelyDbUser || !cryptographySuite.CheckUserPassword(user, headers.Password!))
return Unauthorized<TokenResponse>();
return Unauthorized<LoginPayload>();
if (user.PasswordHash != originalHash)
{
Logger.LogDebug("User ID {userId}'s password hash needs a refresh, updating database.", user.Id);
@@ -260,16 +254,22 @@ namespace Tgstation.Server.Host.Authority
if (!user.Enabled!.Value)
{
Logger.LogTrace("Not logging in disabled user {userId}.", user.Id);
return Forbid<TokenResponse>();
return Forbid<LoginPayload>();
}
var token = tokenFactory.CreateToken(user, oAuthLogin);
var payload = new LoginPayload
{
Bearer = token,
User = ((IApiTransformable<User, GraphQL.Types.User, UserGraphQLTransformer>)user).ToApi(),
};
if (usingSystemIdentity)
await CacheSystemIdentity(systemIdentity!, user, token);
await CacheSystemIdentity(systemIdentity!, user, payload);
Logger.LogDebug("Successfully logged in user {userId}!", user.Id);
return new AuthorityResponse<TokenResponse>(token);
return new AuthorityResponse<LoginPayload>(payload);
}
}
@@ -278,12 +278,12 @@ namespace Tgstation.Server.Host.Authority
/// </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>
/// <param name="loginPayload">The <see cref="LoginPayload"/> for the successful login.</param>
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
private async ValueTask CacheSystemIdentity(ISystemIdentity systemIdentity, User user, TokenResponse token)
private async ValueTask CacheSystemIdentity(ISystemIdentity systemIdentity, User user, LoginPayload loginPayload)
{
// expire the identity slightly after the auth token in case of lag
var identExpiry = token.ParseJwt().ValidTo;
var identExpiry = loginPayload.ToApi().ParseJwt().ValidTo;
identExpiry += tokenFactory.ValidationParameters.ClockSkew;
identExpiry += TimeSpan.FromSeconds(15);
await identityCache.CacheSystemIdentity(user, systemIdentity!, identExpiry);
@@ -19,6 +19,7 @@ using Tgstation.Server.Host.Components.Interop;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Core;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.GraphQL.Mutations;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.Security.OAuth;
@@ -185,7 +186,7 @@ namespace Tgstation.Server.Host.Controllers
return ValueTask.FromResult(HeadersIssue(ApiHeadersProvider.HeadersException!));
}
return loginAuthority.Invoke<TokenResponse, TokenResponse>(this, authority => authority.AttemptLogin(cancellationToken));
return loginAuthority.InvokeTransformable<LoginPayload, TokenResponse>(this, authority => authority.AttemptLogin(cancellationToken));
}
}
}
@@ -5,8 +5,8 @@ using System.Threading.Tasks;
using HotChocolate;
using HotChocolate.Types;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Host.Authority;
using Tgstation.Server.Host.GraphQL.Mutations;
namespace Tgstation.Server.Host.GraphQL
{
@@ -23,16 +23,14 @@ namespace Tgstation.Server.Host.GraphQL
/// <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(
public ValueTask<LoginPayload> Login(
[Service] IGraphQLAuthorityInvoker<ILoginAuthority> loginAuthority,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(loginAuthority);
var tokenResponse = await loginAuthority.Invoke<TokenResponse, TokenResponse>(
authority => authority.AttemptLogin(cancellationToken));
return tokenResponse!.Bearer!;
return loginAuthority.Invoke<LoginPayload, LoginPayload>(
authority => authority.AttemptLogin(cancellationToken))!;
}
}
}
@@ -0,0 +1,31 @@
using HotChocolate;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Host.Models;
namespace Tgstation.Server.Host.GraphQL.Mutations
{
/// <summary>
/// Success response for a login attempt.
/// </summary>
public sealed class LoginPayload : ILegacyApiTransformable<TokenResponse>
{
/// <summary>
/// The JSON Web Token (JWT) to use as a Bearer token for accessing the server. Contains an expiry time.
/// </summary>
public required string Bearer { get; init; }
/// <summary>
/// The <see cref="User"/> that was logged in.
/// </summary>
public required Types.User User { get; init; }
/// <inheritdoc />
[GraphQLIgnore]
public TokenResponse ToApi()
=> new()
{
Bearer = Bearer,
};
}
}
@@ -26,7 +26,7 @@ namespace Tgstation.Server.Host.Security
/// </summary>
/// <param name="user">The <see cref="Models.User"/> to create the token for. Must have the <see cref="Api.Models.EntityId.Id"/> field available.</param>
/// <param name="oAuth">Whether or not this is an OAuth login.</param>
/// <returns>A new <see cref="TokenResponse"/>.</returns>
TokenResponse CreateToken(Models.User user, bool oAuth);
/// <returns>A new token <see cref="string"/>.</returns>
string CreateToken(Models.User user, bool oAuth);
}
}
@@ -101,7 +101,7 @@ namespace Tgstation.Server.Host.Security
}
/// <inheritdoc />
public TokenResponse CreateToken(User user, bool oAuth)
public string CreateToken(User user, bool oAuth)
{
ArgumentNullException.ThrowIfNull(user);
@@ -139,10 +139,7 @@ namespace Tgstation.Server.Host.Security
expiry.UtcDateTime,
now.UtcDateTime));
var tokenResponse = new TokenResponse
{
Bearer = tokenHandler.WriteToken(securityToken),
};
var tokenResponse = tokenHandler.WriteToken(securityToken);
return tokenResponse;
}
@@ -186,7 +186,6 @@
</ItemGroup>
<ItemGroup>
<Folder Include="GraphQL\Mutations\" />
<Folder Include="wwwroot\" />
</ItemGroup>
</Project>
@@ -89,7 +89,7 @@ namespace Tgstation.Server.Host.Swarm.Tests
public TokenValidationParameters ValidationParameters => throw new NotSupportedException();
public TokenResponse CreateToken(User user, bool oAuth)
public string CreateToken(User user, bool oAuth)
{
throw new NotSupportedException();
}
@@ -470,7 +470,7 @@ namespace Tgstation.Server.Tests.Live
});
Assert.IsNotNull(result.Data);
Assert.IsNull(result.Data.Login.String);
Assert.IsNull(result.Data.Login.Bearer);
Assert.IsNotNull(result.Data.Login.Errors);
Assert.AreEqual(1, result.Data.Login.Errors.Count);
var castResult = result.Data.Login.Errors[0] is ILogin_Login_Errors_ErrorMessageError loginError;