using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using Fake.Application; using Fake.Domain.Exceptions; using Fake.Rbac.Application.Dtos.Auth; using Fake.Rbac.Application.Dtos.Menu; using Fake.Rbac.Application.Dtos.User; using Fake.Rbac.Application.Jwt; using Fake.Rbac.Application.Services; using Fake.Rbac.Domain.Managers; using Fake.Rbac.Domain.UserAggregate; using Fake.Rbac.Infrastructure; using Fake.Users; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Oms.Application.Shared; using Oms.Domain.CustomersAggregate.Entities; using Oms.Domain.CustomersAggregate.Enums; using Oms.Domain.CustomersAggregate.Repositories; using SimpleAdmin.Api.WeChat.Dtos; using SimpleAdmin.Api.WeChat.Services; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Processing; namespace SimpleAdmin.Api.Auth; /// /// 接管 OMS 与 RBAC 共用的认证接口,补充客户登录和业务主体 claims 解析。 /// [Authorize] [ApiExplorerSettings(GroupName = "RBAC")] public partial class AuthService( AccountManager accountManager, UserService userService, IJwtService jwtService, IUserRepository userRepository, IWebHostEnvironment webHostEnvironment, IHttpContextAccessor httpContextAccessor, FakeRbacDbContext rbacDbContext, ICustomerRepository customerRepository, IWeChatLoginSessionStore weChatLoginSessionStore) : ApplicationService { private Guid CurrentUserGuid => CurrentUser.FindClaimValueOrNull(ClaimTypes.NameIdentifier)!.To(); /// /// 用户或客户登录。 /// /// 入参:登录类型、账号和密码。 /// 取消令牌。 /// 登录令牌、刷新令牌和用户基础信息。 [AllowAnonymous] [HttpPost("/rbac/auth/login")] public async Task LoginAsync([FromBody] OmsLoginInput input, CancellationToken cancellationToken = default) { return input.Type switch { OmsLoginType.User => await LoginAdminAsync(input.Account, input.Password, cancellationToken), OmsLoginType.Customer => await LoginCustomerAsync(input.Account, input.Password, cancellationToken), _ => throw new BusinessException("不支持的登录类型") }; } /// /// 管理员代登客户门户(免密)。 /// /// 入参:目标客户主键。 /// 取消令牌。 /// 客户门户访问令牌。 [HttpPost("/rbac/auth/customer-oms-login")] public async Task CustomerOmsLoginAsync( [FromBody] OmsCustomerOmsLoginInput input, CancellationToken cancellationToken = default) { EnsureAdminPrincipal(); var customer = await customerRepository.FirstOrDefaultAsync(x => x.Id == input.CustomerId, cancellationToken); if (customer == null) { throw new BusinessException("客户不存在"); } if (customer.ParentCustomerId.HasValue) { ValidateSubCustomerStatus(customer); var mainCustomer = await customerRepository.FirstOrDefaultAsync( x => x.Id == customer.ParentCustomerId.Value, cancellationToken); if (mainCustomer == null) { throw new BusinessException("主客户不存在"); } ValidateMainCustomerStatus(mainCustomer); } else { ValidateMainCustomerStatus(customer); } var userGuid = await GetRbacUserGuidByMapIdAsync(customer.UserId, cancellationToken); if (!userGuid.HasValue) { throw new BusinessException("客户未关联用户"); } var claims = await BuildClaimsAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken); return BuildLoginResult(userGuid.Value, claims); } /// /// 微信扫码登录轮询状态(key 是否已有绑定账号)。 /// [AllowAnonymous] [HttpGet("/rbac/auth/wx-login/status")] public async Task WxLoginStatusAsync( [FromQuery] string loginUuid, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(loginUuid)) { throw new BusinessException("缺少 loginUuid"); } var session = await weChatLoginSessionStore.GetSessionAsync(loginUuid.Trim(), cancellationToken); if (session == null) { return new WxLoginStatusOutput { Ready = false }; } return new WxLoginStatusOutput { Ready = true, Account = string.IsNullOrWhiteSpace(session.CustomerCode) ? session.OpenId : session.CustomerCode }; } /// /// 微信扫码登录(轮询)。 /// [AllowAnonymous] [HttpPost("/rbac/auth/wx-login")] public async Task WxLoginAsync( [FromBody] OmsWxLoginInput input, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(input.LoginUuid)) { throw new BusinessException("缺少 loginUuid"); } var loginKey = input.LoginUuid.Trim(); var session = await weChatLoginSessionStore.GetSessionAsync(loginKey, cancellationToken); if (session == null) { throw new BusinessException("请扫码登录"); } var customer = await ResolveCustomerFromWxSessionAsync(session, cancellationToken); if (customer == null) { throw new BusinessException("未绑定微信,请先注册或绑定账号"); } if (customer.ParentCustomerId.HasValue) { throw new BusinessException("子客户不支持微信扫码登录"); } ValidateMainCustomerStatus(customer); customer.LastLoginIp = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(); customer.LastLoginTime = DateTime.Now; await customerRepository.UpdateAsync(customer, cancellationToken); var userGuid = await GetRbacUserGuidByMapIdAsync(customer.UserId, cancellationToken); if (!userGuid.HasValue) { throw new BusinessException("客户未关联用户"); } await weChatLoginSessionStore.RemoveAsync(loginKey, cancellationToken); var claims = await BuildClaimsAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken); return BuildLoginResult(userGuid.Value, claims); } private async Task ResolveCustomerFromWxSessionAsync( WeChatLoginSessionPayload session, CancellationToken cancellationToken) { if (session.CustomerId > 0) { return await customerRepository.FirstOrDefaultAsync( x => x.Id == session.CustomerId, cancellationToken: cancellationToken); } if (!string.IsNullOrWhiteSpace(session.OpenId)) { return await customerRepository.GetByWechatOpenidAsync(session.OpenId, cancellationToken); } return null; } /// /// 刷新登录令牌。 /// /// 入参:刷新令牌。 /// 取消令牌。 /// 新的访问令牌、刷新令牌和用户基础信息。 [AllowAnonymous] [HttpPost("/rbac/auth/refresh-token")] public async Task RefreshTokenAsync([FromBody] OmsRefreshTokenInput input, CancellationToken cancellationToken = default) { var refreshToken = input.RefreshToken; var userIdText = jwtService.ValidateRefreshToken(refreshToken); if (string.IsNullOrWhiteSpace(userIdText)) { throw new BusinessException("无效的刷新令牌"); } var token = new JwtSecurityTokenHandler().ReadJwtToken(refreshToken); var loginType = ParseLoginType(token.Claims); if (loginType == OmsLoginType.Customer) { return await RefreshCustomerTokenAsync(token.Claims, cancellationToken); } if (!Guid.TryParse(userIdText, out var userId)) { throw new BusinessException("无效的刷新令牌"); } var user = await userRepository.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken: cancellationToken); if (user == null) { throw new BusinessException("用户不存在"); } var claims = await BuildAdminClaimsAsync(user.Id, cancellationToken); return BuildLoginResult(user.Id, claims); } /// /// 获取当前登录用户信息。 /// /// 取消令牌。 /// 当前登录用户信息、角色和菜单权限。 [HttpGet("/rbac/auth/get-current-user")] public async Task GetCurrentUserAsync(CancellationToken cancellationToken = default) { if (!CurrentUser.IsAuthenticated) { throw new UnauthorizedAccessException("用户未登录"); } if (IsCustomerPrincipal()) { return await BuildAdminUserInfoAsync(CurrentUserGuid, cancellationToken); } return await BuildAdminUserInfoAsync(CurrentUserGuid, cancellationToken); } /// /// 修改当前登录账号密码。 /// /// 入参:旧密码和新密码。 /// 取消令牌。 /// 无返回体,仅完成密码修改。 [HttpPost("/rbac/auth/change-password")] public async Task ChangePasswordAsync([FromBody] OmsChangePasswordInput input, CancellationToken cancellationToken = default) { if (IsCustomerPrincipal()) { await ChangeCustomerPasswordAsync(input.OldPassword, input.NewPassword, cancellationToken); return; } await accountManager.UpdatePasswordAsync(CurrentUserGuid, input.OldPassword, input.NewPassword, cancellationToken); } /// /// 更新当前管理员个人资料。 /// /// 入参:姓名和邮箱等个人资料。 /// 取消令牌。 /// 更新后的当前用户信息。 [HttpPut("/rbac/auth/update-profile")] public async Task UpdateProfileAsync([FromBody] OmsUpdateProfileInput input, CancellationToken cancellationToken = default) { EnsureAdminPrincipal(); var userId = CurrentUserGuid; var user = await userRepository.FirstAsync(u => u.Id == userId, cancellationToken: cancellationToken); user.Update(input.Name, input.Email); await userRepository.UpdateAsync(user, cancellationToken: cancellationToken); await UnitOfWorkManager.Current!.SaveChangesAsync(cancellationToken); return await BuildAdminUserInfoAsync(userId, cancellationToken); } /// /// 上传当前管理员头像。 /// /// 入参:头像文件。 /// 取消令牌。 /// 头像访问路径。 [HttpPost("/rbac/auth/upload-avatar")] public async Task UploadAvatarAsync(IFormFile file, CancellationToken cancellationToken = default) { EnsureAdminPrincipal(); var userId = CurrentUserGuid; if (file == null || file.Length == 0) { throw new DomainException("请选择要上传的文件"); } var allowedExtensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp" }; var extension = Path.GetExtension(file.FileName).ToLowerInvariant(); if (!allowedExtensions.Contains(extension)) { throw new DomainException("只支持 jpg、jpeg、png、gif、webp 格式的图片"); } if (file.Length > 10 * 1024 * 1024) { throw new DomainException("文件大小不能超过10MB"); } var uploadPath = Path.Combine(webHostEnvironment.WebRootPath ?? webHostEnvironment.ContentRootPath, "uploads", "avatars"); Directory.CreateDirectory(uploadPath); var fileName = $"{userId}_{DateTime.Now:yyyyMMddHHmmss}{extension}"; var filePath = Path.Combine(uploadPath, fileName); using (var stream = file.OpenReadStream()) using (var image = await Image.LoadAsync(stream, cancellationToken)) { var maxSize = 200; var width = image.Width; var height = image.Height; if (width > maxSize || height > maxSize) { if (width > height) { height = (int)(height * ((float)maxSize / width)); width = maxSize; } else { width = (int)(width * ((float)maxSize / height)); height = maxSize; } } image.Mutate(x => x.Resize(width, height)); await image.SaveAsJpegAsync(filePath, new SixLabors.ImageSharp.Formats.Jpeg.JpegEncoder { Quality = 80 }, cancellationToken); } var avatarUrl = $"/uploads/avatars/{fileName}"; var user = await userRepository.FirstAsync(u => u.Id == userId, cancellationToken: cancellationToken); user.UpdateAvatar(avatarUrl); await userRepository.UpdateAsync(user, cancellationToken: cancellationToken); await UnitOfWorkManager.Current!.SaveChangesAsync(cancellationToken); return avatarUrl; } private async Task LoginAdminAsync(string account, string password, CancellationToken cancellationToken) { var user = await accountManager.ValidateCredentialsAsync(account, password, cancellationToken); var claims = await BuildAdminClaimsAsync(user.Id, cancellationToken); return BuildLoginResult(user.Id, claims); } private async Task LoginCustomerAsync(string account, string password, CancellationToken cancellationToken) { var normalizedAccount = account.Trim(); var customer = await FindCustomerByAccountAsync(normalizedAccount, cancellationToken); if (customer == null) { throw new BusinessException("客户账号或密码错误"); } if (customer.ParentCustomerId.HasValue) { ValidateSubCustomerStatus(customer); var mainCustomer = await customerRepository.FirstOrDefaultAsync(x => x.Id == customer.ParentCustomerId.Value, cancellationToken); if (mainCustomer == null) { throw new BusinessException("主客户不存在"); } ValidateMainCustomerStatus(mainCustomer); } else { ValidateMainCustomerStatus(customer); } if (!string.Equals(customer.Password, OmsPasswordHelper.ToMd5(password), StringComparison.OrdinalIgnoreCase)) { throw new BusinessException("客户账号或密码错误"); } customer.LastLoginIp = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(); customer.LastLoginTime = DateTime.Now; await customerRepository.UpdateAsync(customer, cancellationToken); var userGuid = await GetRbacUserGuidByMapIdAsync(customer.UserId, cancellationToken); if (!userGuid.HasValue) { throw new BusinessException("客户未关联用户"); } var claims = await BuildClaimsAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken); return BuildLoginResult(userGuid.Value, claims); } private async Task RefreshCustomerTokenAsync(IEnumerable claims, CancellationToken cancellationToken) { var mapId = ParseIntClaim(claims, SimpleOmsClaimTypes.MapId); if (mapId <= 0) { throw new BusinessException("无效的刷新令牌"); } var userGuid = await GetRbacUserGuidByMapIdAsync(mapId, cancellationToken); if (!userGuid.HasValue) { throw new BusinessException("用户不存在"); } var refreshedClaims = await BuildClaimsAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken); return BuildLoginResult(userGuid.Value, refreshedClaims); } private async Task BuildAdminUserInfoAsync(Guid userId, CancellationToken cancellationToken) { var userInfo = await userService.GetAsync(userId, cancellationToken); var permissions = await userService.GetUserPermissionsAsync(userId, cancellationToken); return new UserInfoDto { Id = userInfo.Id, Name = userInfo.Name, Account = userInfo.Account, Email = userInfo.Email, Avatar = userInfo.Avatar, OrganizationId = userInfo.OrganizationId, OrganizationName = userInfo.OrganizationName, Roles = userInfo.Roles, Permissions = permissions, Menus = await BuildMenusByPermissionsAsync(permissions, cancellationToken), CreatedAt = userInfo.CreatedAt, CreatedBy = userInfo.CreatedBy, UpdatedAt = userInfo.UpdatedAt, UpdatedBy = userInfo.UpdatedBy }; } private async Task ChangeCustomerPasswordAsync(string oldPassword, string newPassword, CancellationToken cancellationToken) { accountManager.ValidatePasswordStrength(newPassword); var context = await GetCurrentCustomerContextAsync(cancellationToken); if (!string.Equals(context.Customer.Password, OmsPasswordHelper.ToMd5(oldPassword), StringComparison.OrdinalIgnoreCase)) { throw new BusinessException("旧密码不正确"); } context.Customer.Password = OmsPasswordHelper.ToMd5(newPassword); await customerRepository.UpdateAsync(context.Customer, cancellationToken); } private async Task> BuildAdminClaimsAsync(Guid userId, CancellationToken cancellationToken) { return await BuildClaimsAsync(userId, OmsLoginType.User, cancellationToken); } private async Task> BuildClaimsAsync(Guid userId, OmsLoginType loginType, CancellationToken cancellationToken) { var claims = await jwtService.GenerateClaimsByUserIdAsync(userId, cancellationToken); ReplaceOrAppendClaim(claims, SimpleOmsClaimTypes.LoginType, ((int)loginType).ToString()); return claims; } private LoginResultDto BuildLoginResult(Guid userId, IReadOnlyCollection claims) { var accessClaims = CloneClaims(claims); var refreshClaims = CloneClaims(claims); return new LoginResultDto { AccessToken = jwtService.GenerateAccessToken(accessClaims), RefreshToken = jwtService.GenerateRefreshToken(refreshClaims), TokenType = "Bearer", ExpiresIn = jwtService.GetExpiresInSeconds(), UserId = userId }; } private static List CloneClaims(IEnumerable claims) { return claims.Select(x => new Claim(x.Type, x.Value, x.ValueType, x.Issuer, x.OriginalIssuer)).ToList(); } private bool IsCustomerPrincipal() { return CurrentUser.FindClaimOrNull(SimpleOmsClaimTypes.LoginType)?.Value == ((int)OmsLoginType.Customer).ToString(); } private void EnsureAdminPrincipal() { if (IsCustomerPrincipal()) { throw new BusinessException("当前登录主体暂不支持此操作"); } } private async Task GetCurrentCustomerContextAsync(CancellationToken cancellationToken) { var userId = CurrentUser.GetId(); if (userId <= 0) { throw new UnauthorizedAccessException("客户未登录"); } var customer = await customerRepository.FirstOrDefaultAsync(x => x.UserId == userId, cancellationToken); if (customer == null) { throw new BusinessException("客户不存在"); } if (customer.ParentCustomerId.HasValue) { ValidateSubCustomerStatus(customer); var mainCustomer = await customerRepository.FirstOrDefaultAsync(x => x.Id == customer.ParentCustomerId.Value, cancellationToken); if (mainCustomer == null) { throw new BusinessException("主客户不存在"); } ValidateMainCustomerStatus(mainCustomer); } else { ValidateMainCustomerStatus(customer); } return new CustomerPrincipalContext { PrincipalId = CurrentUserGuid, Customer = customer, DisplayName = customer.WechatNickname ?? customer.CustomerName, AccountCode = customer.CustomerCode, Email = customer.Email, Avatar = customer.WechatAvatar, CreateTime = customer.CreateTime, UpdateTime = customer.UpdateTime }; } private async Task FindCustomerByAccountAsync(string account, CancellationToken cancellationToken) { var customer = await customerRepository.GetByPhoneAsync(account, cancellationToken); if (customer != null) { return customer; } customer = await customerRepository.GetByEmailAsync(account, cancellationToken); if (customer != null) { return customer; } return await customerRepository.GetByCustomerCodeAsync(account, cancellationToken); } private async Task> BuildMenusByPermissionsAsync(IReadOnlyCollection permissionCodes, CancellationToken cancellationToken) { if (permissionCodes.Count == 0) { return []; } var allMenus = await rbacDbContext.Menus .Where(x => !x.IsDeleted) .OrderBy(x => x.Order) .ToListAsync(cancellationToken); var menuMap = allMenus.ToDictionary(x => x.Id); var selectedMenuIds = new HashSet(allMenus .Where(x => !string.IsNullOrWhiteSpace(x.PermissionCode) && permissionCodes.Contains(x.PermissionCode, StringComparer.OrdinalIgnoreCase)) .Select(x => x.Id)); var queue = new Queue(selectedMenuIds); while (queue.Count > 0) { var menuId = queue.Dequeue(); if (!menuMap.TryGetValue(menuId, out var currentMenu)) { continue; } if (currentMenu.PId != Guid.Empty && selectedMenuIds.Add(currentMenu.PId)) { queue.Enqueue(currentMenu.PId); } } var menuDtos = allMenus .Where(x => selectedMenuIds.Contains(x.Id)) .OrderBy(x => x.Order) .ThenBy(x => x.CreateTime) .Select(x => new MenuTreeDto { Id = x.Id, PId = x.PId, Name = x.Name, PermissionCode = x.PermissionCode, Type = x.Type, Icon = x.Icon, Route = x.Route, Component = x.Component, Order = x.Order, IsHidden = x.IsHidden, IsCached = x.IsCached, Description = x.Description, CreatedAt = x.CreateTime, UpdatedAt = x.UpdateTime == default ? null : x.UpdateTime }) .ToList(); var dtoMap = menuDtos.ToDictionary(x => x.Id); var roots = new List(); foreach (var menu in menuDtos) { if (menu.PId == Guid.Empty || !dtoMap.TryGetValue(menu.PId, out var parentMenu)) { roots.Add(menu); continue; } parentMenu.Children.Add(menu); } return roots.OrderBy(x => x.Order).ToList(); } private static void ValidateMainCustomerStatus(Customer customer) { if (customer.Status == CustomerStatus.Disabled) { throw new BusinessException("客户已禁用"); } if (customer.Status == CustomerStatus.Frozen) { throw new BusinessException("客户已冻结"); } } private static void ValidateSubCustomerStatus(Customer customer) { if (customer.Status == CustomerStatus.Disabled) { throw new BusinessException("子客户已禁用"); } if (customer.Status == CustomerStatus.Frozen) { throw new BusinessException("子客户已冻结"); } } private static OmsLoginType ParseLoginType(IEnumerable claims) { return int.TryParse(claims.FirstOrDefault(x => x.Type == SimpleOmsClaimTypes.LoginType)?.Value, out var loginType) && Enum.IsDefined(typeof(OmsLoginType), loginType) ? (OmsLoginType)loginType : OmsLoginType.User; } private async Task GetRbacUserGuidByMapIdAsync(int userId, CancellationToken cancellationToken) { return await rbacDbContext.Database.SqlQuery( $"select `Id` as `Value` from `user` where `IsDeleted` = 0 and `userid` = {userId}") .FirstOrDefaultAsync(cancellationToken); } private static int ParseIntClaim(IEnumerable claims, string claimType) { return int.TryParse(claims.FirstOrDefault(x => x.Type == claimType)?.Value, out var value) ? value : 0; } private static void ReplaceOrAppendClaim(List claims, string claimType, string value) { claims.RemoveAll(x => x.Type == claimType); claims.Add(new Claim(claimType, value)); } private sealed class CustomerPrincipalContext { public Guid PrincipalId { get; set; } public required Customer Customer { get; init; } public required string DisplayName { get; init; } public required string AccountCode { get; init; } public string? Email { get; init; } public string? Avatar { get; init; } public DateTime CreateTime { get; init; } public DateTime UpdateTime { get; init; } } }