From e5e3e7dff30dbc2f417a370852f22039c4374f35 Mon Sep 17 00:00:00 2001 From: AeonLucid Date: Mon, 1 Nov 2021 18:27:08 +0100 Subject: [PATCH] JWT Authentication --- .../Controllers/ClientController.cs | 30 +++++-- .../Controllers/CloudScriptController.cs | 5 +- .../Exceptions/ProspectException.cs | 24 +++++ .../Prospect.Server.Api.csproj | 1 + .../Services/Auth/AuthClaimTypes.cs | 8 ++ .../Services/Auth/AuthTokenService.cs | 81 +++++++++++++++++ .../Services/Auth/AuthTokenSettings.cs | 7 ++ .../Services/Auth/AuthType.cs | 8 ++ .../Auth/AuthenticationBuilderExtensions.cs | 20 +++++ .../Entity/EntityAuthenticationHandler.cs | 87 +++++++++++++++++++ .../Entity/EntityAuthenticationOptions.cs | 11 +++ .../Extensions/ClaimsPrincipalExtensions.cs | 29 +++++++ .../Auth/User/UserAuthenticationHandler.cs | 85 ++++++++++++++++++ .../Auth/User/UserAuthenticationOptions.cs | 11 +++ src/Prospect.Server.Api/Startup.cs | 14 +++ src/Prospect.Server.Api/appsettings.json | 3 + 16 files changed, 415 insertions(+), 9 deletions(-) create mode 100644 src/Prospect.Server.Api/Exceptions/ProspectException.cs create mode 100644 src/Prospect.Server.Api/Services/Auth/AuthClaimTypes.cs create mode 100644 src/Prospect.Server.Api/Services/Auth/AuthTokenService.cs create mode 100644 src/Prospect.Server.Api/Services/Auth/AuthTokenSettings.cs create mode 100644 src/Prospect.Server.Api/Services/Auth/AuthType.cs create mode 100644 src/Prospect.Server.Api/Services/Auth/AuthenticationBuilderExtensions.cs create mode 100644 src/Prospect.Server.Api/Services/Auth/Entity/EntityAuthenticationHandler.cs create mode 100644 src/Prospect.Server.Api/Services/Auth/Entity/EntityAuthenticationOptions.cs create mode 100644 src/Prospect.Server.Api/Services/Auth/Extensions/ClaimsPrincipalExtensions.cs create mode 100644 src/Prospect.Server.Api/Services/Auth/User/UserAuthenticationHandler.cs create mode 100644 src/Prospect.Server.Api/Services/Auth/User/UserAuthenticationOptions.cs diff --git a/src/Prospect.Server.Api/Controllers/ClientController.cs b/src/Prospect.Server.Api/Controllers/ClientController.cs index 9de7d96..d71acde 100644 --- a/src/Prospect.Server.Api/Controllers/ClientController.cs +++ b/src/Prospect.Server.Api/Controllers/ClientController.cs @@ -1,32 +1,42 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using Prospect.Server.Api.Config; using Prospect.Server.Api.Models.Client; using Prospect.Server.Api.Models.Client.Data; +using Prospect.Server.Api.Services.Auth; +using Prospect.Server.Api.Services.Auth.User; using Prospect.Server.Api.Services.Database; using Prospect.Server.Api.Services.Database.Models; using Prospect.Server.Steam; namespace Prospect.Server.Api.Controllers { - [Route("Client")] [ApiController] + [Route("Client")] + [Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)] public class ClientController : Controller { private readonly PlayFabSettings _settings; + private readonly AuthTokenService _authTokenService; private readonly DbUserService _userService; private readonly DbEntityService _entityService; - public ClientController(IOptions settings, DbUserService userService, DbEntityService entityService) + public ClientController(IOptions settings, + AuthTokenService authTokenService, + DbUserService userService, + DbEntityService entityService) { _settings = settings.Value; + _authTokenService = authTokenService; _userService = userService; _entityService = entityService; } + [AllowAnonymous] [HttpPost("LoginWithSteam")] [Produces("application/json")] public async Task LoginWithSteam(ClientLoginWithSteamRequest request) @@ -37,8 +47,12 @@ namespace Prospect.Server.Api.Controllers if (ticket.IsValid && ticket.HasValidSignature) { var userSteamId = ticket.SteamId.ToString(); + var user = await _userService.FindOrCreateAsync(PlayFabUserAuthType.Steam, userSteamId); + var userTicket = _authTokenService.Generate(user); + var entity = await _entityService.FindOrCreateAsync(user.Id); + var entityTicket = _authTokenService.Generate(entity); return Ok(new ClientResponse { @@ -54,8 +68,8 @@ namespace Prospect.Server.Api.Controllers Type = "title_player_account", TypeString = "title_player_account" }, - EntityToken = "RW50aXR5VG9rZW4=", - TokenExpiration = DateTime.UtcNow.AddDays(1), + EntityToken = entityTicket, + TokenExpiration = DateTime.UtcNow.AddDays(6), // TODO: }, InfoResultPayload = new FGetPlayerCombinedInfoResultPayload { @@ -71,10 +85,10 @@ namespace Prospect.Server.Api.Controllers UserInventory = new List(), UserReadOnlyDataVersion = 0 }, - LastLoginTime = DateTime.UtcNow, - NewlyCreated = false, + LastLoginTime = DateTime.UtcNow, // TODO: + NewlyCreated = false, // TODO: PlayFabId = user.Id, - SessionTicket = "SOME", + SessionTicket = userTicket, SettingsForUser = new FUserSettings { GatherDeviceInfo = true, @@ -114,7 +128,7 @@ namespace Prospect.Server.Api.Controllers [HttpPost("UpdateUserTitleDisplayName")] [Produces("application/json")] - public IActionResult AddGenericId(FUpdateUserTitleDisplayNameRequest request) + public IActionResult UpdateUserTitleDisplayName(FUpdateUserTitleDisplayNameRequest request) { return Ok(new ClientResponse { diff --git a/src/Prospect.Server.Api/Controllers/CloudScriptController.cs b/src/Prospect.Server.Api/Controllers/CloudScriptController.cs index 893298a..27c3e84 100644 --- a/src/Prospect.Server.Api/Controllers/CloudScriptController.cs +++ b/src/Prospect.Server.Api/Controllers/CloudScriptController.cs @@ -1,10 +1,13 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; using Prospect.Server.Api.Models.Client; +using Prospect.Server.Api.Services.Auth.Entity; namespace Prospect.Server.Api.Controllers { [Route("CloudScript")] [ApiController] + [Authorize(AuthenticationSchemes = EntityAuthenticationOptions.DefaultScheme)] public class CloudScriptController : Controller { [HttpPost("ExecuteFunction")] diff --git a/src/Prospect.Server.Api/Exceptions/ProspectException.cs b/src/Prospect.Server.Api/Exceptions/ProspectException.cs new file mode 100644 index 0000000..dd5db62 --- /dev/null +++ b/src/Prospect.Server.Api/Exceptions/ProspectException.cs @@ -0,0 +1,24 @@ +using System; +using System.Runtime.Serialization; + +namespace Prospect.Server.Api.Exceptions +{ + public class ProspectException : Exception + { + public ProspectException() + { + } + + protected ProspectException(SerializationInfo info, StreamingContext context) : base(info, context) + { + } + + public ProspectException(string message) : base(message) + { + } + + public ProspectException(string message, Exception innerException) : base(message, innerException) + { + } + } +} \ No newline at end of file diff --git a/src/Prospect.Server.Api/Prospect.Server.Api.csproj b/src/Prospect.Server.Api/Prospect.Server.Api.csproj index ba7bc00..f49a953 100644 --- a/src/Prospect.Server.Api/Prospect.Server.Api.csproj +++ b/src/Prospect.Server.Api/Prospect.Server.Api.csproj @@ -7,6 +7,7 @@ + diff --git a/src/Prospect.Server.Api/Services/Auth/AuthClaimTypes.cs b/src/Prospect.Server.Api/Services/Auth/AuthClaimTypes.cs new file mode 100644 index 0000000..61cd12c --- /dev/null +++ b/src/Prospect.Server.Api/Services/Auth/AuthClaimTypes.cs @@ -0,0 +1,8 @@ +namespace Prospect.Server.Api.Services.Auth +{ + public static class AuthClaimTypes + { + public const string Id = "id"; + public const string Type = "type"; + } +} \ No newline at end of file diff --git a/src/Prospect.Server.Api/Services/Auth/AuthTokenService.cs b/src/Prospect.Server.Api/Services/Auth/AuthTokenService.cs new file mode 100644 index 0000000..7dbec98 --- /dev/null +++ b/src/Prospect.Server.Api/Services/Auth/AuthTokenService.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using Prospect.Server.Api.Services.Database.Models; + +namespace Prospect.Server.Api.Services.Auth +{ + public class AuthTokenService + { + private const string DefaultIssuer = "ProspectApi"; + private const string DefaultAudience = "Prospect"; + + private readonly SymmetricSecurityKey _securityKey; + private readonly JwtSecurityTokenHandler _tokenHandler; + + public AuthTokenService(IOptions options) + { + _securityKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(options.Value.Secret)); + _tokenHandler = new JwtSecurityTokenHandler(); + } + + private string CreateToken(IEnumerable claims) + { + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Expires = DateTime.UtcNow.AddDays(7), + Issuer = DefaultIssuer, + Audience = DefaultAudience, + SigningCredentials = new SigningCredentials(_securityKey, SecurityAlgorithms.HmacSha256Signature) + }; + + var token = _tokenHandler.CreateToken(tokenDescriptor); + return _tokenHandler.WriteToken(token); + } + + public string Generate(PlayFabUser user) + { + return CreateToken(new[] + { + new Claim(AuthClaimTypes.Id, user.Id), + new Claim(AuthClaimTypes.Type, AuthType.User), + }); + } + + public string Generate(PlayFabEntity entity) + { + return CreateToken(new[] + { + new Claim(AuthClaimTypes.Id, entity.Id), + new Claim(AuthClaimTypes.Type, AuthType.Entity), + }); + } + + public ClaimsPrincipal Validate(string token) + { + var tokenHandler = new JwtSecurityTokenHandler(); + + try + { + return tokenHandler.ValidateToken(token, new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + ValidateIssuer = true, + ValidateAudience = true, + ValidIssuer = DefaultIssuer, + ValidAudience = DefaultAudience, + IssuerSigningKey = _securityKey + }, out _); + } + catch + { + return null; + } + } + } +} \ No newline at end of file diff --git a/src/Prospect.Server.Api/Services/Auth/AuthTokenSettings.cs b/src/Prospect.Server.Api/Services/Auth/AuthTokenSettings.cs new file mode 100644 index 0000000..10e1aa8 --- /dev/null +++ b/src/Prospect.Server.Api/Services/Auth/AuthTokenSettings.cs @@ -0,0 +1,7 @@ +namespace Prospect.Server.Api.Services.Auth +{ + public class AuthTokenSettings + { + public string Secret { get; set; } + } +} \ No newline at end of file diff --git a/src/Prospect.Server.Api/Services/Auth/AuthType.cs b/src/Prospect.Server.Api/Services/Auth/AuthType.cs new file mode 100644 index 0000000..3b0dfe3 --- /dev/null +++ b/src/Prospect.Server.Api/Services/Auth/AuthType.cs @@ -0,0 +1,8 @@ +namespace Prospect.Server.Api.Services.Auth +{ + public static class AuthType + { + public const string User = "User"; + public const string Entity = "Entity"; + } +} \ No newline at end of file diff --git a/src/Prospect.Server.Api/Services/Auth/AuthenticationBuilderExtensions.cs b/src/Prospect.Server.Api/Services/Auth/AuthenticationBuilderExtensions.cs new file mode 100644 index 0000000..5b060a9 --- /dev/null +++ b/src/Prospect.Server.Api/Services/Auth/AuthenticationBuilderExtensions.cs @@ -0,0 +1,20 @@ +using System; +using Microsoft.AspNetCore.Authentication; +using Prospect.Server.Api.Services.Auth.Entity; +using Prospect.Server.Api.Services.Auth.User; + +namespace Prospect.Server.Api.Services.Auth +{ + public static class AuthenticationBuilderExtensions + { + public static AuthenticationBuilder AddUserAuthentication(this AuthenticationBuilder authenticationBuilder, Action options) + { + return authenticationBuilder.AddScheme(UserAuthenticationOptions.DefaultScheme, options); + } + + public static AuthenticationBuilder AddEntityAuthentication(this AuthenticationBuilder authenticationBuilder, Action options) + { + return authenticationBuilder.AddScheme(EntityAuthenticationOptions.DefaultScheme, options); + } + } +} \ No newline at end of file diff --git a/src/Prospect.Server.Api/Services/Auth/Entity/EntityAuthenticationHandler.cs b/src/Prospect.Server.Api/Services/Auth/Entity/EntityAuthenticationHandler.cs new file mode 100644 index 0000000..b37bfd1 --- /dev/null +++ b/src/Prospect.Server.Api/Services/Auth/Entity/EntityAuthenticationHandler.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Prospect.Server.Api.Models.Client; +using Prospect.Server.Api.Services.Auth.Extensions; + +namespace Prospect.Server.Api.Services.Auth.Entity +{ + public class EntityAuthenticationHandler : AuthenticationHandler + { + private const string Header = "X-EntityToken"; + private const string Type = AuthType.Entity; + + private readonly AuthTokenService _authTokenService; + + public EntityAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock, + AuthTokenService authTokenService) : base(options, logger, encoder, clock) + { + _authTokenService = authTokenService; + } + + private ClientResponse Res { get; set; } = new ClientResponse + { + Code = 401, + Status = "Unauthorized", + Error = "NotAuthenticated", + ErrorCode = 1074, + ErrorMessage = "This API method does not allow anonymous callers." + }; + + protected override Task HandleAuthenticateAsync() + { + if (!Request.Headers.TryGetValue(Header, out var headerValues) || headerValues.Count == 0) + { + return Task.FromResult(AuthenticateResult.Fail("No header")); + } + + var headerValue = headerValues.FirstOrDefault(); + + if (string.IsNullOrWhiteSpace(headerValue)) + { + return Task.FromResult(AuthenticateResult.Fail("Empty header value")); + } + + Res.Error = "EntityTokenInvalid"; + Res.ErrorCode = 1335; + Res.ErrorMessage = "EntityTokenInvalid"; + + var token = _authTokenService.Validate(headerValue); + if (token != null) + { + var identity = new ClaimsIdentity(token.Claims, Options.AuthenticationType); + var identities = new List { identity }; + var principal = new ClaimsPrincipal(identities); + if (principal.FindAuthType() != Type) + { + return Task.FromResult(AuthenticateResult.Fail("Invalid auth type")); + } + + var ticket = new AuthenticationTicket(principal, Options.Scheme); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + + return Task.FromResult(AuthenticateResult.Fail("Invalid JWT")); + } + + protected override async Task HandleChallengeAsync(AuthenticationProperties properties) + { + Response.StatusCode = 401; + Response.ContentType = "application/json"; + + await Response.WriteAsync(JsonSerializer.Serialize(Res)); + } + } +} \ No newline at end of file diff --git a/src/Prospect.Server.Api/Services/Auth/Entity/EntityAuthenticationOptions.cs b/src/Prospect.Server.Api/Services/Auth/Entity/EntityAuthenticationOptions.cs new file mode 100644 index 0000000..3e132b1 --- /dev/null +++ b/src/Prospect.Server.Api/Services/Auth/Entity/EntityAuthenticationOptions.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Authentication; + +namespace Prospect.Server.Api.Services.Auth.Entity +{ + public class EntityAuthenticationOptions : AuthenticationSchemeOptions + { + public const string DefaultScheme = "EntityAuth"; + public string Scheme => DefaultScheme; + public string AuthenticationType = DefaultScheme; + } +} \ No newline at end of file diff --git a/src/Prospect.Server.Api/Services/Auth/Extensions/ClaimsPrincipalExtensions.cs b/src/Prospect.Server.Api/Services/Auth/Extensions/ClaimsPrincipalExtensions.cs new file mode 100644 index 0000000..e5be2e1 --- /dev/null +++ b/src/Prospect.Server.Api/Services/Auth/Extensions/ClaimsPrincipalExtensions.cs @@ -0,0 +1,29 @@ +using System.Security.Claims; +using Prospect.Server.Api.Exceptions; + +namespace Prospect.Server.Api.Services.Auth.Extensions +{ + public static class ClaimsPrincipalExtensions + { + public static string FindAuthId(this ClaimsPrincipal principal) + { + return Find(principal, AuthClaimTypes.Id); + } + + public static string FindAuthType(this ClaimsPrincipal principal) + { + return Find(principal, AuthClaimTypes.Type); + } + + private static string Find(ClaimsPrincipal principal, string claimType) + { + var claim = principal.FindFirst(claimType); + if (claim == null) + { + throw new ProspectException($"Failed to find claim {claimType}"); + } + + return claim.Value; + } + } +} \ No newline at end of file diff --git a/src/Prospect.Server.Api/Services/Auth/User/UserAuthenticationHandler.cs b/src/Prospect.Server.Api/Services/Auth/User/UserAuthenticationHandler.cs new file mode 100644 index 0000000..b2eee72 --- /dev/null +++ b/src/Prospect.Server.Api/Services/Auth/User/UserAuthenticationHandler.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Prospect.Server.Api.Models.Client; +using Prospect.Server.Api.Services.Auth.Extensions; + +namespace Prospect.Server.Api.Services.Auth.User +{ + public class UserAuthenticationHandler : AuthenticationHandler + { + private const string Header = "X-Authorization"; + private const string Type = AuthType.User; + + private readonly AuthTokenService _authTokenService; + + public UserAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock, + AuthTokenService authTokenService) : base(options, logger, encoder, clock) + { + _authTokenService = authTokenService; + } + + private string Failure { get; set; } = "Not Authenticated"; + + protected override Task HandleAuthenticateAsync() + { + if (!Request.Headers.TryGetValue(Header, out var headerValues) || headerValues.Count == 0) + { + return Task.FromResult(AuthenticateResult.Fail("No header")); + } + + var headerValue = headerValues.FirstOrDefault(); + + if (string.IsNullOrWhiteSpace(headerValue)) + { + return Task.FromResult(AuthenticateResult.Fail("Empty header value")); + } + + Failure = "X-Authentication HTTP header contains invalid ticket"; + + var token = _authTokenService.Validate(headerValue); + if (token != null) + { + var identity = new ClaimsIdentity(token.Claims, Options.AuthenticationType); + var identities = new List { identity }; + var principal = new ClaimsPrincipal(identities); + if (principal.FindAuthType() != Type) + { + return Task.FromResult(AuthenticateResult.Fail("Invalid auth type")); + } + + var ticket = new AuthenticationTicket(principal, Options.Scheme); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + + return Task.FromResult(AuthenticateResult.Fail("Invalid JWT")); + } + + protected override async Task HandleChallengeAsync(AuthenticationProperties properties) + { + Response.StatusCode = 401; + Response.ContentType = "application/json"; + + await Response.WriteAsync(JsonSerializer.Serialize(new ClientResponse + { + Code = 401, + Status = "Unauthorized", + Error = "NotAuthenticated", + ErrorCode = 1074, + ErrorMessage = Failure + })); + } + } +} \ No newline at end of file diff --git a/src/Prospect.Server.Api/Services/Auth/User/UserAuthenticationOptions.cs b/src/Prospect.Server.Api/Services/Auth/User/UserAuthenticationOptions.cs new file mode 100644 index 0000000..13bc95f --- /dev/null +++ b/src/Prospect.Server.Api/Services/Auth/User/UserAuthenticationOptions.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Authentication; + +namespace Prospect.Server.Api.Services.Auth.User +{ + public class UserAuthenticationOptions : AuthenticationSchemeOptions + { + public const string DefaultScheme = "UserAuth"; + public string Scheme => DefaultScheme; + public string AuthenticationType = DefaultScheme; + } +} \ No newline at end of file diff --git a/src/Prospect.Server.Api/Startup.cs b/src/Prospect.Server.Api/Startup.cs index 234eba9..66af679 100644 --- a/src/Prospect.Server.Api/Startup.cs +++ b/src/Prospect.Server.Api/Startup.cs @@ -5,6 +5,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Prospect.Server.Api.Config; using Prospect.Server.Api.Middleware; +using Prospect.Server.Api.Services.Auth; +using Prospect.Server.Api.Services.Auth.User; using Prospect.Server.Api.Services.Database; using Serilog; @@ -21,11 +23,21 @@ namespace Prospect.Server.Api public void ConfigureServices(IServiceCollection services) { + services.Configure(Configuration.GetSection(nameof(AuthTokenSettings))); services.Configure(Configuration.GetSection(nameof(DatabaseSettings))); services.Configure(Configuration.GetSection(nameof(PlayFabSettings))); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = UserAuthenticationOptions.DefaultScheme; + options.DefaultChallengeScheme = UserAuthenticationOptions.DefaultScheme; + }) + .AddUserAuthentication(_ => {}) + .AddEntityAuthentication(_ => {}); services.AddControllers(); } @@ -40,6 +52,8 @@ namespace Prospect.Server.Api app.UseSerilogRequestLogging(); app.UseMiddleware(); app.UseRouting(); + app.UseAuthentication(); + app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); diff --git a/src/Prospect.Server.Api/appsettings.json b/src/Prospect.Server.Api/appsettings.json index a487048..3093424 100644 --- a/src/Prospect.Server.Api/appsettings.json +++ b/src/Prospect.Server.Api/appsettings.json @@ -1,4 +1,7 @@ { + "AuthTokenSettings": { + "Secret": "ChangeMe" + }, "DatabaseSettings": { "ConnectionString": "mongodb://localhost:27017", "DatabaseName": "ProspectDb"