feat: 支持租户伪装登录与管理员重置链接
This commit is contained in:
@@ -57,6 +57,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IMenuRepository, EfMenuRepository>();
|
||||
services.AddScoped<IJwtTokenService, JwtTokenService>();
|
||||
services.AddScoped<IRefreshTokenStore, RedisRefreshTokenStore>();
|
||||
services.AddScoped<IAdminPasswordResetTokenStore, RedisAdminPasswordResetTokenStore>();
|
||||
services.AddScoped<ILoginRateLimiter, RedisLoginRateLimiter>();
|
||||
services.AddScoped<IPasswordHasher<DomainIdentityUser>, PasswordHasher<DomainIdentityUser>>();
|
||||
|
||||
@@ -73,6 +74,9 @@ public static class ServiceCollectionExtensions
|
||||
services.AddOptions<RefreshTokenStoreOptions>()
|
||||
.Bind(configuration.GetSection("Identity:RefreshTokenStore"));
|
||||
|
||||
services.AddOptions<AdminPasswordResetOptions>()
|
||||
.Bind(configuration.GetSection("Identity:AdminPasswordReset"));
|
||||
|
||||
if (enableMiniFeatures)
|
||||
{
|
||||
services.AddOptions<WeChatMiniOptions>()
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台重置密码链接令牌配置。
|
||||
/// </summary>
|
||||
public sealed class AdminPasswordResetOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Redis Key 前缀。
|
||||
/// </summary>
|
||||
public string Prefix { get; init; } = "identity:admin:pwdreset:";
|
||||
}
|
||||
|
||||
@@ -41,6 +41,15 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
||||
public Task<IdentityUser?> FindByIdAsync(long userId, CancellationToken cancellationToken = default)
|
||||
=> dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 根据 ID 获取后台用户(用于更新,返回可跟踪实体)。
|
||||
/// </summary>
|
||||
/// <param name="userId">用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>后台用户或 null。</returns>
|
||||
public Task<IdentityUser?> GetForUpdateAsync(long userId, CancellationToken cancellationToken = default)
|
||||
=> dbContext.IdentityUsers.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 按租户与关键字搜索后台用户(只读)。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Security.Cryptography;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Redis 管理后台重置密码链接令牌存储。
|
||||
/// </summary>
|
||||
public sealed class RedisAdminPasswordResetTokenStore(
|
||||
IDistributedCache cache,
|
||||
IOptions<AdminPasswordResetOptions> options)
|
||||
: IAdminPasswordResetTokenStore
|
||||
{
|
||||
private readonly AdminPasswordResetOptions _options = options.Value;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> IssueAsync(long userId, DateTime expiresAt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 生成 URL 安全的随机令牌
|
||||
var token = GenerateUrlSafeToken(48);
|
||||
|
||||
// 2. (空行后) 写入缓存(Value:userId)
|
||||
await cache.SetStringAsync(BuildKey(token), userId.ToString(), new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpiration = expiresAt
|
||||
}, cancellationToken);
|
||||
|
||||
// 3. (空行后) 返回令牌
|
||||
return token;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<long?> ConsumeAsync(string token, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 读取缓存
|
||||
var key = BuildKey(token);
|
||||
var value = await cache.GetStringAsync(key, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. (空行后) 删除缓存(一次性令牌)
|
||||
await cache.RemoveAsync(key, cancellationToken);
|
||||
|
||||
// 3. (空行后) 解析用户 ID
|
||||
return long.TryParse(value, out var userId) ? userId : null;
|
||||
}
|
||||
|
||||
private string BuildKey(string token) => $"{_options.Prefix}{token}";
|
||||
|
||||
private static string GenerateUrlSafeToken(int bytesLength)
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(bytesLength);
|
||||
var token = Convert.ToBase64String(bytes);
|
||||
|
||||
return token
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_')
|
||||
.TrimEnd('=');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user