diff --git a/src/SimpleAdmin.Api/Auth/AuthService.cs b/src/SimpleAdmin.Api/Auth/AuthService.cs index 7d145c8..f716464 100644 --- a/src/SimpleAdmin.Api/Auth/AuthService.cs +++ b/src/SimpleAdmin.Api/Auth/AuthService.cs @@ -4,12 +4,13 @@ using Fake.Application; using Fake.Domain.Exceptions; using Fake.Rbac.Application.Dtos.Auth; using Fake.Rbac.Application.Dtos.Menu; +using Fake.Rbac.Application.Dtos.Role; 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.Security.Claims; using Fake.Users; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -33,7 +34,6 @@ namespace SimpleAdmin.Api.Auth; [ApiExplorerSettings(GroupName = "RBAC")] public partial class AuthService( AccountManager accountManager, - UserService userService, IJwtService jwtService, IUserRepository userRepository, IWebHostEnvironment webHostEnvironment, @@ -41,16 +41,15 @@ public partial class AuthService( FakeRbacDbContext rbacDbContext, ICustomerRepository customerRepository, IWeChatLoginSessionStore weChatLoginSessionStore, - IMemoryCache memoryCache) + IMemoryCache memoryCache, + IRbacAuthCache rbacAuthCache) : ApplicationService { - private const string AllMenusCacheKey = "rbac:menus:all:v1"; - private const string UserInfoCacheKeyPrefix = "rbac:userinfo:v2:"; private static readonly TimeSpan UserInfoCacheTtl = TimeSpan.FromMinutes(3); + private static readonly TimeSpan AllMenusCacheTtl = TimeSpan.FromMinutes(5); private Guid CurrentUserGuid => CurrentUser.FindClaimValueOrNull(ClaimTypes.NameIdentifier)!.To(); - private static string UserInfoCacheKey(Guid userId) => UserInfoCacheKeyPrefix + userId; - + /// /// 用户或客户登录。 /// @@ -112,8 +111,7 @@ public partial class AuthService( throw new BusinessException("客户未关联用户"); } - var claims = await BuildClaimsAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken); - return await BuildLoginResultAsync(userGuid.Value, claims, cancellationToken); + return await BuildLoginResultAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken); } /// @@ -189,8 +187,7 @@ public partial class AuthService( await weChatLoginSessionStore.RemoveAsync(loginKey, cancellationToken); - var claims = await BuildClaimsAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken); - return await BuildLoginResultAsync(userGuid.Value, claims, cancellationToken); + return await BuildLoginResultAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken); } private async Task ResolveCustomerFromWxSessionAsync( @@ -248,8 +245,9 @@ public partial class AuthService( throw new BusinessException("用户不存在"); } - var claims = await BuildAdminClaimsAsync(user.Id, cancellationToken); - return await BuildLoginResultAsync(user.Id, claims, cancellationToken); + // 刷新令牌只发新 token,不重建 UserInfo(前端忽略该字段) + var claims = ReuseClaimsFromRefreshToken(token.Claims, OmsLoginType.User); + return BuildTokenOnlyResult(user.Id, claims); } /// @@ -386,8 +384,7 @@ public partial class AuthService( 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 await BuildLoginResultAsync(user.Id, claims, cancellationToken); + return await BuildLoginResultAsync(user.Id, OmsLoginType.User, cancellationToken); } private async Task LoginCustomerAsync(string account, string password, CancellationToken cancellationToken) @@ -430,8 +427,7 @@ public partial class AuthService( throw new BusinessException("客户未关联用户"); } - var claims = await BuildClaimsAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken); - return await BuildLoginResultAsync(userGuid.Value, claims, cancellationToken); + return await BuildLoginResultAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken); } private async Task RefreshCustomerTokenAsync(IEnumerable claims, CancellationToken cancellationToken) @@ -448,39 +444,77 @@ public partial class AuthService( throw new BusinessException("用户不存在"); } - var refreshedClaims = await BuildClaimsAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken); - return await BuildLoginResultAsync(userGuid.Value, refreshedClaims, cancellationToken); + var refreshedClaims = ReuseClaimsFromRefreshToken(claims, OmsLoginType.Customer); + return BuildTokenOnlyResult(userGuid.Value, refreshedClaims); } private async Task BuildAdminUserInfoAsync(Guid userId, CancellationToken cancellationToken) { - var cacheKey = UserInfoCacheKey(userId); + var cacheKey = rbacAuthCache.UserInfoKey(userId); if (memoryCache.TryGetValue(cacheKey, out UserInfoDto? cached) && cached != null) { return cached; } - // 同一 scoped DbContext 不能并行查询,必须串行 - var userInfo = await userService.GetAsync(userId, cancellationToken); - var permissions = await userService.GetUserPermissionsAsync(userId, cancellationToken); + // 一次加载:用户 + 角色 + 权限,避免 Fake UserService 多次 GetWithRoles / GetRolesWithPermissions + var user = await rbacDbContext.Users + .AsNoTracking() + .Include(x => x.Roles) + .FirstOrDefaultAsync(x => x.Id == userId && !x.IsDeleted, cancellationToken); + if (user == null) + { + throw new BusinessException("用户不存在"); + } + + var roleIds = user.Roles.Select(x => x.RoleId).Distinct().ToList(); + var roles = roleIds.Count == 0 + ? [] + : await rbacDbContext.Roles + .AsNoTracking() + .Include(x => x.Permissions) + .Where(x => roleIds.Contains(x.Id) && !x.IsDeleted) + .ToListAsync(cancellationToken); + + string? organizationName = null; + if (user.OrganizationId.HasValue) + { + organizationName = await rbacDbContext.Organizations + .AsNoTracking() + .Where(x => x.Id == user.OrganizationId.Value) + .Select(x => x.Name) + .FirstOrDefaultAsync(cancellationToken); + } + + var permissions = roles + .SelectMany(x => x.Permissions.Select(p => p.PermissionCode)) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); var menus = await BuildMenusByPermissionsAsync(permissions, cancellationToken); var dto = 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, + Id = user.Id, + Name = user.Name, + Account = user.Account, + Email = user.Email, + Avatar = user.Avatar, + OrganizationId = user.OrganizationId, + OrganizationName = organizationName, + Roles = roles + .Select(x => new RoleSimpleDto + { + Id = x.Id, + Name = x.Name, + Code = x.Code + }) + .ToList(), Permissions = permissions, Menus = menus, - CreatedAt = userInfo.CreatedAt, - CreatedBy = userInfo.CreatedBy, - UpdatedAt = userInfo.UpdatedAt, - UpdatedBy = userInfo.UpdatedBy + CreatedAt = user.CreateTime, + CreatedBy = user.CreateUserId, + UpdatedAt = user.UpdateTime == default ? null : user.UpdateTime, + UpdatedBy = user.UpdateUserId }; memoryCache.Set( @@ -495,7 +529,7 @@ public partial class AuthService( private void InvalidateUserInfoCache(Guid userId) { - memoryCache.Remove(UserInfoCacheKey(userId)); + rbacAuthCache.InvalidateUser(userId); } private async Task ChangeCustomerPasswordAsync(string oldPassword, string newPassword, CancellationToken cancellationToken) @@ -512,22 +546,25 @@ public partial class AuthService( 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; - } - + /// + /// 登录快照:一次构建 UserInfo,并从中派生 JWT claims,避免再走 Fake GetUserRoles/GetAsync。 + /// private async Task BuildLoginResultAsync( Guid userId, - IReadOnlyCollection claims, + OmsLoginType loginType, CancellationToken cancellationToken) + { + var userInfo = await BuildAdminUserInfoAsync(userId, cancellationToken); + var claims = await BuildClaimsFromUserInfoAsync(userId, userInfo, loginType, cancellationToken); + return BuildTokenResult(userId, claims, userInfo); + } + + private OmsLoginResultDto BuildTokenOnlyResult(Guid userId, List claims) + { + return BuildTokenResult(userId, claims, userInfo: null); + } + + private OmsLoginResultDto BuildTokenResult(Guid userId, IReadOnlyCollection claims, UserInfoDto? userInfo) { var accessClaims = CloneClaims(claims); var refreshClaims = CloneClaims(claims); @@ -539,10 +576,59 @@ public partial class AuthService( TokenType = "Bearer", ExpiresIn = jwtService.GetExpiresInSeconds(), UserId = userId, - UserInfo = await BuildAdminUserInfoAsync(userId, cancellationToken) + UserInfo = userInfo }; } + private async Task> BuildClaimsFromUserInfoAsync( + Guid userId, + UserInfoDto userInfo, + OmsLoginType loginType, + CancellationToken cancellationToken) + { + var claims = new List + { + new(FakeClaimTypes.UserName, userInfo.Name ?? string.Empty) + }; + + foreach (var roleCode in (userInfo.Roles ?? []) + .Select(x => x.Code) + .Where(x => !string.IsNullOrWhiteSpace(x))) + { + claims.Add(new Claim(FakeClaimTypes.Role, roleCode!)); + } + + var mapId = await GetMapIdByUserGuidAsync(userId, cancellationToken); + if (mapId.HasValue) + { + claims.Add(new Claim(FakeClaimTypes.UserId, mapId.Value.ToString())); + claims.Add(new Claim(ClaimTypes.NameIdentifier, userId.ToString())); + } + else + { + claims.Add(new Claim(FakeClaimTypes.UserId, userId.ToString())); + claims.Add(new Claim(ClaimTypes.NameIdentifier, userId.ToString())); + } + + ReplaceOrAppendClaim(claims, SimpleOmsClaimTypes.LoginType, ((int)loginType).ToString()); + return claims; + } + + private static List ReuseClaimsFromRefreshToken(IEnumerable source, OmsLoginType loginType) + { + var skip = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "jti", "iat", "nbf", "exp", "iss", "aud", "token_type" + }; + + var claims = source + .Where(x => !skip.Contains(x.Type)) + .Select(x => new Claim(x.Type, x.Value, x.ValueType, x.Issuer, x.OriginalIssuer)) + .ToList(); + ReplaceOrAppendClaim(claims, SimpleOmsClaimTypes.LoginType, ((int)loginType).ToString()); + return claims; + } + private static List CloneClaims(IEnumerable claims) { return claims.Select(x => new Claim(x.Type, x.Value, x.ValueType, x.Issuer, x.OriginalIssuer)).ToList(); @@ -694,7 +780,8 @@ public partial class AuthService( private async Task> GetAllActiveMenusCachedAsync( CancellationToken cancellationToken) { - if (memoryCache.TryGetValue(AllMenusCacheKey, out List? cached) + var cacheKey = rbacAuthCache.AllMenusKey(); + if (memoryCache.TryGetValue(cacheKey, out List? cached) && cached is { Count: > 0 }) { return cached; @@ -706,7 +793,7 @@ public partial class AuthService( .OrderBy(x => x.Order) .ToListAsync(cancellationToken); - memoryCache.Set(AllMenusCacheKey, allMenus, TimeSpan.FromMinutes(5)); + memoryCache.Set(cacheKey, allMenus, AllMenusCacheTtl); return allMenus; } @@ -751,6 +838,13 @@ public partial class AuthService( .FirstOrDefaultAsync(cancellationToken); } + private async Task GetMapIdByUserGuidAsync(Guid userId, CancellationToken cancellationToken) + { + return await rbacDbContext.Database.SqlQuery( + $"select `userid` as `Value` from `user` where `Id` = {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; diff --git a/src/SimpleAdmin.Api/Auth/IRbacAuthCache.cs b/src/SimpleAdmin.Api/Auth/IRbacAuthCache.cs new file mode 100644 index 0000000..da22384 --- /dev/null +++ b/src/SimpleAdmin.Api/Auth/IRbacAuthCache.cs @@ -0,0 +1,18 @@ +namespace SimpleAdmin.Api.Auth; + +/// +/// RBAC 登录用户信息 / 菜单缓存。通过 epoch 批量失效,避免角色权限变更后脏读。 +/// +public interface IRbacAuthCache +{ + string UserInfoKey(Guid userId); + + string AllMenusKey(); + + void InvalidateUser(Guid userId); + + /// + /// 角色/菜单等全局变更时 bump epoch,使全部 userInfo 与全量菜单缓存失效。 + /// + void InvalidateAll(); +} diff --git a/src/SimpleAdmin.Api/Auth/RbacAuthCache.cs b/src/SimpleAdmin.Api/Auth/RbacAuthCache.cs new file mode 100644 index 0000000..a9baa7d --- /dev/null +++ b/src/SimpleAdmin.Api/Auth/RbacAuthCache.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.Caching.Memory; + +namespace SimpleAdmin.Api.Auth; + +public sealed class RbacAuthCache(IMemoryCache memoryCache) : IRbacAuthCache +{ + private const string EpochKey = "rbac:auth:epoch"; + private const string UserInfoKeyPrefix = "rbac:userinfo:v3:"; + private const string AllMenusKeyPrefix = "rbac:menus:all:v3:"; + + private string GetEpoch() + { + return memoryCache.GetOrCreate( + EpochKey, + entry => + { + entry.Priority = CacheItemPriority.NeverRemove; + return "0"; + })!; + } + + public string UserInfoKey(Guid userId) => UserInfoKeyPrefix + GetEpoch() + ":" + userId; + + public string AllMenusKey() => AllMenusKeyPrefix + GetEpoch(); + + public void InvalidateUser(Guid userId) + { + memoryCache.Remove(UserInfoKey(userId)); + } + + public void InvalidateAll() + { + memoryCache.Set(EpochKey, Guid.NewGuid().ToString("N")); + } +} diff --git a/src/SimpleAdmin.Api/Auth/RbacCacheInvalidationMiddleware.cs b/src/SimpleAdmin.Api/Auth/RbacCacheInvalidationMiddleware.cs new file mode 100644 index 0000000..7617ebe --- /dev/null +++ b/src/SimpleAdmin.Api/Auth/RbacCacheInvalidationMiddleware.cs @@ -0,0 +1,31 @@ +namespace SimpleAdmin.Api.Auth; + +/// +/// 角色/菜单/分配角色等写操作成功后,失效 auth 相关内存缓存。 +/// +public sealed class RbacCacheInvalidationMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext context, IRbacAuthCache authCache) + { + await next(context); + + if (context.Response.StatusCode is < 200 or >= 300) + { + return; + } + + var method = context.Request.Method; + if (method is not ("POST" or "PUT" or "DELETE")) + { + return; + } + + var path = context.Request.Path.Value ?? string.Empty; + if (path.Equals("/rbac/user/assign-roles", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("/rbac/role/", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("/rbac/menu/", StringComparison.OrdinalIgnoreCase)) + { + authCache.InvalidateAll(); + } + } +} diff --git a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs index ffebfc8..770bcae 100644 --- a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs +++ b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs @@ -61,7 +61,8 @@ public class SimpleAdminApiModule : FakeModule }); services.AddOmsWeChat(configuration); - + services.AddSingleton(); + //Json格式化全局处理 context.Services.AddFakeNewtonsoft(); @@ -121,6 +122,9 @@ public class SimpleAdminApiModule : FakeModule app.UseAuthentication(); app.UseAuthorization(); + // 角色/菜单写操作后失效登录用户信息缓存 + app.UseMiddleware(); + // 端点映射 app.MapControllers();