chore: 同步当前开发内容
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// JWT 认证扩展
|
||||
/// </summary>
|
||||
public static class JwtAuthenticationExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 配置 JWT Bearer 认证
|
||||
/// </summary>
|
||||
public static IServiceCollection AddJwtAuthentication(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var jwtOptions = configuration.GetSection("Identity:Jwt").Get<JwtOptions>()
|
||||
?? throw new InvalidOperationException("缺少 Identity:Jwt 配置");
|
||||
|
||||
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
|
||||
JwtSecurityTokenHandler.DefaultOutboundClaimTypeMap.Clear();
|
||||
|
||||
services
|
||||
.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.RequireHttpsMetadata = false;
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = jwtOptions.Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = jwtOptions.Audience,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.Secret)),
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromMinutes(1),
|
||||
NameClaimType = ClaimTypes.NameIdentifier,
|
||||
RoleClaimType = ClaimTypes.Role
|
||||
};
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Services;
|
||||
using DomainIdentityUser = TakeoutSaaS.Domain.Identity.Entities.IdentityUser;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// 身份认证基础设施注入
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册身份认证基础设施(数据库、Redis、JWT、限流等)
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合</param>
|
||||
/// <param name="configuration">配置源</param>
|
||||
/// <param name="enableMiniFeatures">是否启用小程序相关依赖(如微信登录)</param>
|
||||
/// <param name="enableAdminSeed">是否启用后台账号初始化</param>
|
||||
public static IServiceCollection AddIdentityInfrastructure(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
bool enableMiniFeatures = false,
|
||||
bool enableAdminSeed = false)
|
||||
{
|
||||
var dbConnection = configuration.GetConnectionString("IdentityDatabase");
|
||||
if (string.IsNullOrWhiteSpace(dbConnection))
|
||||
{
|
||||
throw new InvalidOperationException("缺少 IdentityDatabase 连接字符串配置");
|
||||
}
|
||||
|
||||
services.AddDbContext<IdentityDbContext>(options => options.UseNpgsql(dbConnection));
|
||||
|
||||
var redisConnection = configuration.GetConnectionString("Redis");
|
||||
if (string.IsNullOrWhiteSpace(redisConnection))
|
||||
{
|
||||
throw new InvalidOperationException("缺少 Redis 连接字符串配置");
|
||||
}
|
||||
|
||||
services.AddStackExchangeRedisCache(options =>
|
||||
{
|
||||
options.Configuration = redisConnection;
|
||||
});
|
||||
|
||||
services.AddScoped<IIdentityUserRepository, EfIdentityUserRepository>();
|
||||
services.AddScoped<IMiniUserRepository, EfMiniUserRepository>();
|
||||
services.AddScoped<IJwtTokenService, JwtTokenService>();
|
||||
services.AddScoped<IRefreshTokenStore, RedisRefreshTokenStore>();
|
||||
services.AddScoped<ILoginRateLimiter, RedisLoginRateLimiter>();
|
||||
services.AddScoped<IPasswordHasher<DomainIdentityUser>, PasswordHasher<DomainIdentityUser>>();
|
||||
|
||||
services.AddOptions<JwtOptions>()
|
||||
.Bind(configuration.GetSection("Identity:Jwt"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddOptions<LoginRateLimitOptions>()
|
||||
.Bind(configuration.GetSection("Identity:LoginRateLimit"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddOptions<RefreshTokenStoreOptions>()
|
||||
.Bind(configuration.GetSection("Identity:RefreshTokenStore"));
|
||||
|
||||
if (enableMiniFeatures)
|
||||
{
|
||||
services.AddOptions<WeChatMiniOptions>()
|
||||
.Bind(configuration.GetSection("Identity:WeChatMini"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddHttpClient<IWeChatAuthService, WeChatAuthService>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://api.weixin.qq.com/");
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
});
|
||||
}
|
||||
|
||||
if (enableAdminSeed)
|
||||
{
|
||||
services.AddOptions<AdminSeedOptions>()
|
||||
.Bind(configuration.GetSection("Identity:AdminSeed"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddHostedService<IdentityDataSeeder>();
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台初始账号配置。
|
||||
/// </summary>
|
||||
public sealed class AdminSeedOptions
|
||||
{
|
||||
public List<SeedUserOptions> Users { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class SeedUserOptions
|
||||
{
|
||||
[Required]
|
||||
public string Account { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
public Guid TenantId { get; set; }
|
||||
public Guid? MerchantId { get; set; }
|
||||
public string[] Roles { get; set; } = Array.Empty<string>();
|
||||
public string[] Permissions { get; set; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
/// <summary>
|
||||
/// JWT 配置。
|
||||
/// </summary>
|
||||
public sealed class JwtOptions
|
||||
{
|
||||
[Required]
|
||||
public string Issuer { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string Audience { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MinLength(32)]
|
||||
public string Secret { get; set; } = string.Empty;
|
||||
|
||||
[Range(5, 1440)]
|
||||
public int AccessTokenExpirationMinutes { get; set; } = 60;
|
||||
|
||||
[Range(60, 1440 * 14)]
|
||||
public int RefreshTokenExpirationMinutes { get; set; } = 60 * 24 * 7;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 登录限流配置。
|
||||
/// </summary>
|
||||
public sealed class LoginRateLimitOptions
|
||||
{
|
||||
[Range(1, 3600)]
|
||||
public int WindowSeconds { get; set; } = 60;
|
||||
|
||||
[Range(1, 100)]
|
||||
public int MaxAttempts { get; set; } = 5;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 刷新令牌存储配置。
|
||||
/// </summary>
|
||||
public sealed class RefreshTokenStoreOptions
|
||||
{
|
||||
public string Prefix { get; set; } = "identity:refresh:";
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 微信小程序配置。
|
||||
/// </summary>
|
||||
public sealed class WeChatMiniOptions
|
||||
{
|
||||
[Required]
|
||||
public string AppId { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string Secret { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core 后台用户仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfIdentityUserRepository : IIdentityUserRepository
|
||||
{
|
||||
private readonly IdentityDbContext _dbContext;
|
||||
|
||||
public EfIdentityUserRepository(IdentityDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
public Task<IdentityUser?> FindByAccountAsync(string account, CancellationToken cancellationToken = default)
|
||||
=> _dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Account == account, cancellationToken);
|
||||
|
||||
public Task<IdentityUser?> FindByIdAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||
=> _dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core 小程序用户仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfMiniUserRepository : IMiniUserRepository
|
||||
{
|
||||
private readonly IdentityDbContext _dbContext;
|
||||
|
||||
public EfMiniUserRepository(IdentityDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
public Task<MiniUser?> FindByOpenIdAsync(string openId, CancellationToken cancellationToken = default)
|
||||
=> _dbContext.MiniUsers.AsNoTracking().FirstOrDefaultAsync(x => x.OpenId == openId, cancellationToken);
|
||||
|
||||
public Task<MiniUser?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _dbContext.MiniUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
|
||||
public async Task<MiniUser> CreateOrUpdateAsync(string openId, string? unionId, string? nickname, string? avatar, Guid tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await _dbContext.MiniUsers.FirstOrDefaultAsync(x => x.OpenId == openId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
user = new MiniUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OpenId = openId,
|
||||
UnionId = unionId,
|
||||
Nickname = nickname ?? "小程序用户",
|
||||
Avatar = avatar,
|
||||
TenantId = tenantId
|
||||
};
|
||||
_dbContext.MiniUsers.Add(user);
|
||||
}
|
||||
else
|
||||
{
|
||||
user.UnionId = unionId ?? user.UnionId;
|
||||
user.Nickname = nickname ?? user.Nickname;
|
||||
user.Avatar = avatar ?? user.Avatar;
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
using DomainIdentityUser = TakeoutSaaS.Domain.Identity.Entities.IdentityUser;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 后台账号初始化种子任务
|
||||
/// </summary>
|
||||
public sealed class IdentityDataSeeder : IHostedService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<IdentityDataSeeder> _logger;
|
||||
|
||||
public IdentityDataSeeder(IServiceProvider serviceProvider, ILogger<IdentityDataSeeder> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
|
||||
var options = scope.ServiceProvider.GetRequiredService<IOptions<AdminSeedOptions>>().Value;
|
||||
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<DomainIdentityUser>>();
|
||||
|
||||
await context.Database.MigrateAsync(cancellationToken);
|
||||
|
||||
if (options.Users == null || options.Users.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("AdminSeed 未配置账号,跳过后台账号初始化");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var userOptions in options.Users)
|
||||
{
|
||||
var user = await context.IdentityUsers.FirstOrDefaultAsync(x => x.Account == userOptions.Account, cancellationToken);
|
||||
var roles = NormalizeValues(userOptions.Roles);
|
||||
var permissions = NormalizeValues(userOptions.Permissions);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
user = new DomainIdentityUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Account = userOptions.Account,
|
||||
DisplayName = userOptions.DisplayName,
|
||||
TenantId = userOptions.TenantId,
|
||||
MerchantId = userOptions.MerchantId,
|
||||
Avatar = null,
|
||||
Roles = roles,
|
||||
Permissions = permissions,
|
||||
};
|
||||
user.PasswordHash = passwordHasher.HashPassword(user, userOptions.Password);
|
||||
context.IdentityUsers.Add(user);
|
||||
_logger.LogInformation("已创建后台账号 {Account}", user.Account);
|
||||
}
|
||||
else
|
||||
{
|
||||
user.DisplayName = userOptions.DisplayName;
|
||||
user.TenantId = userOptions.TenantId;
|
||||
user.MerchantId = userOptions.MerchantId;
|
||||
user.Roles = roles;
|
||||
user.Permissions = permissions;
|
||||
user.PasswordHash = passwordHasher.HashPassword(user, userOptions.Password);
|
||||
_logger.LogInformation("已更新后台账号 {Account}", user.Account);
|
||||
}
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
private static string[] NormalizeValues(string[]? values)
|
||||
=> values == null
|
||||
? Array.Empty<string>()
|
||||
: values
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||
.Select(v => v.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 身份认证 DbContext。
|
||||
/// </summary>
|
||||
public sealed class IdentityDbContext : DbContext
|
||||
{
|
||||
public IdentityDbContext(DbContextOptions<IdentityDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<IdentityUser> IdentityUsers => Set<IdentityUser>();
|
||||
public DbSet<MiniUser> MiniUsers => Set<MiniUser>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
ConfigureIdentityUser(modelBuilder.Entity<IdentityUser>());
|
||||
ConfigureMiniUser(modelBuilder.Entity<MiniUser>());
|
||||
}
|
||||
|
||||
private static void ConfigureIdentityUser(EntityTypeBuilder<IdentityUser> builder)
|
||||
{
|
||||
builder.ToTable("identity_users");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.Account).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.DisplayName).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.PasswordHash).HasMaxLength(256).IsRequired();
|
||||
builder.Property(x => x.Avatar).HasMaxLength(256);
|
||||
|
||||
var converter = new ValueConverter<string[], string>(
|
||||
v => string.Join(',', v ?? Array.Empty<string>()),
|
||||
v => string.IsNullOrWhiteSpace(v) ? Array.Empty<string>() : v.Split(',', StringSplitOptions.RemoveEmptyEntries));
|
||||
|
||||
var comparer = new ValueComparer<string[]>(
|
||||
(l, r) => l!.SequenceEqual(r!),
|
||||
v => v.Aggregate(0, (current, item) => HashCode.Combine(current, item.GetHashCode())),
|
||||
v => v.ToArray());
|
||||
|
||||
builder.Property(x => x.Roles)
|
||||
.HasConversion(converter)
|
||||
.Metadata.SetValueComparer(comparer);
|
||||
|
||||
builder.Property(x => x.Permissions)
|
||||
.HasConversion(converter)
|
||||
.Metadata.SetValueComparer(comparer);
|
||||
|
||||
builder.HasIndex(x => x.Account).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureMiniUser(EntityTypeBuilder<MiniUser> builder)
|
||||
{
|
||||
builder.ToTable("mini_users");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.OpenId).HasMaxLength(128).IsRequired();
|
||||
builder.Property(x => x.UnionId).HasMaxLength(128);
|
||||
builder.Property(x => x.Nickname).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.Avatar).HasMaxLength(256);
|
||||
|
||||
builder.HasIndex(x => x.OpenId).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Services;
|
||||
|
||||
/// <summary>
|
||||
/// JWT 令牌生成器。
|
||||
/// </summary>
|
||||
public sealed class JwtTokenService : IJwtTokenService
|
||||
{
|
||||
private readonly JwtSecurityTokenHandler _tokenHandler = new();
|
||||
private readonly IRefreshTokenStore _refreshTokenStore;
|
||||
private readonly JwtOptions _options;
|
||||
|
||||
public JwtTokenService(IRefreshTokenStore refreshTokenStore, IOptions<JwtOptions> options)
|
||||
{
|
||||
_refreshTokenStore = refreshTokenStore;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task<TokenResponse> CreateTokensAsync(CurrentUserProfile profile, bool isNewUser = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var accessExpires = now.AddMinutes(_options.AccessTokenExpirationMinutes);
|
||||
var refreshExpires = now.AddMinutes(_options.RefreshTokenExpirationMinutes);
|
||||
|
||||
var claims = BuildClaims(profile);
|
||||
var signingCredentials = new SigningCredentials(
|
||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Secret)),
|
||||
SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var jwt = new JwtSecurityToken(
|
||||
issuer: _options.Issuer,
|
||||
audience: _options.Audience,
|
||||
claims: claims,
|
||||
notBefore: now,
|
||||
expires: accessExpires,
|
||||
signingCredentials: signingCredentials);
|
||||
|
||||
var accessToken = _tokenHandler.WriteToken(jwt);
|
||||
var refreshDescriptor = await _refreshTokenStore.IssueAsync(profile.UserId, refreshExpires, cancellationToken);
|
||||
|
||||
return new TokenResponse
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
AccessTokenExpiresAt = accessExpires,
|
||||
RefreshToken = refreshDescriptor.Token,
|
||||
RefreshTokenExpiresAt = refreshDescriptor.ExpiresAt,
|
||||
User = profile,
|
||||
IsNewUser = isNewUser
|
||||
};
|
||||
}
|
||||
|
||||
private static IEnumerable<Claim> BuildClaims(CurrentUserProfile profile)
|
||||
{
|
||||
var userId = profile.UserId.ToString();
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, userId),
|
||||
new(ClaimTypes.NameIdentifier, userId),
|
||||
new(JwtRegisteredClaimNames.UniqueName, profile.Account),
|
||||
new("tenant_id", profile.TenantId.ToString()),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||
};
|
||||
|
||||
if (profile.MerchantId.HasValue)
|
||||
{
|
||||
claims.Add(new Claim("merchant_id", profile.MerchantId.Value.ToString()));
|
||||
}
|
||||
|
||||
foreach (var role in profile.Roles)
|
||||
{
|
||||
claims.Add(new Claim(ClaimTypes.Role, role));
|
||||
}
|
||||
|
||||
foreach (var permission in profile.Permissions)
|
||||
{
|
||||
claims.Add(new Claim("permission", permission));
|
||||
}
|
||||
|
||||
return claims;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Redis 登录限流实现。
|
||||
/// </summary>
|
||||
public sealed class RedisLoginRateLimiter : ILoginRateLimiter
|
||||
{
|
||||
private readonly IDistributedCache _cache;
|
||||
private readonly LoginRateLimitOptions _options;
|
||||
|
||||
public RedisLoginRateLimiter(IDistributedCache cache, IOptions<LoginRateLimitOptions> options)
|
||||
{
|
||||
_cache = cache;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task EnsureAllowedAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = BuildKey(key);
|
||||
var current = await _cache.GetStringAsync(cacheKey, cancellationToken);
|
||||
var count = string.IsNullOrWhiteSpace(current) ? 0 : int.Parse(current);
|
||||
if (count >= _options.MaxAttempts)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "尝试次数过多,请稍后再试");
|
||||
}
|
||||
|
||||
count++;
|
||||
await _cache.SetStringAsync(
|
||||
cacheKey,
|
||||
count.ToString(),
|
||||
new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(_options.WindowSeconds)
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task ResetAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> _cache.RemoveAsync(BuildKey(key), cancellationToken);
|
||||
|
||||
private static string BuildKey(string key) => $"identity:login:{key}";
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Models;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Redis 刷新令牌存储。
|
||||
/// </summary>
|
||||
public sealed class RedisRefreshTokenStore : IRefreshTokenStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly IDistributedCache _cache;
|
||||
private readonly RefreshTokenStoreOptions _options;
|
||||
|
||||
public RedisRefreshTokenStore(IDistributedCache cache, IOptions<RefreshTokenStoreOptions> options)
|
||||
{
|
||||
_cache = cache;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task<RefreshTokenDescriptor> IssueAsync(Guid userId, DateTime expiresAt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(48));
|
||||
var descriptor = new RefreshTokenDescriptor(token, userId, expiresAt, false);
|
||||
|
||||
var key = BuildKey(token);
|
||||
var entryOptions = new DistributedCacheEntryOptions { AbsoluteExpiration = expiresAt };
|
||||
await _cache.SetStringAsync(key, JsonSerializer.Serialize(descriptor, JsonOptions), entryOptions, cancellationToken);
|
||||
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
public async Task<RefreshTokenDescriptor?> GetAsync(string refreshToken, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var json = await _cache.GetStringAsync(BuildKey(refreshToken), cancellationToken);
|
||||
return string.IsNullOrWhiteSpace(json)
|
||||
? null
|
||||
: JsonSerializer.Deserialize<RefreshTokenDescriptor>(json, JsonOptions);
|
||||
}
|
||||
|
||||
public async Task RevokeAsync(string refreshToken, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var descriptor = await GetAsync(refreshToken, cancellationToken);
|
||||
if (descriptor == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var updated = descriptor with { Revoked = true };
|
||||
var entryOptions = new DistributedCacheEntryOptions { AbsoluteExpiration = updated.ExpiresAt };
|
||||
await _cache.SetStringAsync(BuildKey(refreshToken), JsonSerializer.Serialize(updated, JsonOptions), entryOptions, cancellationToken);
|
||||
}
|
||||
|
||||
private string BuildKey(string token) => $"{_options.Prefix}{token}";
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 微信 code2Session 实现
|
||||
/// </summary>
|
||||
public sealed class WeChatAuthService : IWeChatAuthService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly WeChatMiniOptions _options;
|
||||
|
||||
public WeChatAuthService(HttpClient httpClient, IOptions<WeChatMiniOptions> options)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task<WeChatSessionInfo> Code2SessionAsync(string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var requestUri = $"sns/jscode2session?appid={Uri.EscapeDataString(_options.AppId)}&secret={Uri.EscapeDataString(_options.Secret)}&js_code={Uri.EscapeDataString(code)}&grant_type=authorization_code";
|
||||
using var response = await _httpClient.GetAsync(requestUri, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<WeChatSessionResponse>(cancellationToken: cancellationToken);
|
||||
if (payload == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, "微信登录失败:响应为空");
|
||||
}
|
||||
|
||||
if (payload.ErrorCode.HasValue && payload.ErrorCode.Value != 0)
|
||||
{
|
||||
var message = string.IsNullOrWhiteSpace(payload.ErrorMessage)
|
||||
? $"微信登录失败,错误码:{payload.ErrorCode}"
|
||||
: payload.ErrorMessage;
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, message);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(payload.OpenId) || string.IsNullOrWhiteSpace(payload.SessionKey))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, "微信登录失败:返回数据无效");
|
||||
}
|
||||
|
||||
return new WeChatSessionInfo
|
||||
{
|
||||
OpenId = payload.OpenId,
|
||||
UnionId = payload.UnionId,
|
||||
SessionKey = payload.SessionKey
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class WeChatSessionResponse
|
||||
{
|
||||
[JsonPropertyName("openid")]
|
||||
public string? OpenId { get; set; }
|
||||
|
||||
[JsonPropertyName("unionid")]
|
||||
public string? UnionId { get; set; }
|
||||
|
||||
[JsonPropertyName("session_key")]
|
||||
public string? SessionKey { get; set; }
|
||||
|
||||
[JsonPropertyName("errcode")]
|
||||
public int? ErrorCode { get; set; }
|
||||
|
||||
[JsonPropertyName("errmsg")]
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user