JWT Authentication

This commit is contained in:
AeonLucid
2021-11-01 18:27:08 +01:00
parent 505ae8b2f8
commit e5e3e7dff3
16 changed files with 415 additions and 9 deletions
@@ -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<PlayFabSettings> settings, DbUserService userService, DbEntityService entityService)
public ClientController(IOptions<PlayFabSettings> 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<IActionResult> 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<FServerLoginResult>
{
@@ -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<object>(),
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<FUpdateUserTitleDisplayNameResult>
{
@@ -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")]
@@ -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)
{
}
}
}
@@ -7,6 +7,7 @@
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="2.13.2" />
<PackageReference Include="Serilog.AspNetCore" Version="4.1.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.14.0" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,8 @@
namespace Prospect.Server.Api.Services.Auth
{
public static class AuthClaimTypes
{
public const string Id = "id";
public const string Type = "type";
}
}
@@ -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<AuthTokenSettings> options)
{
_securityKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(options.Value.Secret));
_tokenHandler = new JwtSecurityTokenHandler();
}
private string CreateToken(IEnumerable<Claim> 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;
}
}
}
}
@@ -0,0 +1,7 @@
namespace Prospect.Server.Api.Services.Auth
{
public class AuthTokenSettings
{
public string Secret { get; set; }
}
}
@@ -0,0 +1,8 @@
namespace Prospect.Server.Api.Services.Auth
{
public static class AuthType
{
public const string User = "User";
public const string Entity = "Entity";
}
}
@@ -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<UserAuthenticationOptions> options)
{
return authenticationBuilder.AddScheme<UserAuthenticationOptions, UserAuthenticationHandler>(UserAuthenticationOptions.DefaultScheme, options);
}
public static AuthenticationBuilder AddEntityAuthentication(this AuthenticationBuilder authenticationBuilder, Action<EntityAuthenticationOptions> options)
{
return authenticationBuilder.AddScheme<EntityAuthenticationOptions, EntityAuthenticationHandler>(EntityAuthenticationOptions.DefaultScheme, options);
}
}
}
@@ -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<EntityAuthenticationOptions>
{
private const string Header = "X-EntityToken";
private const string Type = AuthType.Entity;
private readonly AuthTokenService _authTokenService;
public EntityAuthenticationHandler(
IOptionsMonitor<EntityAuthenticationOptions> 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<AuthenticateResult> 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<ClaimsIdentity> { 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));
}
}
}
@@ -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;
}
}
@@ -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;
}
}
}
@@ -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<UserAuthenticationOptions>
{
private const string Header = "X-Authorization";
private const string Type = AuthType.User;
private readonly AuthTokenService _authTokenService;
public UserAuthenticationHandler(
IOptionsMonitor<UserAuthenticationOptions> 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<AuthenticateResult> 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<ClaimsIdentity> { 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
}));
}
}
}
@@ -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;
}
}
+14
View File
@@ -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<AuthTokenSettings>(Configuration.GetSection(nameof(AuthTokenSettings)));
services.Configure<DatabaseSettings>(Configuration.GetSection(nameof(DatabaseSettings)));
services.Configure<PlayFabSettings>(Configuration.GetSection(nameof(PlayFabSettings)));
services.AddSingleton<AuthTokenService>();
services.AddSingleton<DbUserService>();
services.AddSingleton<DbEntityService>();
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<RequestLoggerMiddleware>();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
+3
View File
@@ -1,4 +1,7 @@
{
"AuthTokenSettings": {
"Secret": "ChangeMe"
},
"DatabaseSettings": {
"ConnectionString": "mongodb://localhost:27017",
"DatabaseName": "ProspectDb"