使用 JwtBearer 令牌进行身份验证
This commit is contained in:
parent
e7cbd32a39
commit
6742812bf2
@ -0,0 +1,6 @@
|
|||||||
|
namespace HelloShop.IdentityService;
|
||||||
|
|
||||||
|
public class CustomJwtBearerDefaults
|
||||||
|
{
|
||||||
|
public const string AuthenticationScheme = "CustomJwtBearer";
|
||||||
|
}
|
@ -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<CustomJwtBearerOptions> configure) => builder.AddCustomJwtBearer(CustomJwtBearerDefaults.AuthenticationScheme, configure);
|
||||||
|
|
||||||
|
public static AuthenticationBuilder AddCustomJwtBearer(this AuthenticationBuilder builder, string authenticationScheme, Action<CustomJwtBearerOptions> configure)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(builder);
|
||||||
|
ArgumentNullException.ThrowIfNull(authenticationScheme);
|
||||||
|
ArgumentNullException.ThrowIfNull(configure);
|
||||||
|
|
||||||
|
builder.Services.AddScoped<IUserClaimsPrincipalFactory<User>, CustomUserClaimsPrincipalFactory<User, Role>>();
|
||||||
|
|
||||||
|
return builder.AddScheme<CustomJwtBearerOptions, CustomJwtBearerHandler>(authenticationScheme, configure);
|
||||||
|
}
|
||||||
|
}
|
@ -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<CustomJwtBearerOptions> options, ILoggerFactory logger, UrlEncoder encoder) : SignInAuthenticationHandler<CustomJwtBearerOptions>(options, logger, encoder)
|
||||||
|
{
|
||||||
|
protected override Task<AuthenticateResult> 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();
|
||||||
|
}
|
@ -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!;
|
||||||
|
}
|
@ -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<TUser, TRole>(UserManager<TUser> userManager, RoleManager<TRole> roleManager, IOptions<IdentityOptions> options) : UserClaimsPrincipalFactory<TUser, TRole>(userManager, roleManager, options) where TUser : IdentityUser<int> where TRole : IdentityRole<int>
|
||||||
|
{
|
||||||
|
protected override async Task<ClaimsIdentity> 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;
|
||||||
|
}
|
||||||
|
}
|
115
src/HelloShop.IdentityService/Controllers/AccountController.cs
Normal file
115
src/HelloShop.IdentityService/Controllers/AccountController.cs
Normal file
@ -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<User> userManager, SignInManager<User> signInManager, IOptionsMonitor<JwtBearerOptions> jwtBearerOptions) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpPost(nameof(Login))]
|
||||||
|
public async Task<Results<Ok<AccessTokenResponse>, 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<Results<Ok<AccessTokenResponse>, 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<Results<Ok, ValidationProblem>> 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<string, string[]>(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);
|
||||||
|
}
|
||||||
|
}
|
@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|||||||
namespace HelloShop.IdentityService.EntityFrameworks.Migrations
|
namespace HelloShop.IdentityService.EntityFrameworks.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(IdentityServiceDbContext))]
|
[DbContext(typeof(IdentityServiceDbContext))]
|
||||||
[Migration("20240307112633_InitialCreate")]
|
[Migration("20240316084118_InitialCreate")]
|
||||||
partial class InitialCreate
|
partial class InitialCreate
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
@ -6,6 +6,7 @@
|
|||||||
<InvariantGlobalization>true</InvariantGlobalization>
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.3" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.2" />
|
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.2" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.2">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.2">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
@ -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; }
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
namespace HelloShop.IdentityService;
|
||||||
|
|
||||||
|
public class AccountRefreshRequest
|
||||||
|
{
|
||||||
|
public required string RefreshToken { get; init; }
|
||||||
|
|
||||||
|
}
|
@ -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; }
|
||||||
|
}
|
@ -1,9 +1,13 @@
|
|||||||
|
using HelloShop.IdentityService;
|
||||||
using HelloShop.IdentityService.Constants;
|
using HelloShop.IdentityService.Constants;
|
||||||
using HelloShop.IdentityService.DataSeeding;
|
using HelloShop.IdentityService.DataSeeding;
|
||||||
using HelloShop.IdentityService.Entities;
|
using HelloShop.IdentityService.Entities;
|
||||||
using HelloShop.IdentityService.EntityFrameworks;
|
using HelloShop.IdentityService.EntityFrameworks;
|
||||||
using HelloShop.ServiceDefaults.Extensions;
|
using HelloShop.ServiceDefaults.Extensions;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@ -18,7 +22,7 @@ builder.Services.AddDbContext<IdentityServiceDbContext>(options =>
|
|||||||
options.UseNpgsql(builder.Configuration.GetConnectionString(DbConstants.ConnectionStringName));
|
options.UseNpgsql(builder.Configuration.GetConnectionString(DbConstants.ConnectionStringName));
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddIdentityApiEndpoints<User>(options =>
|
builder.Services.AddIdentity<User, Role>(options =>
|
||||||
{
|
{
|
||||||
options.Password.RequireDigit = false;
|
options.Password.RequireDigit = false;
|
||||||
options.Password.RequireLowercase = false;
|
options.Password.RequireLowercase = false;
|
||||||
@ -26,15 +30,32 @@ builder.Services.AddIdentityApiEndpoints<User>(options =>
|
|||||||
options.Password.RequireNonAlphanumeric = false;
|
options.Password.RequireNonAlphanumeric = false;
|
||||||
options.Password.RequiredLength = 5;
|
options.Password.RequiredLength = 5;
|
||||||
options.SignIn.RequireConfirmedAccount = false;
|
options.SignIn.RequireConfirmedAccount = false;
|
||||||
}).AddRoles<Role>().AddEntityFrameworkStores<IdentityServiceDbContext>();
|
options.ClaimsIdentity.SecurityStampClaimType = "securitystamp";
|
||||||
|
}).AddEntityFrameworkStores<IdentityServiceDbContext>();
|
||||||
|
|
||||||
|
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.AddDataSeedingProviders();
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddOpenApi();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
app.MapGroup("identity").MapIdentityApi<User>();
|
|
||||||
|
|
||||||
app.MapDefaultEndpoints();
|
app.MapDefaultEndpoints();
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
@ -0,0 +1,6 @@
|
|||||||
|
namespace HelloShop.ServiceDefaults.Constants;
|
||||||
|
|
||||||
|
public static class CustomClaimTypes
|
||||||
|
{
|
||||||
|
public const string RoleIdentifier = "roleid";
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
namespace HelloShop.ServiceDefaults.Constants;
|
||||||
|
|
||||||
|
public static class IdentityConstants
|
||||||
|
{
|
||||||
|
public const string IssuerSigningKey = "f0a12726d98e7dcda0a578857d0552f0e2ae229cde729240c396eec661a72ecf0f73c485255d446499df1d96f277431c843899c32ad87baa5f700ff8c1726191";
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user