diff --git a/src/HelloShop.IdentityService/Authentication/CustomJwtBearerDefaults.cs b/src/HelloShop.IdentityService/Authentication/CustomJwtBearerDefaults.cs new file mode 100644 index 0000000..b28ad3f --- /dev/null +++ b/src/HelloShop.IdentityService/Authentication/CustomJwtBearerDefaults.cs @@ -0,0 +1,6 @@ +namespace HelloShop.IdentityService; + +public class CustomJwtBearerDefaults +{ + public const string AuthenticationScheme = "CustomJwtBearer"; +} diff --git a/src/HelloShop.IdentityService/Authentication/CustomJwtBearerExtensions.cs b/src/HelloShop.IdentityService/Authentication/CustomJwtBearerExtensions.cs new file mode 100644 index 0000000..6081b7f --- /dev/null +++ b/src/HelloShop.IdentityService/Authentication/CustomJwtBearerExtensions.cs @@ -0,0 +1,25 @@ +using HelloShop.IdentityService.Entities; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; + +namespace HelloShop.IdentityService; + +public static class CustomJwtBearerExtensions +{ + public static AuthenticationBuilder AddCustomJwtBearer(this AuthenticationBuilder builder) => builder.AddCustomJwtBearer(CustomJwtBearerDefaults.AuthenticationScheme); + + public static AuthenticationBuilder AddCustomJwtBearer(this AuthenticationBuilder builder, string authenticationScheme) => builder.AddCustomJwtBearer(authenticationScheme, _ => { }); + + public static AuthenticationBuilder AddCustomJwtBearer(this AuthenticationBuilder builder, Action configure) => builder.AddCustomJwtBearer(CustomJwtBearerDefaults.AuthenticationScheme, configure); + + public static AuthenticationBuilder AddCustomJwtBearer(this AuthenticationBuilder builder, string authenticationScheme, Action configure) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(authenticationScheme); + ArgumentNullException.ThrowIfNull(configure); + + builder.Services.AddScoped, CustomUserClaimsPrincipalFactory>(); + + return builder.AddScheme(authenticationScheme, configure); + } +} diff --git a/src/HelloShop.IdentityService/Authentication/CustomJwtBearerHandler.cs b/src/HelloShop.IdentityService/Authentication/CustomJwtBearerHandler.cs new file mode 100644 index 0000000..ef921a4 --- /dev/null +++ b/src/HelloShop.IdentityService/Authentication/CustomJwtBearerHandler.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.BearerToken; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using System.Text.Encodings.Web; + +namespace HelloShop.IdentityService; + +public class CustomJwtBearerHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) : SignInAuthenticationHandler(options, logger, encoder) +{ + protected override Task HandleAuthenticateAsync() => throw new NotImplementedException(); + + protected override async Task HandleSignInAsync(ClaimsPrincipal user, AuthenticationProperties? properties) + { + var utcNow = TimeProvider.GetUtcNow(); + + JwtSecurityTokenHandler tokenHandler = new(); + + var signingKey = new SymmetricSecurityKey(Encoding.Default.GetBytes(Options.IssuerSigningKey)); + + var accessTokenDescriptor = new SecurityTokenDescriptor + { + Issuer = Options.Issuer, + Audience = Options.Audience, + Subject = user.Identity as ClaimsIdentity, + SigningCredentials = new(signingKey, Options.SecurityAlgorithm), + Expires = utcNow.Add(Options.AccessTokenExpiration).LocalDateTime, + }; + + var accessToken = tokenHandler.CreateJwtSecurityToken(accessTokenDescriptor); + + var refreshTokenDescriptor = new SecurityTokenDescriptor + { + Issuer = Options.Issuer, + Audience = Options.Audience, + Subject = user.Identity as ClaimsIdentity, + SigningCredentials = new(signingKey, Options.SecurityAlgorithm), + Expires = utcNow.Add(Options.RefreshTokenExpiration).LocalDateTime, + }; + + var refreshToken = tokenHandler.CreateJwtSecurityToken(refreshTokenDescriptor); + + AccessTokenResponse response = new() + { + AccessToken = tokenHandler.WriteToken(accessToken), + ExpiresIn = (long)Options.AccessTokenExpiration.TotalSeconds, + RefreshToken = tokenHandler.WriteToken(refreshToken) + }; + + await Context.Response.WriteAsJsonAsync(response); + } + + protected override Task HandleSignOutAsync(AuthenticationProperties? properties) => throw new NotImplementedException(); +} diff --git a/src/HelloShop.IdentityService/Authentication/CustomJwtBearerOptions.cs b/src/HelloShop.IdentityService/Authentication/CustomJwtBearerOptions.cs new file mode 100644 index 0000000..bca6bc3 --- /dev/null +++ b/src/HelloShop.IdentityService/Authentication/CustomJwtBearerOptions.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.IdentityModel.Tokens; + +namespace HelloShop.IdentityService; + +public class CustomJwtBearerOptions : AuthenticationSchemeOptions +{ + public TimeSpan AccessTokenExpiration { get; set; } = TimeSpan.FromHours(1); + + public TimeSpan RefreshTokenExpiration { get; set; } = TimeSpan.FromDays(14); + + public string SecurityAlgorithm { get; set; } = SecurityAlgorithms.HmacSha256; + + public string IssuerSigningKey { get; set; } = default!; + + public string Issuer { get; set; } = default!; + + public string Audience { get; set; } = default!; +} diff --git a/src/HelloShop.IdentityService/Authentication/CustomUserClaimsPrincipalFactory.cs b/src/HelloShop.IdentityService/Authentication/CustomUserClaimsPrincipalFactory.cs new file mode 100644 index 0000000..053b05b --- /dev/null +++ b/src/HelloShop.IdentityService/Authentication/CustomUserClaimsPrincipalFactory.cs @@ -0,0 +1,28 @@ +using HelloShop.ServiceDefaults.Constants; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using System.Security.Claims; + +namespace HelloShop.IdentityService; + +public class CustomUserClaimsPrincipalFactory(UserManager userManager, RoleManager roleManager, IOptions options) : UserClaimsPrincipalFactory(userManager, roleManager, options) where TUser : IdentityUser where TRole : IdentityRole +{ + protected override async Task GenerateClaimsAsync(TUser user) + { + var claimsIdentity = await base.GenerateClaimsAsync(user).ConfigureAwait(false); + + if (UserManager.SupportsUserRole) + { + var roleNames = await UserManager.GetRolesAsync(user).ConfigureAwait(false); + + var roles = RoleManager.Roles.Where(r => r.Name != null && roleNames.Contains(r.Name)); + + foreach (var role in roles) + { + claimsIdentity.AddClaim(new Claim(CustomClaimTypes.RoleIdentifier, role.Id.ToString())); + } + } + + return claimsIdentity; + } +} diff --git a/src/HelloShop.IdentityService/Controllers/AccountController.cs b/src/HelloShop.IdentityService/Controllers/AccountController.cs new file mode 100644 index 0000000..d3c1c95 --- /dev/null +++ b/src/HelloShop.IdentityService/Controllers/AccountController.cs @@ -0,0 +1,115 @@ +using HelloShop.IdentityService.Entities; +using Microsoft.AspNetCore.Authentication.BearerToken; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; + +namespace HelloShop.IdentityService; + +[Route("api/[controller]")] +[ApiController] +public class AccountController(UserManager userManager, SignInManager signInManager, IOptionsMonitor jwtBearerOptions) : ControllerBase +{ + [HttpPost(nameof(Login))] + public async Task, EmptyHttpResult, ProblemHttpResult>> Login([FromBody] AccountLoginRequest request) + { + signInManager.AuthenticationScheme = CustomJwtBearerDefaults.AuthenticationScheme; + + var result = await signInManager.PasswordSignInAsync(request.UserName, request.Password, false, lockoutOnFailure: true); + + if (result.RequiresTwoFactor) + { + if (!string.IsNullOrEmpty(request.TwoFactorCode)) + { + result = await signInManager.TwoFactorAuthenticatorSignInAsync(request.TwoFactorCode, false, rememberClient: false); + } + else if (!string.IsNullOrEmpty(request.TwoFactorRecoveryCode)) + { + result = await signInManager.TwoFactorRecoveryCodeSignInAsync(request.TwoFactorRecoveryCode); + } + } + + if (!result.Succeeded) + { + return TypedResults.Problem(result.ToString(), statusCode: StatusCodes.Status401Unauthorized); + } + + // The signInManager already produced the needed response in the form of a cookie or bearer token. + return TypedResults.Empty; + } + + [HttpPost(nameof(Refresh))] + public async Task, UnauthorizedHttpResult, SignInHttpResult, ChallengeHttpResult>> Refresh([FromBody] AccountRefreshRequest request) + { + var options = jwtBearerOptions.Get(JwtBearerDefaults.AuthenticationScheme); + + var handler = new JwtSecurityTokenHandler(); + + var validationResult = await handler.ValidateTokenAsync(request.RefreshToken, options.TokenValidationParameters); + + // Reject the /refresh attempt with a 401 if the token expired or the security stamp validation fails + if (!validationResult.IsValid || await signInManager.ValidateSecurityStampAsync(new ClaimsPrincipal(validationResult.ClaimsIdentity)) is not User user) + { + return TypedResults.Challenge(); + } + + var newPrincipal = await signInManager.CreateUserPrincipalAsync(user); + + return TypedResults.SignIn(newPrincipal, authenticationScheme: CustomJwtBearerDefaults.AuthenticationScheme); + } + + [HttpPost(nameof(Register))] + public async Task> Register([FromBody] AccountRegisterRequest request) + { + var user = new User { UserName = request.UserName, Email = request.Email }; + + if (string.IsNullOrEmpty(request.Email) || !new EmailAddressAttribute().IsValid(request.Email)) + { + return CreateValidationProblem(IdentityResult.Failed(userManager.ErrorDescriber.InvalidEmail(request.Email))); + } + + var result = await userManager.CreateAsync(user, request.Password); + + if (!result.Succeeded) + { + return CreateValidationProblem(result); + } + + return TypedResults.Ok(); + } + + private static ValidationProblem CreateValidationProblem(IdentityResult result) + { + // We expect a single error code and description in the normal case. + // This could be golfed with GroupBy and ToDictionary, but perf! :P + Debug.Assert(!result.Succeeded); + + var errorDictionary = new Dictionary(1); + + foreach (var error in result.Errors) + { + string[] newDescriptions; + + if (errorDictionary.TryGetValue(error.Code, out var descriptions)) + { + newDescriptions = new string[descriptions.Length + 1]; + Array.Copy(descriptions, newDescriptions, descriptions.Length); + newDescriptions[descriptions.Length] = error.Description; + } + else + { + newDescriptions = [error.Description]; + } + + errorDictionary[error.Code] = newDescriptions; + } + + return TypedResults.ValidationProblem(errorDictionary); + } +} diff --git a/src/HelloShop.IdentityService/EntityFrameworks/Migrations/20240307112633_InitialCreate.Designer.cs b/src/HelloShop.IdentityService/EntityFrameworks/Migrations/20240316084118_InitialCreate.Designer.cs similarity index 99% rename from src/HelloShop.IdentityService/EntityFrameworks/Migrations/20240307112633_InitialCreate.Designer.cs rename to src/HelloShop.IdentityService/EntityFrameworks/Migrations/20240316084118_InitialCreate.Designer.cs index 5b72bdd..835136d 100644 --- a/src/HelloShop.IdentityService/EntityFrameworks/Migrations/20240307112633_InitialCreate.Designer.cs +++ b/src/HelloShop.IdentityService/EntityFrameworks/Migrations/20240316084118_InitialCreate.Designer.cs @@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; namespace HelloShop.IdentityService.EntityFrameworks.Migrations { [DbContext(typeof(IdentityServiceDbContext))] - [Migration("20240307112633_InitialCreate")] + [Migration("20240316084118_InitialCreate")] partial class InitialCreate { /// diff --git a/src/HelloShop.IdentityService/EntityFrameworks/Migrations/20240307112633_InitialCreate.cs b/src/HelloShop.IdentityService/EntityFrameworks/Migrations/20240316084118_InitialCreate.cs similarity index 100% rename from src/HelloShop.IdentityService/EntityFrameworks/Migrations/20240307112633_InitialCreate.cs rename to src/HelloShop.IdentityService/EntityFrameworks/Migrations/20240316084118_InitialCreate.cs diff --git a/src/HelloShop.IdentityService/HelloShop.IdentityService.csproj b/src/HelloShop.IdentityService/HelloShop.IdentityService.csproj index e689458..210141a 100644 --- a/src/HelloShop.IdentityService/HelloShop.IdentityService.csproj +++ b/src/HelloShop.IdentityService/HelloShop.IdentityService.csproj @@ -6,6 +6,7 @@ true + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/HelloShop.IdentityService/Models/Accounts/AccountLoginRequest.cs b/src/HelloShop.IdentityService/Models/Accounts/AccountLoginRequest.cs new file mode 100644 index 0000000..297cbd1 --- /dev/null +++ b/src/HelloShop.IdentityService/Models/Accounts/AccountLoginRequest.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace HelloShop.IdentityService; + +public class AccountLoginRequest +{ + public required string UserName { get; init; } + + public required string Password { get; init; } + + [JsonIgnore] + public string? TwoFactorCode { get; init; } + + [JsonIgnore] + public string? TwoFactorRecoveryCode { get; init; } +} diff --git a/src/HelloShop.IdentityService/Models/Accounts/AccountRefreshRequest.cs b/src/HelloShop.IdentityService/Models/Accounts/AccountRefreshRequest.cs new file mode 100644 index 0000000..2175572 --- /dev/null +++ b/src/HelloShop.IdentityService/Models/Accounts/AccountRefreshRequest.cs @@ -0,0 +1,7 @@ +namespace HelloShop.IdentityService; + +public class AccountRefreshRequest +{ + public required string RefreshToken { get; init; } + +} \ No newline at end of file diff --git a/src/HelloShop.IdentityService/Models/Accounts/AccountRegisterRequest.cs b/src/HelloShop.IdentityService/Models/Accounts/AccountRegisterRequest.cs new file mode 100644 index 0000000..2ca41cb --- /dev/null +++ b/src/HelloShop.IdentityService/Models/Accounts/AccountRegisterRequest.cs @@ -0,0 +1,10 @@ +namespace HelloShop.IdentityService; + +public class AccountRegisterRequest +{ + public required string UserName { get; init; } + + public required string Password { get; init; } + + public string? Email { get; init; } +} diff --git a/src/HelloShop.IdentityService/Program.cs b/src/HelloShop.IdentityService/Program.cs index 5bb9bad..3024467 100644 --- a/src/HelloShop.IdentityService/Program.cs +++ b/src/HelloShop.IdentityService/Program.cs @@ -1,9 +1,13 @@ +using HelloShop.IdentityService; using HelloShop.IdentityService.Constants; using HelloShop.IdentityService.DataSeeding; using HelloShop.IdentityService.Entities; using HelloShop.IdentityService.EntityFrameworks; using HelloShop.ServiceDefaults.Extensions; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using System.Text; var builder = WebApplication.CreateBuilder(args); @@ -18,7 +22,7 @@ builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString(DbConstants.ConnectionStringName)); }); -builder.Services.AddIdentityApiEndpoints(options => +builder.Services.AddIdentity(options => { options.Password.RequireDigit = false; options.Password.RequireLowercase = false; @@ -26,15 +30,32 @@ builder.Services.AddIdentityApiEndpoints(options => options.Password.RequireNonAlphanumeric = false; options.Password.RequiredLength = 5; options.SignIn.RequireConfirmedAccount = false; -}).AddRoles().AddEntityFrameworkStores(); + options.ClaimsIdentity.SecurityStampClaimType = "securitystamp"; +}).AddEntityFrameworkStores(); + +const string issuerSigningKey = HelloShop.ServiceDefaults.Constants.IdentityConstants.IssuerSigningKey; + +builder.Services.AddAuthentication(options => +{ + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultSignInScheme = CustomJwtBearerDefaults.AuthenticationScheme; +}).AddJwtBearer(options => +{ + options.TokenValidationParameters.ValidateIssuer = false; + options.TokenValidationParameters.ValidateAudience = false; + options.TokenValidationParameters.IssuerSigningKey = new SymmetricSecurityKey(Encoding.Default.GetBytes(issuerSigningKey)); +}).AddCustomJwtBearer(options => +{ + options.IssuerSigningKey = issuerSigningKey; + options.SecurityAlgorithm = SecurityAlgorithms.HmacSha256; +}); builder.Services.AddDataSeedingProviders(); builder.Services.AddOpenApi(); var app = builder.Build(); -app.MapGroup("identity").MapIdentityApi(); - app.MapDefaultEndpoints(); app.UseHttpsRedirection(); diff --git a/src/HelloShop.ServiceDefaults/Constants/CustomClaimTypes.cs b/src/HelloShop.ServiceDefaults/Constants/CustomClaimTypes.cs new file mode 100644 index 0000000..28688f6 --- /dev/null +++ b/src/HelloShop.ServiceDefaults/Constants/CustomClaimTypes.cs @@ -0,0 +1,6 @@ +namespace HelloShop.ServiceDefaults.Constants; + +public static class CustomClaimTypes +{ + public const string RoleIdentifier = "roleid"; +} diff --git a/src/HelloShop.ServiceDefaults/Constants/IdentityConstants.cs b/src/HelloShop.ServiceDefaults/Constants/IdentityConstants.cs new file mode 100644 index 0000000..39e281b --- /dev/null +++ b/src/HelloShop.ServiceDefaults/Constants/IdentityConstants.cs @@ -0,0 +1,6 @@ +namespace HelloShop.ServiceDefaults.Constants; + +public static class IdentityConstants +{ + public const string IssuerSigningKey = "f0a12726d98e7dcda0a578857d0552f0e2ae229cde729240c396eec661a72ecf0f73c485255d446499df1d96f277431c843899c32ad87baa5f700ff8c1726191"; +} \ No newline at end of file