From 2249588e079d9e05a3526680a42e0d456d8d6634 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Mon, 15 Dec 2025 14:43:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E7=A7=9F=E6=88=B7?= =?UTF-8?q?=E4=BC=AA=E8=A3=85=E7=99=BB=E5=BD=95=E4=B8=8E=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E9=87=8D=E7=BD=AE=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/AuthController.cs | 25 ++++++- .../Controllers/TenantsController.cs | 44 +++++++++++ .../CreateTenantAdminResetLinkTokenCommand.cs | 15 ++++ .../Commands/ImpersonateTenantCommand.cs | 16 ++++ ...TenantAdminResetLinkTokenCommandHandler.cs | 73 ++++++++++++++++++ .../ImpersonateTenantCommandHandler.cs | 75 +++++++++++++++++++ .../IAdminPasswordResetTokenStore.cs | 25 +++++++ .../ResetAdminPasswordByTokenCommand.cs | 20 +++++ .../Contracts/ResetAdminPasswordRequest.cs | 18 +++++ ...ResetAdminPasswordByTokenCommandHandler.cs | 57 ++++++++++++++ .../Repositories/IIdentityUserRepository.cs | 8 ++ .../Tenants/Enums/TenantAuditAction.cs | 12 ++- .../Extensions/ServiceCollectionExtensions.cs | 4 + .../Options/AdminPasswordResetOptions.cs | 13 ++++ .../Persistence/EfIdentityUserRepository.cs | 9 +++ .../RedisAdminPasswordResetTokenStore.cs | 66 ++++++++++++++++ 16 files changed, 478 insertions(+), 2 deletions(-) create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantAdminResetLinkTokenCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ImpersonateTenantCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantAdminResetLinkTokenCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ImpersonateTenantCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminPasswordResetTokenStore.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Commands/ResetAdminPasswordByTokenCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Contracts/ResetAdminPasswordRequest.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Handlers/ResetAdminPasswordByTokenCommandHandler.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminPasswordResetOptions.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisAdminPasswordResetTokenStore.cs diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs index 22e9048..2545af7 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs @@ -1,6 +1,8 @@ +using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Application.Identity.Abstractions; +using TakeoutSaaS.Application.Identity.Commands; using TakeoutSaaS.Application.Identity.Contracts; using TakeoutSaaS.Module.Authorization.Attributes; using TakeoutSaaS.Shared.Abstractions.Constants; @@ -14,10 +16,11 @@ namespace TakeoutSaaS.AdminApi.Controllers; /// /// 提供登录、刷新 Token 以及用户权限查询能力。 /// 认证服务 +/// 中介者。 [ApiVersion("1.0")] [Authorize] [Route("api/admin/v{version:apiVersion}/auth")] -public sealed class AuthController(IAdminAuthService authService) : BaseApiController +public sealed class AuthController(IAdminAuthService authService, IMediator mediator) : BaseApiController { /// /// 登录获取 Token @@ -65,6 +68,26 @@ public sealed class AuthController(IAdminAuthService authService) : BaseApiContr return ApiResponse.Ok(response); } + /// + /// 通过重置链接令牌重置管理员密码。 + /// + /// 令牌为一次性使用;成功后即可使用新密码登录。 + [HttpPost("reset-password")] + [AllowAnonymous] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> ResetPassword([FromBody] ResetAdminPasswordRequest request, CancellationToken cancellationToken) + { + // 1. 通过令牌重置密码 + await mediator.Send(new ResetAdminPasswordByTokenCommand + { + Token = request.Token, + NewPassword = request.NewPassword + }, cancellationToken); + + // 2. 返回成功 + return ApiResponse.Success("密码重置成功"); + } + /// /// 获取当前用户信息 /// diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs index c57b987..0e4346f 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs @@ -5,6 +5,7 @@ using System.ComponentModel.DataAnnotations; using TakeoutSaaS.Application.App.Tenants.Commands; using TakeoutSaaS.Application.App.Tenants.Dto; using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Application.Identity.Contracts; using TakeoutSaaS.Module.Authorization.Attributes; using TakeoutSaaS.Shared.Abstractions.Results; using TakeoutSaaS.Shared.Web.Api; @@ -318,6 +319,49 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController return ApiResponse>.Ok(result); } + /// + /// 伪装登录租户(仅平台超级管理员可用)。 + /// + /// 目标租户主管理员的令牌对。 + [HttpPost("{tenantId:long}/impersonate")] + [PermissionAuthorize("tenant:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Impersonate(long tenantId, CancellationToken cancellationToken) + { + // 1. 执行伪装登录 + var result = await mediator.Send(new ImpersonateTenantCommand { TenantId = tenantId }, cancellationToken); + + // 2. 返回令牌 + return ApiResponse.Ok(result); + } + + /// + /// 生成租户主管理员重置密码链接(仅平台超级管理员可用)。 + /// + /// 链接默认 24 小时有效且仅可使用一次。 + /// 重置密码链接。 + [HttpPost("{tenantId:long}/admin/reset-link")] + [PermissionAuthorize("tenant:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CreateAdminResetLink(long tenantId, CancellationToken cancellationToken) + { + // 1. 生成一次性令牌 + var token = await mediator.Send(new CreateTenantAdminResetLinkTokenCommand { TenantId = tenantId }, cancellationToken); + + // 2. (空行后) 解析前端来源(优先 Origin,避免拼成 AdminApi 域名) + var origin = Request.Headers.Origin.ToString(); + if (string.IsNullOrWhiteSpace(origin)) + { + origin = $"{Request.Scheme}://{Request.Host}"; + } + + origin = origin.TrimEnd('/'); + var resetUrl = $"{origin}/#/auth/reset-password?token={Uri.EscapeDataString(token)}"; + + // 3. (空行后) 返回链接 + return ApiResponse.Ok(resetUrl); + } + /// /// 配额校验并占用额度(门店/账号/短信/配送)。 /// diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantAdminResetLinkTokenCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantAdminResetLinkTokenCommand.cs new file mode 100644 index 0000000..0005a64 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantAdminResetLinkTokenCommand.cs @@ -0,0 +1,15 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 生成租户主管理员重置链接令牌命令(平台超级管理员使用)。 +/// +public sealed record CreateTenantAdminResetLinkTokenCommand : IRequest +{ + /// + /// 目标租户 ID。 + /// + public required long TenantId { get; init; } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ImpersonateTenantCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ImpersonateTenantCommand.cs new file mode 100644 index 0000000..1ea701a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ImpersonateTenantCommand.cs @@ -0,0 +1,16 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 伪装登录租户命令(平台超级管理员使用)。 +/// +public sealed record ImpersonateTenantCommand : IRequest +{ + /// + /// 目标租户 ID。 + /// + public required long TenantId { get; init; } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantAdminResetLinkTokenCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantAdminResetLinkTokenCommandHandler.cs new file mode 100644 index 0000000..c3a00d3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantAdminResetLinkTokenCommandHandler.cs @@ -0,0 +1,73 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.Identity.Abstractions; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 生成租户主管理员重置链接令牌处理器(平台超级管理员使用)。 +/// +public sealed class CreateTenantAdminResetLinkTokenCommandHandler( + ITenantRepository tenantRepository, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor, + IAdminAuthService adminAuthService, + IAdminPasswordResetTokenStore tokenStore) + : IRequestHandler +{ + private const long PlatformRootTenantId = 1000000000001; + + /// + public async Task Handle(CreateTenantAdminResetLinkTokenCommand request, CancellationToken cancellationToken) + { + // 1. 校验仅允许平台超级管理员执行 + var currentTenantId = tenantProvider.GetCurrentTenantId(); + if (currentTenantId != PlatformRootTenantId) + { + throw new BusinessException(ErrorCodes.Forbidden, "仅平台超级管理员可生成重置链接"); + } + + // 2. (空行后) 校验租户存在且存在主管理员 + var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在"); + + if (!tenant.PrimaryOwnerUserId.HasValue || tenant.PrimaryOwnerUserId.Value == 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "该租户未配置主管理员账号,无法生成重置链接"); + } + + // 3. (空行后) 签发一次性重置令牌(默认 24 小时有效) + var token = await tokenStore.IssueAsync(tenant.PrimaryOwnerUserId.Value, DateTime.UtcNow.AddHours(24), cancellationToken); + + // 4. (空行后) 写入审计日志 + var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken); + var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName) + ? $"user:{currentUserAccessor.UserId}" + : operatorProfile.DisplayName; + + var auditLog = new TenantAuditLog + { + TenantId = tenant.Id, + Action = TenantAuditAction.AdminResetLinkIssued, + Title = "生成重置链接", + Description = $"操作者:{operatorName},目标用户ID:{tenant.PrimaryOwnerUserId.Value}", + OperatorId = currentUserAccessor.UserId, + OperatorName = operatorName, + PreviousStatus = tenant.Status, + CurrentStatus = tenant.Status + }; + await tenantRepository.AddAuditLogAsync(auditLog, cancellationToken); + await tenantRepository.SaveChangesAsync(cancellationToken); + + // 5. (空行后) 返回令牌 + return token; + } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ImpersonateTenantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ImpersonateTenantCommandHandler.cs new file mode 100644 index 0000000..8f80077 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ImpersonateTenantCommandHandler.cs @@ -0,0 +1,75 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.Identity.Abstractions; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 伪装登录租户处理器(平台超级管理员使用)。 +/// +public sealed class ImpersonateTenantCommandHandler( + ITenantRepository tenantRepository, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor, + IAdminAuthService adminAuthService, + IJwtTokenService jwtTokenService) + : IRequestHandler +{ + private const long PlatformRootTenantId = 1000000000001; + + /// + public async Task Handle(ImpersonateTenantCommand request, CancellationToken cancellationToken) + { + // 1. 校验仅允许平台超级管理员执行 + var currentTenantId = tenantProvider.GetCurrentTenantId(); + if (currentTenantId != PlatformRootTenantId) + { + throw new BusinessException(ErrorCodes.Forbidden, "仅平台超级管理员可执行伪装登录"); + } + + // 2. (空行后) 校验租户存在且存在主管理员 + var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在"); + + if (!tenant.PrimaryOwnerUserId.HasValue || tenant.PrimaryOwnerUserId.Value == 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "该租户未配置主管理员账号,无法伪装登录"); + } + + // 3. (空行后) 为租户主管理员签发令牌 + var targetProfile = await adminAuthService.GetProfileAsync(tenant.PrimaryOwnerUserId.Value, cancellationToken); + var token = await jwtTokenService.CreateTokensAsync(targetProfile, false, cancellationToken); + + // 4. (空行后) 写入审计日志 + var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken); + var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName) + ? $"user:{currentUserAccessor.UserId}" + : operatorProfile.DisplayName; + + var auditLog = new TenantAuditLog + { + TenantId = tenant.Id, + Action = TenantAuditAction.ImpersonatedLogin, + Title = "伪装登录", + Description = $"操作者:{operatorName},目标账号:{targetProfile.Account}", + OperatorId = currentUserAccessor.UserId, + OperatorName = operatorName, + PreviousStatus = tenant.Status, + CurrentStatus = tenant.Status + }; + await tenantRepository.AddAuditLogAsync(auditLog, cancellationToken); + await tenantRepository.SaveChangesAsync(cancellationToken); + + // 5. (空行后) 返回令牌 + return token; + } +} + diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminPasswordResetTokenStore.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminPasswordResetTokenStore.cs new file mode 100644 index 0000000..c83e6fc --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminPasswordResetTokenStore.cs @@ -0,0 +1,25 @@ +namespace TakeoutSaaS.Application.Identity.Abstractions; + +/// +/// 管理后台“重置密码链接”令牌存储。 +/// +public interface IAdminPasswordResetTokenStore +{ + /// + /// 签发一次性重置令牌。 + /// + /// 目标用户 ID。 + /// 过期时间(UTC)。 + /// 取消标记。 + /// 令牌值。 + Task IssueAsync(long userId, DateTime expiresAt, CancellationToken cancellationToken = default); + + /// + /// 消费一次性重置令牌(成功后即删除)。 + /// + /// 令牌值。 + /// 取消标记。 + /// 令牌绑定的用户 ID;不存在/过期返回 null。 + Task ConsumeAsync(string token, CancellationToken cancellationToken = default); +} + diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/ResetAdminPasswordByTokenCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/ResetAdminPasswordByTokenCommand.cs new file mode 100644 index 0000000..52bdb13 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/ResetAdminPasswordByTokenCommand.cs @@ -0,0 +1,20 @@ +using MediatR; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 通过重置链接令牌重置管理员密码命令。 +/// +public sealed record ResetAdminPasswordByTokenCommand : IRequest +{ + /// + /// 一次性重置令牌。 + /// + public required string Token { get; init; } + + /// + /// 新密码(明文,仅用于服务端哈希)。 + /// + public required string NewPassword { get; init; } +} + diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/ResetAdminPasswordRequest.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/ResetAdminPasswordRequest.cs new file mode 100644 index 0000000..b0ffd28 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/ResetAdminPasswordRequest.cs @@ -0,0 +1,18 @@ +namespace TakeoutSaaS.Application.Identity.Contracts; + +/// +/// 通过重置链接令牌重置管理员密码请求。 +/// +public sealed class ResetAdminPasswordRequest +{ + /// + /// 一次性重置令牌。 + /// + public string Token { get; init; } = string.Empty; + + /// + /// 新密码。 + /// + public string NewPassword { get; init; } = string.Empty; +} + diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/ResetAdminPasswordByTokenCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ResetAdminPasswordByTokenCommandHandler.cs new file mode 100644 index 0000000..8df343d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ResetAdminPasswordByTokenCommandHandler.cs @@ -0,0 +1,57 @@ +using MediatR; +using Microsoft.AspNetCore.Identity; +using TakeoutSaaS.Application.Identity.Abstractions; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Domain.Identity.Entities; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 通过重置链接令牌重置管理员密码处理器。 +/// +public sealed class ResetAdminPasswordByTokenCommandHandler( + IAdminPasswordResetTokenStore tokenStore, + IIdentityUserRepository userRepository, + IPasswordHasher passwordHasher) + : IRequestHandler +{ + /// + public async Task Handle(ResetAdminPasswordByTokenCommand request, CancellationToken cancellationToken) + { + // 1. 参数校验 + var token = request.Token?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(token)) + { + throw new BusinessException(ErrorCodes.BadRequest, "重置令牌不能为空"); + } + + var password = request.NewPassword?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(password)) + { + throw new BusinessException(ErrorCodes.BadRequest, "新密码不能为空"); + } + + if (password.Length is < 6 or > 32) + { + throw new BusinessException(ErrorCodes.BadRequest, "新密码长度需为 6~32 位"); + } + + // 2. (空行后) 校验并消费令牌 + var userId = await tokenStore.ConsumeAsync(token, cancellationToken); + if (!userId.HasValue || userId.Value == 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "重置链接无效或已过期"); + } + + // 3. (空行后) 获取用户(可更新)并写入新密码哈希 + var user = await userRepository.GetForUpdateAsync(userId.Value, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在"); + + user.PasswordHash = passwordHasher.HashPassword(user, password); + await userRepository.SaveChangesAsync(cancellationToken); + } +} + diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs index 3a393c2..773854a 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs @@ -31,6 +31,14 @@ public interface IIdentityUserRepository /// 后台用户或 null。 Task FindByIdAsync(long userId, CancellationToken cancellationToken = default); + /// + /// 根据 ID 获取后台用户(用于更新,返回可跟踪实体)。 + /// + /// 用户 ID。 + /// 取消标记。 + /// 后台用户或 null。 + Task GetForUpdateAsync(long userId, CancellationToken cancellationToken = default); + /// /// 按租户与关键字查询后台用户列表(仅读)。 /// diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantAuditAction.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantAuditAction.cs index 0ec412b..6395a2a 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantAuditAction.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantAuditAction.cs @@ -53,5 +53,15 @@ public enum TenantAuditAction /// /// 释放入驻审核(审核完成或手动释放)。 /// - ReviewClaimReleased = 10 + ReviewClaimReleased = 10, + + /// + /// 平台伪装登录租户。 + /// + ImpersonatedLogin = 11, + + /// + /// 生成主管理员重置链接。 + /// + AdminResetLinkIssued = 12 } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs index 231f604..266b011 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs @@ -57,6 +57,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped, PasswordHasher>(); @@ -73,6 +74,9 @@ public static class ServiceCollectionExtensions services.AddOptions() .Bind(configuration.GetSection("Identity:RefreshTokenStore")); + services.AddOptions() + .Bind(configuration.GetSection("Identity:AdminPasswordReset")); + if (enableMiniFeatures) { services.AddOptions() diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminPasswordResetOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminPasswordResetOptions.cs new file mode 100644 index 0000000..1a958a6 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminPasswordResetOptions.cs @@ -0,0 +1,13 @@ +namespace TakeoutSaaS.Infrastructure.Identity.Options; + +/// +/// 管理后台重置密码链接令牌配置。 +/// +public sealed class AdminPasswordResetOptions +{ + /// + /// Redis Key 前缀。 + /// + public string Prefix { get; init; } = "identity:admin:pwdreset:"; +} + diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs index ea21444..a79df2f 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs @@ -41,6 +41,15 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde public Task FindByIdAsync(long userId, CancellationToken cancellationToken = default) => dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken); + /// + /// 根据 ID 获取后台用户(用于更新,返回可跟踪实体)。 + /// + /// 用户 ID。 + /// 取消标记。 + /// 后台用户或 null。 + public Task GetForUpdateAsync(long userId, CancellationToken cancellationToken = default) + => dbContext.IdentityUsers.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken); + /// /// 按租户与关键字搜索后台用户(只读)。 /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisAdminPasswordResetTokenStore.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisAdminPasswordResetTokenStore.cs new file mode 100644 index 0000000..187278e --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisAdminPasswordResetTokenStore.cs @@ -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; + +/// +/// Redis 管理后台重置密码链接令牌存储。 +/// +public sealed class RedisAdminPasswordResetTokenStore( + IDistributedCache cache, + IOptions options) + : IAdminPasswordResetTokenStore +{ + private readonly AdminPasswordResetOptions _options = options.Value; + + /// + public async Task 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; + } + + /// + public async Task 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('='); + } +} +