From f13f2d9fd368ef36cbb60e57e23cdcce9e054a3a Mon Sep 17 00:00:00 2001 From: Andy Pack Date: Sat, 21 Jan 2023 16:17:46 +0000 Subject: [PATCH] adding jwt auth --- .../Extensions/ServiceExtensions.cs | 6 -- Selector.Web/Auth/JwtTokenService.cs | 61 ++++++++++++++ Selector.Web/Controller/AuthController.cs | 71 ++++++++++++++++ Selector.Web/Options.cs | 11 +++ Selector.Web/Selector.Web.csproj | 4 + Selector.Web/Startup.cs | 83 +++++++++++++++---- Selector.Web/appsettings.json | 10 ++- 7 files changed, 221 insertions(+), 25 deletions(-) create mode 100644 Selector.Web/Auth/JwtTokenService.cs create mode 100644 Selector.Web/Controller/AuthController.cs diff --git a/Selector.Model/Extensions/ServiceExtensions.cs b/Selector.Model/Extensions/ServiceExtensions.cs index 5374a0c..e564136 100644 --- a/Selector.Model/Extensions/ServiceExtensions.cs +++ b/Selector.Model/Extensions/ServiceExtensions.cs @@ -9,12 +9,6 @@ namespace Selector.Model.Extensions { public static void AddAuthorisationHandlers(this IServiceCollection services) { - services.AddAuthorization(options => - { - options.FallbackPolicy = new AuthorizationPolicyBuilder() - .RequireAuthenticatedUser() - .Build(); - }); services.AddScoped(); services.AddSingleton(); diff --git a/Selector.Web/Auth/JwtTokenService.cs b/Selector.Web/Auth/JwtTokenService.cs new file mode 100644 index 0000000..055ba24 --- /dev/null +++ b/Selector.Web/Auth/JwtTokenService.cs @@ -0,0 +1,61 @@ +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using Selector.Model; + +namespace Selector.Web.Auth; + +public class JwtTokenService +{ + private readonly UserManager _userManager; + private readonly IOptions _options; + + public JwtTokenService(UserManager userManager, IOptions options) + { + _userManager = userManager; + _options = options; + } + + public async Task CreateJwtToken(ApplicationUser user) + { + var userClaims = await _userManager.GetClaimsAsync(user); + var roles = await _userManager.GetRolesAsync(user); + + var roleClaims = roles.Select(r => new Claim("roles", r)); + + var claims = new[] + { + new Claim(JwtRegisteredClaimNames.Sub, user.Id), + new Claim(ClaimTypes.NameIdentifier, user.Id), + + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + + new Claim(JwtRegisteredClaimNames.Email, user.Email), + new Claim(ClaimTypes.Email, user.Email), + + new Claim(ClaimTypes.Name, user.UserName), + + new Claim("uid", user.Id) + } + .Union(userClaims) + .Union(roleClaims); + + var symmetricSecurityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Value.Key)); + var signingCredentials = new SigningCredentials(symmetricSecurityKey, SecurityAlgorithms.HmacSha256); + + var jwtSecurityToken = new JwtSecurityToken( + issuer: _options.Value.Issuer, + audience: _options.Value.Audience, + claims: claims, + expires: DateTime.UtcNow.Add(_options.Value.Expiry), + signingCredentials: signingCredentials); + + return jwtSecurityToken; + } +} \ No newline at end of file diff --git a/Selector.Web/Controller/AuthController.cs b/Selector.Web/Controller/AuthController.cs new file mode 100644 index 0000000..43d6462 --- /dev/null +++ b/Selector.Web/Controller/AuthController.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Selector.Model; +using Selector.Web.Auth; + +namespace Selector.Web.Controller; + +[ApiController] +[AllowAnonymous] +[Route("api/[controller]/token")] +public class AuthController : BaseAuthController +{ + private readonly JwtTokenService _tokenService; + + public AuthController(ApplicationDbContext context, IAuthorizationService auth, UserManager userManager, ILogger logger, JwtTokenService tokenService) : base(context, auth, userManager, logger) + { + _tokenService = tokenService; + } + + public class TokenModel + { + public string Username { get; set; } + public string Password { get; set; } + } + + [HttpPost] + public async Task Token([FromForm] TokenModel model) + { + var user = await UserManager.GetUserAsync(User); + + if (user is null) // user isn't logged in, use parameter creds + { + if (model.Username is null) + { + return BadRequest("No username provided"); + } + + if (model.Password is null) + { + return BadRequest("No password provided"); + } + + var normalUsername = model.Username.Trim().ToUpperInvariant(); + user = await Context.Users.FirstOrDefaultAsync(u => u.NormalizedUserName == normalUsername); + + if (user is null) + { + return NotFound("user not found"); + } + + if (!await UserManager.CheckPasswordAsync(user, model.Password)) + { + return Unauthorized("credentials failed"); + } + } + + var token = await _tokenService.CreateJwtToken(user); + var tokenHandler = new JwtSecurityTokenHandler(); + + return Ok(new Dictionary + { + {"token", tokenHandler.WriteToken(token)} + }); + } +} \ No newline at end of file diff --git a/Selector.Web/Options.cs b/Selector.Web/Options.cs index ae4850c..8157aae 100644 --- a/Selector.Web/Options.cs +++ b/Selector.Web/Options.cs @@ -57,4 +57,15 @@ namespace Selector.Web public bool Enabled { get; set; } = false; public string ConnectionString { get; set; } } + + public class JwtOptions + { + public const string _Key = "Jwt"; + + + public string Key { get; set; } + public string Issuer { get; set; } + public string Audience { get; set; } + public TimeSpan Expiry { get; set; } = TimeSpan.FromDays(7); + } } diff --git a/Selector.Web/Selector.Web.csproj b/Selector.Web/Selector.Web.csproj index a9046a8..758d185 100644 --- a/Selector.Web/Selector.Web.csproj +++ b/Selector.Web/Selector.Web.csproj @@ -32,17 +32,21 @@ + + + + diff --git a/Selector.Web/Startup.cs b/Selector.Web/Startup.cs index 90d6b49..b157a2c 100644 --- a/Selector.Web/Startup.cs +++ b/Selector.Web/Startup.cs @@ -1,24 +1,23 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; - -using Microsoft.AspNetCore.Identity; -using Microsoft.EntityFrameworkCore; - +using Microsoft.IdentityModel.Tokens; +using Selector.Cache.Extensions; using Selector.Events; -using Selector.Web.Hubs; -using Selector.Web.Extensions; using Selector.Extensions; using Selector.Model; using Selector.Model.Extensions; -using Selector.Cache; -using Selector.Cache.Extensions; +using Selector.Web.Auth; +using Selector.Web.Extensions; +using Selector.Web.Hubs; namespace Selector.Web { @@ -46,6 +45,10 @@ namespace Selector.Web { Configuration.GetSection(string.Join(':', RootOptions.Key, NowPlayingOptions.Key)).Bind(options); }); + services.Configure(options => + { + Configuration.GetSection(JwtOptions._Key).Bind(options); + }); var config = OptionsHelper.ConfigureOptions(Configuration); @@ -60,6 +63,20 @@ namespace Selector.Web services.AddSignalR(o => o.EnableDetailedErrors = true); services.AddHttpClient(); + ConfigureDB(services, config); + ConfigureIdentity(services, config); + ConfigureAuth(services, config); + + services.AddEvents(); + + services.AddSpotify(); + ConfigureLastFm(config, services); + + ConfigureRedis(services, config); + } + + public void ConfigureDB(IServiceCollection services, RootOptions config) + { services.AddDbContext(options => options.UseNpgsql(Configuration.GetConnectionString("Default")) ); @@ -69,7 +86,10 @@ namespace Selector.Web services.AddTransient(); //services.AddTransient(); + } + public void ConfigureIdentity(IServiceCollection services, RootOptions config) + { services.AddIdentity() .AddEntityFrameworkStores() .AddDefaultUI() @@ -96,7 +116,10 @@ namespace Selector.Web options.User.RequireUniqueEmail = false; options.SignIn.RequireConfirmedEmail = false; }); + } + public void ConfigureAuth(IServiceCollection services, RootOptions config) + { services.ConfigureApplicationCookie(options => { // Cookie settings @@ -108,13 +131,41 @@ namespace Selector.Web options.SlidingExpiration = true; }); + var jwtConfig = Configuration.GetSection(JwtOptions._Key).Get(); + + services.AddAuthentication() + .AddJwtBearer(o => + { + o.RequireHttpsMetadata = false; + o.SaveToken = false; + o.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero, + ValidIssuer = jwtConfig.Issuer, + ValidAudience = jwtConfig.Audience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtConfig.Key)) + }; + }); + + services.AddAuthorization(options => + { + options.FallbackPolicy = new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .AddAuthenticationSchemes(IdentityConstants.ApplicationScheme, JwtBearerDefaults.AuthenticationScheme) + .Build(); + }); + + services.AddTransient(); + services.AddAuthorisationHandlers(); + } - services.AddEvents(); - - services.AddSpotify(); - ConfigureLastFm(config, services); - + public void ConfigureRedis(IServiceCollection services, RootOptions config) + { if (config.RedisOptions.Enabled) { Console.WriteLine("> Adding Redis..."); diff --git a/Selector.Web/appsettings.json b/Selector.Web/appsettings.json index 2ba4c58..57c5503 100644 --- a/Selector.Web/appsettings.json +++ b/Selector.Web/appsettings.json @@ -11,10 +11,14 @@ "enabled": true }, "Now": { - "ArtistResampleWindow": "14.00:00:00", - "AlbumResampleWindow": "14.00:00:00", - "TrackResampleWindow": "14.00:00:00" + "ArtistResampleWindow": "14.00:00:00", + "AlbumResampleWindow": "14.00:00:00", + "TrackResampleWindow": "14.00:00:00" } }, + "Jwt": { + "Issuer": "https://selector.sarsoo.xyz", + "Audience": "selector.sarsoo.xyz" + }, "AllowedHosts": "*" } \ No newline at end of file