feat: 支持租户伪装登录与管理员重置链接

This commit is contained in:
2025-12-15 14:43:50 +08:00
parent d64545dd26
commit 2249588e07
16 changed files with 478 additions and 2 deletions

View File

@@ -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>()

View File

@@ -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:";
}

View File

@@ -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>

View File

@@ -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. (空行后) 写入缓存ValueuserId
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('=');
}
}