Files
TakeoutSaaS.AdminApi/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs

563 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.AspNetCore.Identity;
using TakeoutSaaS.Application.Identity.Abstractions;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Services;
/// <summary>
/// 管理后台认证服务实现。
/// </summary>
public sealed class AdminAuthService(
IIdentityUserRepository userRepository,
IUserRoleRepository userRoleRepository,
IRoleRepository roleRepository,
IPermissionRepository permissionRepository,
IRolePermissionRepository rolePermissionRepository,
IMenuRepository menuRepository,
IPasswordHasher<IdentityUser> passwordHasher,
IJwtTokenService jwtTokenService,
IRefreshTokenStore refreshTokenStore,
ITenantProvider tenantProvider) : IAdminAuthService
{
/// <summary>
/// 管理后台登录:验证账号密码并生成令牌。
/// </summary>
/// <param name="request">登录请求</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>令牌响应</returns>
/// <exception cref="BusinessException">账号或密码错误时抛出</exception>
public async Task<TokenResponse> LoginAsync(AdminLoginRequest request, CancellationToken cancellationToken = default)
{
// 1. 根据账号查找用户
var user = await userRepository.FindByAccountAsync(PortalType.Admin, null, request.Account, cancellationToken)
?? throw new BusinessException(ErrorCodes.Unauthorized, "账号或密码错误");
// 2. 校验账号状态
var now = DateTime.UtcNow;
if (user.Status == IdentityUserStatus.Disabled)
{
throw new BusinessException(ErrorCodes.Forbidden, "账号已被禁用,请联系管理员");
}
if (user.Status == IdentityUserStatus.Locked)
{
if (user.LockedUntil.HasValue && user.LockedUntil.Value <= now)
{
await ResetLockedUserAsync(user.Id, cancellationToken);
}
else
{
throw new BusinessException(ErrorCodes.Forbidden, "账号已被锁定,请稍后再试");
}
}
if (user.MustChangePassword)
{
throw new BusinessException(ErrorCodes.Forbidden, "账号需要重置密码,请通过重置链接设置新密码");
}
// 3. 验证密码(使用 ASP.NET Core Identity 的密码哈希器)
var result = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, request.Password);
if (result == PasswordVerificationResult.Failed)
{
await IncreaseFailedLoginAsync(user.Id, now, cancellationToken);
throw new BusinessException(ErrorCodes.Unauthorized, "账号或密码错误");
}
// 4. 更新登录成功状态
await UpdateLoginSuccessAsync(user.Id, now, cancellationToken);
// 5. 构建用户档案并生成令牌
var profile = await BuildProfileAsync(user, cancellationToken);
return await jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
}
/// <summary>
/// 简化登录与标准登录一致Admin Portal
/// </summary>
/// <param name="request">登录请求</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>令牌响应</returns>
public async Task<TokenResponse> LoginSimpleAsync(AdminLoginRequest request, CancellationToken cancellationToken = default)
{
// 1. 参数校验
if (string.IsNullOrWhiteSpace(request.Account))
{
throw new BusinessException(ErrorCodes.BadRequest, "账号不能为空");
}
// 2. (空行后) 标准化账号
request.Account = request.Account.Trim();
// 3. (空行后) 走标准登录逻辑Admin Portal
return await LoginAsync(request, cancellationToken);
}
/// <summary>
/// 刷新访问令牌:使用刷新令牌获取新的访问令牌和刷新令牌。
/// </summary>
/// <param name="request">刷新令牌请求</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>新的令牌响应</returns>
/// <exception cref="BusinessException">刷新令牌无效、已过期或用户不存在时抛出</exception>
public async Task<TokenResponse> RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default)
{
// 1. 验证刷新令牌(检查是否存在、是否过期、是否已撤销)
var descriptor = await refreshTokenStore.GetAsync(request.RefreshToken, cancellationToken);
if (descriptor == null || descriptor.ExpiresAt <= DateTime.UtcNow || descriptor.Revoked)
{
throw new BusinessException(ErrorCodes.Unauthorized, "RefreshToken 无效或已过期");
}
// 2. 根据用户 ID 查找用户
var user = await userRepository.FindByIdAsync(descriptor.UserId, cancellationToken)
?? throw new BusinessException(ErrorCodes.Unauthorized, "用户不存在");
// 3. 撤销旧刷新令牌(防止重复使用)
await refreshTokenStore.RevokeAsync(descriptor.Token, cancellationToken);
// 4. 生成新的令牌对
var profile = await BuildProfileAsync(user, cancellationToken);
return await jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
}
/// <summary>
/// 获取用户档案。
/// </summary>
/// <param name="userId">用户 ID</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>用户档案</returns>
/// <exception cref="BusinessException">用户不存在时抛出</exception>
public async Task<CurrentUserProfile> GetProfileAsync(long userId, CancellationToken cancellationToken = default)
{
var user = await userRepository.FindByIdAsync(userId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
// 1. 返回档案
return await BuildProfileAsync(user, cancellationToken);
}
/// <summary>
/// 获取当前用户可见菜单树。
/// </summary>
/// <param name="userId">用户 ID。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>菜单树。</returns>
public async Task<IReadOnlyList<MenuNodeDto>> GetMenuTreeAsync(long userId, CancellationToken cancellationToken = default)
{
// 1. 读取档案以获取权限
var profile = await GetProfileAsync(userId, cancellationToken);
// 2. 读取菜单定义
var definitions = await menuRepository.GetByPortalAsync(profile.Portal, cancellationToken);
// 3. 生成菜单树
var menu = BuildMenuTree(definitions, profile.Permissions);
return menu;
}
/// <summary>
/// 获取指定用户的权限概览(校验当前租户)。
/// </summary>
public async Task<UserPermissionDto?> GetUserPermissionsAsync(long userId, CancellationToken cancellationToken = default)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var user = await userRepository.FindByIdAsync(userId, cancellationToken);
if (user == null || user.TenantId != tenantId)
{
return null;
}
// 1. 解析角色集合
var roleCodes = await ResolveUserRolesAsync(user.Portal, user.TenantId, user.Id, cancellationToken);
// 2. (空行后) 解析权限集合
var permissionCodes = await ResolveUserPermissionsAsync(user.Portal, user.TenantId, user.Id, cancellationToken);
// 3. (空行后) 返回概览
return new UserPermissionDto
{
UserId = user.Id,
TenantId = user.TenantId,
MerchantId = user.MerchantId,
Account = user.Account,
DisplayName = user.DisplayName,
Roles = roleCodes,
Permissions = permissionCodes,
CreatedAt = user.CreatedAt
};
}
/// <summary>
/// 按租户分页查询用户权限概览。
/// </summary>
public async Task<PagedResult<UserPermissionDto>> SearchUserPermissionsAsync(
string? keyword,
int page,
int pageSize,
string? sortBy,
bool sortDescending,
CancellationToken cancellationToken = default)
{
// 1. 获取当前租户
var tenantId = tenantProvider.GetCurrentTenantId();
// 2. (空行后) 查询用户列表
var users = await userRepository.SearchAsync(tenantId, keyword, cancellationToken);
// 3. (空行后) 排序
var sorted = sortBy?.ToLowerInvariant() switch
{
"account" => sortDescending
? users.OrderByDescending(x => x.Account)
: users.OrderBy(x => x.Account),
"displayname" => sortDescending
? users.OrderByDescending(x => x.DisplayName)
: users.OrderBy(x => x.DisplayName),
_ => sortDescending
? users.OrderByDescending(x => x.CreatedAt)
: users.OrderBy(x => x.CreatedAt)
};
// 4. (空行后) 分页
var paged = sorted
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToList();
// 5. (空行后) 解析角色与权限
var resolved = await ResolveRolesAndPermissionsAsync(PortalType.Tenant, tenantId, paged, cancellationToken);
// 6. (空行后) 映射为 DTO
var items = paged.Select(user => new UserPermissionDto
{
UserId = user.Id,
TenantId = user.TenantId,
MerchantId = user.MerchantId,
Account = user.Account,
DisplayName = user.DisplayName,
Roles = resolved[user.Id].roles,
Permissions = resolved[user.Id].permissions,
CreatedAt = user.CreatedAt
}).ToList();
// 7. (空行后) 返回分页结果
return new PagedResult<UserPermissionDto>(items, page, pageSize, users.Count);
}
private async Task<CurrentUserProfile> BuildProfileAsync(IdentityUser user, CancellationToken cancellationToken)
{
// 1. 解析角色集合
var roles = await ResolveUserRolesAsync(user.Portal, user.TenantId, user.Id, cancellationToken);
// 2. (空行后) 解析权限集合
var permissions = await ResolveUserPermissionsAsync(user.Portal, user.TenantId, user.Id, cancellationToken);
// 3. (空行后) 组装档案
return new CurrentUserProfile
{
Portal = user.Portal,
UserId = user.Id,
Account = user.Account,
DisplayName = user.DisplayName,
TenantId = user.TenantId,
MerchantId = user.MerchantId,
Roles = roles,
Permissions = permissions,
Avatar = user.Avatar
};
}
private async Task ResetLockedUserAsync(long userId, CancellationToken cancellationToken)
{
// 1. 获取可更新实体
var tracked = await userRepository.GetForUpdateAsync(userId, cancellationToken);
if (tracked == null)
{
return;
}
// 2. 解除锁定并清空失败次数
tracked.Status = IdentityUserStatus.Active;
tracked.LockedUntil = null;
tracked.FailedLoginCount = 0;
// 3. 保存变更
await userRepository.SaveChangesAsync(cancellationToken);
}
private async Task IncreaseFailedLoginAsync(long userId, DateTime now, CancellationToken cancellationToken)
{
// 1. 获取可更新实体
var tracked = await userRepository.GetForUpdateAsync(userId, cancellationToken);
if (tracked == null)
{
return;
}
// 2. 累计失败次数并判断是否需要锁定
tracked.FailedLoginCount += 1;
if (tracked.FailedLoginCount >= 5)
{
tracked.Status = IdentityUserStatus.Locked;
tracked.LockedUntil = now.AddMinutes(15);
}
// 3. 保存变更
await userRepository.SaveChangesAsync(cancellationToken);
}
private async Task UpdateLoginSuccessAsync(long userId, DateTime now, CancellationToken cancellationToken)
{
// 1. 获取可更新实体
var tracked = await userRepository.GetForUpdateAsync(userId, cancellationToken);
if (tracked == null)
{
return;
}
// 2. 重置失败次数并刷新登录时间
tracked.FailedLoginCount = 0;
tracked.LockedUntil = null;
tracked.LastLoginAt = now;
if (tracked.Status == IdentityUserStatus.Locked)
{
tracked.Status = IdentityUserStatus.Active;
}
// 3. 保存变更
await userRepository.SaveChangesAsync(cancellationToken);
}
private static bool IsLikelyPhone(string value)
{
// 1. 空值快速返回
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
// 2. 标准化前缀
var span = value.AsSpan();
if (span[0] == '+')
{
span = span[1..];
}
// 3. 长度校验
if (span.Length < 6 || span.Length > 32)
{
return false;
}
// 4. 逐字符校验
foreach (var ch in span)
{
if (!char.IsDigit(ch))
{
return false;
}
}
// 5. 返回校验结果
return true;
}
private static IReadOnlyList<MenuNodeDto> BuildMenuTree(
IReadOnlyList<Domain.Identity.Entities.MenuDefinition> definitions,
IReadOnlyList<string> permissions)
{
// 1. 权限集合
var permissionSet = new HashSet<string>(permissions, StringComparer.OrdinalIgnoreCase);
// 2. 父子分组
var parentLookup = definitions
.GroupBy(x => x.ParentId)
.ToDictionary(g => g.Key, g => g.OrderBy(d => d.SortOrder).ToList());
// 3. 递归构造并过滤
IReadOnlyList<MenuNodeDto> BuildChildren(long parentId)
{
if (!parentLookup.TryGetValue(parentId, out var children))
{
return [];
}
// 1. 收集可见节点
var result = new List<MenuNodeDto>();
foreach (var def in children)
{
// 1.1 构造节点
var node = MapMenuNode(def);
// 1.2 递归子节点
var sub = BuildChildren(def.Id);
// 1.3 可见性(严格使用 RequiredPermissions不做兜底
var requiredPermissions = node.RequiredPermissions;
// 1.4 (空行后) 判断可见性
var isVisible = requiredPermissions.Length == 0 || requiredPermissions.Any(permissionSet.Contains);
// 1.5 (空行后) 收集
if (isVisible || sub.Count > 0)
{
result.Add(node with { Children = sub });
}
}
// 2. 返回本层节点
return result;
}
// 4. 返回根节点
return BuildChildren(0);
}
private static MenuNodeDto MapMenuNode(Domain.Identity.Entities.MenuDefinition definition)
{
// 1. 解析权限
var requiredPermissions = SplitCodes(definition.RequiredPermissions);
var metaPermissions = SplitCodes(definition.MetaPermissions);
var metaRoles = SplitCodes(definition.MetaRoles);
// 2. 解析按钮权限
var authList = string.IsNullOrWhiteSpace(definition.AuthListJson)
? []
: System.Text.Json.JsonSerializer.Deserialize<List<MenuAuthItemDto>>(definition.AuthListJson) ?? [];
// 3. 构造节点
return new MenuNodeDto
{
Name = definition.Name,
Path = definition.Path,
Component = definition.Component,
Meta = new MenuMetaDto
{
Title = definition.Title,
Icon = definition.Icon,
KeepAlive = definition.KeepAlive,
IsIframe = definition.IsIframe,
Link = definition.Link,
Roles = metaRoles,
Permissions = metaPermissions,
AuthList = authList
},
Children = [],
RequiredPermissions = requiredPermissions
};
}
private static string[] SplitCodes(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return Array.Empty<string>();
}
// 1. 拆分去重
return value
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private async Task<string[]> ResolveUserRolesAsync(PortalType portal, long? tenantId, long userId, CancellationToken cancellationToken)
{
var relations = await userRoleRepository.GetByUserIdAsync(portal, tenantId, userId, cancellationToken);
var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray();
if (roleIds.Length == 0)
{
return Array.Empty<string>();
}
// 2. (空行后) 查询角色定义并返回角色码
var roles = await roleRepository.GetByIdsAsync(portal, tenantId, roleIds, cancellationToken);
return roles.Select(x => x.Code).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
private async Task<string[]> ResolveUserPermissionsAsync(PortalType portal, long? tenantId, long userId, CancellationToken cancellationToken)
{
var relations = await userRoleRepository.GetByUserIdAsync(portal, tenantId, userId, cancellationToken);
var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray();
if (roleIds.Length == 0)
{
return Array.Empty<string>();
}
// 2. (空行后) 查询角色权限关联
var rolePermissions = await rolePermissionRepository.GetByRoleIdsAsync(portal, tenantId, roleIds, cancellationToken);
var permissionIds = rolePermissions.Select(x => x.PermissionId).Distinct().ToArray();
if (permissionIds.Length == 0)
{
return Array.Empty<string>();
}
// 3. (空行后) 查询权限定义并返回权限码
var permissions = await permissionRepository.GetByIdsAsync(permissionIds, cancellationToken);
return permissions.Select(x => x.Code).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
private async Task<Dictionary<long, (string[] roles, string[] permissions)>> ResolveRolesAndPermissionsAsync(
PortalType portal,
long? tenantId,
IReadOnlyCollection<IdentityUser> users,
CancellationToken cancellationToken)
{
// 1. 读取用户-角色关系
var userIds = users.Select(x => x.Id).ToArray();
var userRoleRelations = await userRoleRepository.GetByUserIdsAsync(portal, tenantId, userIds, cancellationToken);
var roleIds = userRoleRelations.Select(x => x.RoleId).Distinct().ToArray();
// 2. (空行后) 读取角色定义
var roles = roleIds.Length == 0
? Array.Empty<Role>()
: await roleRepository.GetByIdsAsync(portal, tenantId, roleIds, cancellationToken);
var roleCodeMap = roles.ToDictionary(r => r.Id, r => r.Code, comparer: EqualityComparer<long>.Default);
// 3. (空行后) 读取角色-权限关系
var rolePermissions = roleIds.Length == 0
? Array.Empty<RolePermission>()
: await rolePermissionRepository.GetByRoleIdsAsync(portal, tenantId, roleIds, cancellationToken);
// 4. (空行后) 读取权限定义
var permissionIds = rolePermissions.Select(x => x.PermissionId).Distinct().ToArray();
var permissions = permissionIds.Length == 0
? Array.Empty<Permission>()
: await permissionRepository.GetByIdsAsync(permissionIds, cancellationToken);
var permissionCodeMap = permissions.ToDictionary(p => p.Id, p => p.Code, comparer: EqualityComparer<long>.Default);
// 5. (空行后) 构建 Role -> PermissionId[] 映射
var rolePermissionsLookup = rolePermissions
.GroupBy(rp => rp.RoleId)
.ToDictionary(g => g.Key, g => g.Select(rp => rp.PermissionId).ToArray(), comparer: EqualityComparer<long>.Default);
// 6. (空行后) 按用户聚合角色码与权限码
var result = new Dictionary<long, (string[] roles, string[] permissions)>();
foreach (var userId in userIds)
{
// 6.1 解析用户角色码
var rolesForUser = userRoleRelations.Where(ur => ur.UserId == userId).Select(ur => ur.RoleId).Distinct().ToArray();
var roleCodes = rolesForUser
.Select(rid => roleCodeMap.GetValueOrDefault(rid))
.Where(c => !string.IsNullOrWhiteSpace(c))
.Select(c => c!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
// 6.2 (空行后) 解析用户权限码
var permissionCodes = rolesForUser
.SelectMany(rid => rolePermissionsLookup.GetValueOrDefault(rid) ?? Array.Empty<long>())
.Select(pid => permissionCodeMap.GetValueOrDefault(pid))
.Where(code => !string.IsNullOrWhiteSpace(code))
.Select(code => code!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
// 6.3 (空行后) 写入结果
result[userId] = (roleCodes, permissionCodes);
}
// 7. (空行后) 返回聚合结果
return result;
}
}