diff --git a/src/Tgstation.Server.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index 16fa1704dd..0fc660be6c 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -1,20 +1,14 @@ -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using System; -using System.Collections.Generic; using System.Globalization; -using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Net; -using System.Security.Claims; using System.Threading.Tasks; using Tgstation.Server.Api; using Tgstation.Server.Api.Models; -using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; @@ -57,64 +51,6 @@ namespace Tgstation.Server.Host.Controllers /// readonly bool requireInstance; - /// - /// Runs after a has been validated. Creates the for the - /// - /// The for the operation - /// A representing the running operation - public static async Task OnTokenValidated(TokenValidatedContext context) - { - var databaseContext = context.HttpContext.RequestServices.GetRequiredService(); - var authenticationContextFactory = context.HttpContext.RequestServices.GetRequiredService(); - - var userIdClaim = context.Principal.FindFirst(JwtRegisteredClaimNames.Sub); - - if (userIdClaim == default(Claim)) - throw new InvalidOperationException("Missing required claim!"); - - long userId; - try - { - userId = Int64.Parse(userIdClaim.Value, CultureInfo.InvariantCulture); - } - catch (Exception e) - { - throw new InvalidOperationException("Failed to parse user ID!", e); - } - - ApiHeaders apiHeaders; - try - { - apiHeaders = new ApiHeaders(context.HttpContext.Request.GetTypedHeaders()); - } - catch - { - //let OnActionExecutionAsync handle the reponse - return; - } - - await authenticationContextFactory.CreateAuthenticationContext(userId, apiHeaders.InstanceId, context.SecurityToken.ValidFrom, context.HttpContext.RequestAborted).ConfigureAwait(false); - - var authenticationContext = authenticationContextFactory.CurrentAuthenticationContext; - - var enumerator = Enum.GetValues(typeof(RightsType)); - var claims = new List(); - foreach (RightsType I in enumerator) - { - //if there's no instance user, do a weird thing and add all the instance roles - //we need it so we can get to OnActionExecutionAsync where we can properly decide between BadRequest and Forbid - //if user is null that means they got the token with an expired password - var rightInt = authenticationContext.User == null || (RightsHelper.IsInstanceRight(I) && authenticationContext.InstanceUser == null) ? ~0U : authenticationContext.GetRight(I); - var rightEnum = RightsHelper.RightToType(I); - var right = (Enum)Enum.ToObject(rightEnum, rightInt); - foreach (Enum J in Enum.GetValues(rightEnum)) - if (right.HasFlag(J)) - claims.Add(new Claim(ClaimTypes.Role, RightsHelper.RoleName(I, J))); - } - - context.Principal.AddIdentity(new ClaimsIdentity(claims)); - } - /// /// Construct an /// diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 86475ae07c..6233129ca1 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -101,6 +101,8 @@ namespace Tgstation.Server.Host.Core services.AddOptions(); + services.AddScoped(); + const string scheme = "JwtBearer"; services.AddAuthentication((options) => { @@ -128,9 +130,11 @@ namespace Tgstation.Server.Host.Core }; jwtBearerOptions.Events = new JwtBearerEvents { - OnTokenValidated = ApiController.OnTokenValidated + //Application is our composition root so this monstrosity of a line is okay + OnTokenValidated = ctx => ctx.HttpContext.RequestServices.GetRequiredService().InjectClaimsIntoContext(ctx, ctx.HttpContext.RequestAborted) }; }); + JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); //fucking converts 'sub' to M$ bs services.AddMvc().AddJsonOptions(options => diff --git a/src/Tgstation.Server.Host/Security/ClaimsInjector.cs b/src/Tgstation.Server.Host/Security/ClaimsInjector.cs new file mode 100644 index 0000000000..3e7f202b2e --- /dev/null +++ b/src/Tgstation.Server.Host/Security/ClaimsInjector.cs @@ -0,0 +1,95 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Http; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api; +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Security +{ + /// + sealed class ClaimsInjector : IClaimsInjector + { + /// + /// The for the + /// + readonly IDatabaseContext databaseContext; + + /// + /// The for the + /// + readonly IAuthenticationContextFactory authenticationContextFactory; + + /// + /// Construct a + /// + /// The value of + /// The value of + public ClaimsInjector(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory) + { + this.databaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext)); + this.authenticationContextFactory = authenticationContextFactory ?? throw new ArgumentNullException(nameof(authenticationContextFactory)); + } + + /// + public async Task InjectClaimsIntoContext(TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken) + { + if (tokenValidatedContext == null) + throw new ArgumentNullException(nameof(tokenValidatedContext)); + + //Find the user id in the token + var userIdClaim = tokenValidatedContext.Principal.FindFirst(JwtRegisteredClaimNames.Sub); + if (userIdClaim == default) + throw new InvalidOperationException("Missing required claim!"); + + long userId; + try + { + userId = Int64.Parse(userIdClaim.Value, CultureInfo.InvariantCulture); + } + catch (Exception e) + { + throw new InvalidOperationException("Failed to parse user ID!", e); + } + + ApiHeaders apiHeaders; + try + { + apiHeaders = new ApiHeaders(tokenValidatedContext.HttpContext.Request.GetTypedHeaders()); + } + catch (InvalidOperationException) + { + //we are not responsible for handling header validation issues + return; + } + + //This populates the CurrentAuthenticationContext field for use by us and subsequent controllers + await authenticationContextFactory.CreateAuthenticationContext(userId, apiHeaders.InstanceId, tokenValidatedContext.SecurityToken.ValidFrom, cancellationToken).ConfigureAwait(false); + + var authenticationContext = authenticationContextFactory.CurrentAuthenticationContext; + + var enumerator = Enum.GetValues(typeof(RightsType)); + var claims = new List(); + foreach (RightsType I in enumerator) + { + //if there's no instance user, do a weird thing and add all the instance roles + //we need it so we can get to OnActionExecutionAsync where we can properly decide between BadRequest and Forbid + //if user is null that means they got the token with an expired password + var rightInt = authenticationContext.User == null || (RightsHelper.IsInstanceRight(I) && authenticationContext.InstanceUser == null) ? ~0U : authenticationContext.GetRight(I); + var rightEnum = RightsHelper.RightToType(I); + var right = (Enum)Enum.ToObject(rightEnum, rightInt); + foreach (Enum J in Enum.GetValues(rightEnum)) + if (right.HasFlag(J)) + claims.Add(new Claim(ClaimTypes.Role, RightsHelper.RoleName(I, J))); + } + + tokenValidatedContext.Principal.AddIdentity(new ClaimsIdentity(claims)); + } + } +} diff --git a/src/Tgstation.Server.Host/Security/IClaimsInjector.cs b/src/Tgstation.Server.Host/Security/IClaimsInjector.cs new file mode 100644 index 0000000000..216b03b0f0 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/IClaimsInjector.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using System.Threading; +using System.Threading.Tasks; + +namespace Tgstation.Server.Host.Security +{ + /// + /// For injecting s that can look for + /// + interface IClaimsInjector + { + /// + /// Setup the s for a given + /// + /// The containing the and of the request and the to add s to + /// The for the operation + /// A representing the running operation + Task InjectClaimsIntoContext(TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken); + } +} \ No newline at end of file