From 54db985fa593a638dacc6839036d9ce4752d64fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=91=E5=AE=81?= <1772105645@qq.com> Date: Sun, 17 May 2026 19:43:43 +0800 Subject: [PATCH] feat: gRPC auth service, validators, middleware fixes - Add AuthGrpcService for inter-service token validation - Add FluentValidation validators for auth/menus/roles/users - Fix middleware Stream lifecycle issues - Register ABP ObjectAccessor for container compatibility - Update connection strings for NUC Box infrastructure - Add JWT claim mapping fixes --- .../Endpoints/Auth/GetAccessCodesEndpoint.cs | 12 +-- .../Endpoints/Auth/GetCurrentUserEndpoint.cs | 13 +--- src/RAG.Api/Endpoints/Auth/LoginEndpoint.cs | 10 +-- .../Endpoints/Auth/RefreshTokenEndpoint.cs | 18 ++--- .../Endpoints/Menus/GetAllMenusEndpoint.cs | 24 +----- src/RAG.Api/Grpc/AuthGrpcService.cs | 73 +++++++++++++++++++ .../Middleware/ApiResponseMiddleware.cs | 20 ++++- .../Middleware/GlobalExceptionMiddleware.cs | 10 ++- src/RAG.Api/Program.cs | 16 ++++ src/RAG.Api/Protos/auth.proto | 35 +++++++++ src/RAG.Api/RAG.Api.csproj | 17 +++-- src/RAG.Api/RAGApiModule.cs | 7 ++ src/RAG.Api/Services/CurrentUserContext.cs | 11 +-- src/RAG.Api/appsettings.json | 15 ++++ .../Auth/Commands/LoginCommand.cs | 46 ++++++++++-- .../Auth/Commands/RefreshTokenCommand.cs | 1 + .../Auth/Validators/AuthCommandValidators.cs | 53 ++++++++++++++ .../Common/ValidationBehavior.cs | 23 ++++++ .../Menus/Queries/GetAllMenusQuery.cs | 15 ++-- .../Validators/GetAllMenusQueryValidator.cs | 12 +++ src/RAG.Application/RAG.Application.csproj | 2 + src/RAG.Application/RAGApplicationModule.cs | 10 ++- .../Roles/Commands/DeleteRoleCommand.cs | 3 + .../Roles/Validators/RoleCommandValidators.cs | 51 +++++++++++++ .../Users/Commands/CreateUserCommand.cs | 1 + .../Users/Validators/UserCommandValidators.cs | 59 +++++++++++++++ src/RAG.Domain/Common/ICurrentUserContext.cs | 13 ++++ 27 files changed, 484 insertions(+), 86 deletions(-) create mode 100644 src/RAG.Api/Grpc/AuthGrpcService.cs create mode 100644 src/RAG.Api/Protos/auth.proto create mode 100644 src/RAG.Application/Auth/Validators/AuthCommandValidators.cs create mode 100644 src/RAG.Application/Common/ValidationBehavior.cs create mode 100644 src/RAG.Application/Menus/Validators/GetAllMenusQueryValidator.cs create mode 100644 src/RAG.Application/Roles/Validators/RoleCommandValidators.cs create mode 100644 src/RAG.Application/Users/Validators/UserCommandValidators.cs diff --git a/src/RAG.Api/Endpoints/Auth/GetAccessCodesEndpoint.cs b/src/RAG.Api/Endpoints/Auth/GetAccessCodesEndpoint.cs index 875f7ed..3ca6464 100644 --- a/src/RAG.Api/Endpoints/Auth/GetAccessCodesEndpoint.cs +++ b/src/RAG.Api/Endpoints/Auth/GetAccessCodesEndpoint.cs @@ -1,16 +1,12 @@ +using RAG.Domain.Common; using RAG.Domain.Exceptions; -using System.Security.Claims; using FastEndpoints; using MediatR; using RAG.Application.Auth.Queries; namespace RAG.Api.Endpoints.Auth; -/// -/// 获取当前用户的权限码列表。 -/// 前端调 GET /auth/codes,解包后得到 string[],用于 v-access:code 指令的按钮级权限控制。 -/// -public class GetAccessCodesEndpoint(IMediator mediator) : EndpointWithoutRequest> +public class GetAccessCodesEndpoint(IMediator mediator, ICurrentUserContext userContext) : EndpointWithoutRequest> { public override void Configure() { @@ -19,9 +15,7 @@ public class GetAccessCodesEndpoint(IMediator mediator) : EndpointWithoutRequest public override async Task HandleAsync(CancellationToken ct) { - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) - ?? throw new UnauthorizedException("未授权,请先登录"); - + var userId = userContext.GetRequiredUserId(); var codes = await mediator.Send(new GetAccessCodesQuery(userId), ct); await Send.OkAsync(codes, ct); } diff --git a/src/RAG.Api/Endpoints/Auth/GetCurrentUserEndpoint.cs b/src/RAG.Api/Endpoints/Auth/GetCurrentUserEndpoint.cs index ff6421b..cccde04 100644 --- a/src/RAG.Api/Endpoints/Auth/GetCurrentUserEndpoint.cs +++ b/src/RAG.Api/Endpoints/Auth/GetCurrentUserEndpoint.cs @@ -1,28 +1,21 @@ +using RAG.Domain.Common; using RAG.Domain.Exceptions; -using System.Security.Claims; using FastEndpoints; using MediatR; using RAG.Application.Auth.Queries; namespace RAG.Api.Endpoints.Auth; -/// -/// 获取当前登录用户信息:从 JWT claims 中取 userId,查库返回用户资料+角色。 -/// 前端登录后调 GET /user/info,解包后得到 { userId, username, realName, roles, ... }。 -/// -public class GetCurrentUserEndpoint(IMediator mediator) : EndpointWithoutRequest +public class GetCurrentUserEndpoint(IMediator mediator, ICurrentUserContext userContext) : EndpointWithoutRequest { public override void Configure() { Get("/user/info"); - // 所有已认证用户都可调用,不需要特定权限码 } public override async Task HandleAsync(CancellationToken ct) { - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) - ?? throw new UnauthorizedException("未授权,请先登录"); - + var userId = userContext.GetRequiredUserId(); var result = await mediator.Send(new GetCurrentUserQuery(userId), ct); await Send.OkAsync(result, ct); } diff --git a/src/RAG.Api/Endpoints/Auth/LoginEndpoint.cs b/src/RAG.Api/Endpoints/Auth/LoginEndpoint.cs index 9dae81c..9b00b1c 100644 --- a/src/RAG.Api/Endpoints/Auth/LoginEndpoint.cs +++ b/src/RAG.Api/Endpoints/Auth/LoginEndpoint.cs @@ -1,15 +1,12 @@ using FastEndpoints; using MediatR; +using Microsoft.Extensions.Configuration; using RAG.Application.Auth.Commands; using RAG.Application.Auth.DTOs; namespace RAG.Api.Endpoints.Auth; -/// -/// 登录接口:验证凭据后返回 TokenResponse,同时将 refreshToken 写入 httpOnly Cookie。 -/// 前端 requestClient 只从响应体取 accessToken,refreshToken 通过 Cookie 自动管理。 -/// -public class LoginEndpoint(IMediator mediator) : Endpoint +public class LoginEndpoint(IMediator mediator, IConfiguration config) : Endpoint { public override void Configure() { @@ -21,11 +18,10 @@ public class LoginEndpoint(IMediator mediator) : Endpoint("Cookie:Secure"), SameSite = SameSiteMode.Lax, Expires = DateTimeOffset.UtcNow.AddDays(7), Path = "/" diff --git a/src/RAG.Api/Endpoints/Auth/RefreshTokenEndpoint.cs b/src/RAG.Api/Endpoints/Auth/RefreshTokenEndpoint.cs index 0dc48b1..3eb7390 100644 --- a/src/RAG.Api/Endpoints/Auth/RefreshTokenEndpoint.cs +++ b/src/RAG.Api/Endpoints/Auth/RefreshTokenEndpoint.cs @@ -1,23 +1,19 @@ using RAG.Domain.Exceptions; -using System.Text; using FastEndpoints; using MediatR; +using Microsoft.Extensions.Configuration; using RAG.Application.Auth.Commands; using RAG.Application.Auth.DTOs; namespace RAG.Api.Endpoints.Auth; -/// -/// 刷新令牌接口:从 httpOnly Cookie 读取 refreshToken,颁发新的 token 对。 -/// 前端通过 baseRequestClient(不解包)调用,返回纯字符串(新 accessToken)。 -/// 注意:此接口被 ApiResponseMiddleware 排除,不会包裹为 { code, data, message }。 -/// -public class RefreshTokenEndpoint(IMediator mediator) : EndpointWithoutRequest +public class RefreshTokenEndpoint(IMediator mediator, IConfiguration config) : EndpointWithoutRequest { public override void Configure() { Post("/auth/refresh"); AllowAnonymous(); + Options(b => b.ExcludeFromDescription()); } public override async Task HandleAsync(CancellationToken ct) @@ -27,19 +23,17 @@ public class RefreshTokenEndpoint(IMediator mediator) : EndpointWithoutRequest("Cookie:Secure"), SameSite = SameSiteMode.Lax, Expires = DateTimeOffset.UtcNow.AddDays(7), Path = "/" }); - // 前端 baseRequestClient 期望 resp.data 为纯字符串(新 accessToken) + HttpContext.Response.StatusCode = 200; HttpContext.Response.ContentType = "text/plain; charset=utf-8"; - var bytes = Encoding.UTF8.GetBytes(result.AccessToken); - await HttpContext.Response.Body.WriteAsync(bytes, ct); + await HttpContext.Response.WriteAsync(result.AccessToken, ct); } } diff --git a/src/RAG.Api/Endpoints/Menus/GetAllMenusEndpoint.cs b/src/RAG.Api/Endpoints/Menus/GetAllMenusEndpoint.cs index 762a175..42f7d72 100644 --- a/src/RAG.Api/Endpoints/Menus/GetAllMenusEndpoint.cs +++ b/src/RAG.Api/Endpoints/Menus/GetAllMenusEndpoint.cs @@ -1,39 +1,23 @@ +using RAG.Domain.Common; using RAG.Domain.Exceptions; -using System.Security.Claims; using FastEndpoints; using MediatR; -using Microsoft.EntityFrameworkCore; using RAG.Application.Menus.DTOs; using RAG.Application.Menus.Queries; -using RAG.Infrastructure.Persistence; namespace RAG.Api.Endpoints.Menus; -/// -/// 获取当前用户可见的动态菜单树。 -/// 前端在 backend 模式下调 GET /menu/all,解包后得到 RouteRecordStringComponent[]。 -/// 根据 JWT 中的用户 ID 查出角色,再按角色过滤菜单。 -/// -public class GetAllMenusEndpoint(IMediator mediator, RagDbContext db) : EndpointWithoutRequest> +public class GetAllMenusEndpoint(IMediator mediator, ICurrentUserContext userContext) : EndpointWithoutRequest> { public override void Configure() { Get("/menu/all"); - // 所有已认证用户可调用 } public override async Task HandleAsync(CancellationToken ct) { - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) - ?? throw new UnauthorizedException("未授权,请先登录"); - - // 查出当前用户角色 - var userRoles = await db.Users - .Where(u => u.Id.ToString() == userId) - .SelectMany(u => u.UserRoles.Select(ur => ur.Role.Name)) - .ToListAsync(ct); - - var menus = await mediator.Send(new GetAllMenusQuery(userRoles), ct); + var userId = userContext.GetRequiredUserId(); + var menus = await mediator.Send(new GetAllMenusQuery(userId), ct); await Send.OkAsync(menus, ct); } } diff --git a/src/RAG.Api/Grpc/AuthGrpcService.cs b/src/RAG.Api/Grpc/AuthGrpcService.cs new file mode 100644 index 0000000..6e19ad6 --- /dev/null +++ b/src/RAG.Api/Grpc/AuthGrpcService.cs @@ -0,0 +1,73 @@ +using Grpc.Core; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace RAG.Api.Grpc; + +public class AuthGrpcService(IConfiguration config) : AuthService.AuthServiceBase +{ + private readonly TokenValidationParameters _validationParams = new() + { + ValidateIssuer = true, + ValidIssuer = config["Jwt:Issuer"], + ValidateAudience = true, + ValidAudience = config["Jwt:Audience"], + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:SigningKey"]!)), + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero + }; + + public override Task ValidateToken(ValidateTokenRequest request, ServerCallContext context) + { + var response = new ValidateTokenResponse(); + + try + { + var handler = new JwtSecurityTokenHandler(); + var principal = handler.ValidateToken(request.Token, _validationParams, out var validatedToken); + + if (validatedToken is not JwtSecurityToken jwtToken) + return Task.FromResult(response); + + response.Valid = true; + response.UserId = principal.FindFirstValue(ClaimTypes.NameIdentifier) + ?? principal.FindFirstValue(JwtRegisteredClaimNames.Sub) ?? ""; + response.Username = principal.FindFirstValue(ClaimTypes.Name) + ?? principal.FindFirstValue(JwtRegisteredClaimNames.UniqueName) ?? ""; + response.Email = principal.FindFirstValue(JwtRegisteredClaimNames.Email) + ?? principal.FindFirstValue(ClaimTypes.Email) ?? ""; + response.Roles.AddRange(principal.FindAll(ClaimTypes.Role).Select(c => c.Value)); + response.Permissions.AddRange(principal.FindAll("permissions").Select(c => c.Value)); + response.ExpiresAt = new DateTimeOffset(jwtToken.ValidTo).ToUnixTimeSeconds(); + } + catch (SecurityTokenException) { } + catch (ArgumentException) { } + + return Task.FromResult(response); + } + + public override Task CheckPermission(CheckPermissionRequest request, ServerCallContext context) + { + var response = new CheckPermissionResponse(); + + try + { + var handler = new JwtSecurityTokenHandler(); + var principal = handler.ValidateToken(request.Token, _validationParams, out _); + + response.UserId = principal.FindFirstValue(ClaimTypes.NameIdentifier) + ?? principal.FindFirstValue(JwtRegisteredClaimNames.Sub) ?? ""; + var permissions = principal.FindAll("permissions").Select(c => c.Value).ToList(); + response.Allowed = permissions.Contains(request.Permission); + response.Roles.AddRange(principal.FindAll(ClaimTypes.Role).Select(c => c.Value)); + } + catch (SecurityTokenException) { } + catch (ArgumentException) { } + + return Task.FromResult(response); + } +} diff --git a/src/RAG.Api/Middleware/ApiResponseMiddleware.cs b/src/RAG.Api/Middleware/ApiResponseMiddleware.cs index e7b23e0..c863506 100644 --- a/src/RAG.Api/Middleware/ApiResponseMiddleware.cs +++ b/src/RAG.Api/Middleware/ApiResponseMiddleware.cs @@ -16,11 +16,26 @@ public class ApiResponseMiddleware(RequestDelegate next) public async Task InvokeAsync(HttpContext context) { + if (context.Request.ContentType?.StartsWith("application/grpc") == true || IsExcludedPath(context.Request.Path)) + { + await next(context); + return; + } + var originalBody = context.Response.Body; - using var buffer = new MemoryStream(); + var buffer = new MemoryStream(); context.Response.Body = buffer; - await next(context); + try + { + await next(context); + } + catch + { + context.Response.Body = originalBody; + await buffer.DisposeAsync(); + throw; + } context.Response.Body = originalBody; @@ -56,6 +71,7 @@ public class ApiResponseMiddleware(RequestDelegate next) context.Response.ContentType = "application/json; charset=utf-8"; context.Response.ContentLength = bytes.Length; await originalBody.WriteAsync(bytes, context.RequestAborted); + await buffer.DisposeAsync(); } private static bool IsExcludedPath(PathString path) => diff --git a/src/RAG.Api/Middleware/GlobalExceptionMiddleware.cs b/src/RAG.Api/Middleware/GlobalExceptionMiddleware.cs index 6c5cdea..91ec387 100644 --- a/src/RAG.Api/Middleware/GlobalExceptionMiddleware.cs +++ b/src/RAG.Api/Middleware/GlobalExceptionMiddleware.cs @@ -1,6 +1,7 @@ using System.Net; using System.Text; using System.Text.Json; +using FluentValidation; using RAG.Domain.Exceptions; namespace RAG.Api.Middleware; @@ -15,6 +16,12 @@ public class GlobalExceptionMiddleware(RequestDelegate next) public async Task InvokeAsync(HttpContext context) { + if (context.Request.ContentType?.StartsWith("application/grpc") == true) + { + await next(context); + return; + } + try { await next(context); @@ -32,7 +39,8 @@ public class GlobalExceptionMiddleware(RequestDelegate next) UnauthorizedException => (HttpStatusCode.Unauthorized, 401, ex.Message), NotFoundException => (HttpStatusCode.NotFound, 404, ex.Message), BusinessException => (HttpStatusCode.BadRequest, 400, ex.Message), - // 未知异常:生产环境隐藏详情,开发环境暴露 + ValidationException vex => (HttpStatusCode.BadRequest, 400, + string.Join("; ", vex.Errors.Select(e => e.ErrorMessage))), _ => (HttpStatusCode.InternalServerError, 500, "服务内部错误,请稍后重试") }; diff --git a/src/RAG.Api/Program.cs b/src/RAG.Api/Program.cs index a9f899f..b56c81c 100644 --- a/src/RAG.Api/Program.cs +++ b/src/RAG.Api/Program.cs @@ -1,9 +1,25 @@ using Microsoft.EntityFrameworkCore; using RAG.Api; using RAG.Infrastructure.Persistence; +using Volo.Abp.DependencyInjection; var builder = WebApplication.CreateBuilder(args); +// ABP 需要同时注册 ObjectAccessor 的具体类型和接口类型 +var appBuilderAccessor = new ObjectAccessor(); +var webAppAccessor = new ObjectAccessor(); +var hostAccessor = new ObjectAccessor(); +var endpointAccessor = new ObjectAccessor(); + +builder.Services.AddSingleton(appBuilderAccessor); +builder.Services.AddSingleton>(appBuilderAccessor); +builder.Services.AddSingleton(webAppAccessor); +builder.Services.AddSingleton>(webAppAccessor); +builder.Services.AddSingleton(hostAccessor); +builder.Services.AddSingleton>(hostAccessor); +builder.Services.AddSingleton(endpointAccessor); +builder.Services.AddSingleton>(endpointAccessor); + // ABP 模块化引导:按 DependsOn 依赖顺序调用各模块的 ConfigureServices await builder.AddApplicationAsync(); diff --git a/src/RAG.Api/Protos/auth.proto b/src/RAG.Api/Protos/auth.proto new file mode 100644 index 0000000..b8b96da --- /dev/null +++ b/src/RAG.Api/Protos/auth.proto @@ -0,0 +1,35 @@ +syntax = "proto3"; + +package auth; + +option csharp_namespace = "RAG.Api.Grpc"; + +service AuthService { + rpc ValidateToken (ValidateTokenRequest) returns (ValidateTokenResponse); + rpc CheckPermission (CheckPermissionRequest) returns (CheckPermissionResponse); +} + +message ValidateTokenRequest { + string token = 1; +} + +message ValidateTokenResponse { + bool valid = 1; + string user_id = 2; + string username = 3; + string email = 4; + repeated string roles = 5; + repeated string permissions = 6; + int64 expires_at = 7; +} + +message CheckPermissionRequest { + string token = 1; + string permission = 2; +} + +message CheckPermissionResponse { + bool allowed = 1; + string user_id = 2; + repeated string roles = 3; +} diff --git a/src/RAG.Api/RAG.Api.csproj b/src/RAG.Api/RAG.Api.csproj index d561281..a7e6524 100644 --- a/src/RAG.Api/RAG.Api.csproj +++ b/src/RAG.Api/RAG.Api.csproj @@ -10,18 +10,23 @@ + - - runtime; build; native; contentfiles; analyzers; buildtransitive - all + + runtime; build; native; contentfiles; analyzers; buildtransitive + all - - - + + + + + + + diff --git a/src/RAG.Api/RAGApiModule.cs b/src/RAG.Api/RAGApiModule.cs index d923ced..8904b33 100644 --- a/src/RAG.Api/RAGApiModule.cs +++ b/src/RAG.Api/RAGApiModule.cs @@ -1,6 +1,7 @@ using FastEndpoints; using FastEndpoints.Security; using FastEndpoints.Swagger; +using RAG.Api.Grpc; using RAG.Api.Middleware; using RAG.Api.Services; using RAG.Application; @@ -26,6 +27,9 @@ public class RAGApiModule : AbpModule services.AddFastEndpoints(); services.SwaggerDocument(); + // gRPC + services.AddGrpc(); + // 当前用户上下文,供 AuditInterceptor 获取操作者 ID 和 IP services.AddHttpContextAccessor(); services.AddScoped(); @@ -63,6 +67,9 @@ public class RAGApiModule : AbpModule // 全局异常中间件:捕获所有未处理异常,统一返回 { code, message, data } app.UseMiddleware(); + // gRPC endpoint mapping(必须在 ApiResponseMiddleware 之前) + ((IEndpointRouteBuilder)app).MapGrpcService(); + // 响应包裹中间件:统一 { code: 0, data, message } 格式 app.UseMiddleware(); diff --git a/src/RAG.Api/Services/CurrentUserContext.cs b/src/RAG.Api/Services/CurrentUserContext.cs index 081e869..1d14d2e 100644 --- a/src/RAG.Api/Services/CurrentUserContext.cs +++ b/src/RAG.Api/Services/CurrentUserContext.cs @@ -1,12 +1,9 @@ +using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using RAG.Domain.Common; namespace RAG.Api.Services; -/// -/// 当前用户上下文实现,从 HttpContext 中提取当前认证用户的 ID 和请求 IP。 -/// 供 AuditInterceptor 使用,实现审计字段的自动填充。 -/// public class CurrentUserContext : ICurrentUserContext { private readonly IHttpContextAccessor _httpContextAccessor; @@ -23,10 +20,10 @@ public class CurrentUserContext : ICurrentUserContext var httpContext = _httpContextAccessor.HttpContext; if (httpContext?.User?.Identity?.IsAuthenticated == true) { - // JWT 令牌中 NameIdentifier claim 对应登录时写入的 user.Id - return httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system"; + return httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? httpContext.User.FindFirstValue(JwtRegisteredClaimNames.Sub) + ?? "system"; } - // 未认证场景(种子数据、后台任务等)使用 "system" 作为操作者 return "system"; } } diff --git a/src/RAG.Api/appsettings.json b/src/RAG.Api/appsettings.json index 6f25551..0e71556 100644 --- a/src/RAG.Api/appsettings.json +++ b/src/RAG.Api/appsettings.json @@ -1,4 +1,16 @@ { + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://localhost:5211", + "Protocols": "Http1" + }, + "Grpc": { + "Url": "http://localhost:50051", + "Protocols": "Http2" + } + } + }, "Logging": { "LogLevel": { "Default": "Information", @@ -20,5 +32,8 @@ "SigningKey": "RagJwtSecretKey2026MustBeAtLeast32CharsLong!", "Issuer": "rag-api", "Audience": "rag-client" + }, + "Cookie": { + "Secure": false } } diff --git a/src/RAG.Application/Auth/Commands/LoginCommand.cs b/src/RAG.Application/Auth/Commands/LoginCommand.cs index 6898cbc..8687279 100644 --- a/src/RAG.Application/Auth/Commands/LoginCommand.cs +++ b/src/RAG.Application/Auth/Commands/LoginCommand.cs @@ -1,6 +1,7 @@ using RAG.Domain.Exceptions; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; +using System.Security.Cryptography; using System.Text; using MediatR; using Microsoft.EntityFrameworkCore; @@ -16,19 +17,29 @@ public record LoginCommand(string Username, string Password) : IRequest { + private static readonly Dictionary LoginAttempts = new(); + public async Task Handle(LoginCommand request, CancellationToken ct) { + var key = request.Username.Trim().ToLowerInvariant(); + + if (LoginAttempts.TryGetValue(key, out var attempt) && attempt.LockedUntil > DateTime.UtcNow) + throw new BusinessException($"登录失败次数过多,请在 {(attempt.LockedUntil - DateTime.UtcNow).Minutes + 1} 分钟后重试"); + var user = await db.Users .Include(u => u.UserRoles).ThenInclude(ur => ur.Role).ThenInclude(r => r.RolePermissions).ThenInclude(rp => rp.Permission) .FirstOrDefaultAsync(u => u.Username == request.Username, ct) - ?? throw new UnauthorizedException("用户名或密码错误"); + ?? throw FailLogin(key, "用户名或密码错误"); if (!BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash)) - throw new UnauthorizedException("用户名或密码错误"); + throw FailLogin(key, "用户名或密码错误"); if (!user.IsActive) throw new BusinessException("用户已被停用"); + // 登录成功,清除失败计数 + LoginAttempts.Remove(key); + var roles = user.UserRoles.Select(ur => ur.Role.Name).ToList(); var permissions = user.UserRoles.SelectMany(ur => ur.Role.RolePermissions).Select(rp => rp.Permission.Code).Distinct().ToList(); @@ -37,6 +48,7 @@ public class LoginCommandHandler(RagDbContext db, IConfiguration config) : IRequ await db.RefreshTokens.AddAsync(new RefreshToken { + Id = Guid.NewGuid(), UserId = user.Id, Token = refreshToken, ExpiresAt = DateTime.UtcNow.AddDays(7), @@ -46,10 +58,28 @@ public class LoginCommandHandler(RagDbContext db, IConfiguration config) : IRequ return new TokenResponse(accessToken, refreshToken, DateTime.UtcNow.AddMinutes(15)); } + private static BusinessException FailLogin(string key, string message) + { + if (!LoginAttempts.TryGetValue(key, out var attempt)) + attempt = (0, DateTime.MinValue); + + attempt.Count++; + + if (attempt.Count >= 5) + { + var lockedUntil = DateTime.UtcNow.AddMinutes(15); + LoginAttempts[key] = (attempt.Count, lockedUntil); + return new BusinessException($"登录失败次数过多,请 15 分钟后重试"); + } + + LoginAttempts[key] = attempt; + return new BusinessException(message); + } + private string GenerateAccessToken(User user, List roles, List permissions) { - var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:SigningKey"]!)); - var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:SigningKey"]!)); + var creds = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256); var claims = new List { @@ -71,5 +101,11 @@ public class LoginCommandHandler(RagDbContext db, IConfiguration config) : IRequ return new JwtSecurityTokenHandler().WriteToken(token); } - private static string GenerateRefreshToken() => Convert.ToBase64String(Guid.NewGuid().ToByteArray()) + Guid.NewGuid().ToString("N"); + private static string GenerateRefreshToken() + { + var bytes = new byte[64]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(bytes); + return Convert.ToBase64String(bytes); + } } diff --git a/src/RAG.Application/Auth/Commands/RefreshTokenCommand.cs b/src/RAG.Application/Auth/Commands/RefreshTokenCommand.cs index b09300c..432d8f0 100644 --- a/src/RAG.Application/Auth/Commands/RefreshTokenCommand.cs +++ b/src/RAG.Application/Auth/Commands/RefreshTokenCommand.cs @@ -54,6 +54,7 @@ public class RefreshTokenCommandHandler(RagDbContext db, IConfiguration config) var newRefresh = Convert.ToBase64String(Guid.NewGuid().ToByteArray()) + Guid.NewGuid().ToString("N"); await db.RefreshTokens.AddAsync(new Domain.Entities.RefreshToken { + Id = Guid.NewGuid(), UserId = user.Id, Token = newRefresh, ExpiresAt = DateTime.UtcNow.AddDays(7), diff --git a/src/RAG.Application/Auth/Validators/AuthCommandValidators.cs b/src/RAG.Application/Auth/Validators/AuthCommandValidators.cs new file mode 100644 index 0000000..5b6eb23 --- /dev/null +++ b/src/RAG.Application/Auth/Validators/AuthCommandValidators.cs @@ -0,0 +1,53 @@ +using FluentValidation; +using RAG.Application.Auth.Commands; + +namespace RAG.Application.Auth.Validators; + +public class LoginCommandValidator : AbstractValidator +{ + public LoginCommandValidator() + { + RuleFor(x => x.Username).NotEmpty().WithMessage("用户名不能为空") + .MaximumLength(50).WithMessage("用户名长度不能超过 50 个字符") + .Must(u => u == u.Trim()).WithMessage("用户名前后不能有空格"); + RuleFor(x => x.Password).NotEmpty().WithMessage("密码不能为空") + .MaximumLength(100).WithMessage("密码长度不能超过 100 个字符"); + } +} + +public class RegisterCommandValidator : AbstractValidator +{ + public RegisterCommandValidator() + { + RuleFor(x => x.Username).NotEmpty().WithMessage("用户名不能为空") + .Length(3, 50).WithMessage("用户名长度 3-50 个字符"); + RuleFor(x => x.Email).NotEmpty().WithMessage("邮箱不能为空") + .EmailAddress().WithMessage("邮箱格式不正确"); + RuleFor(x => x.Password).NotEmpty().WithMessage("密码不能为空") + .Length(6, 100).WithMessage("密码长度 6-100 个字符"); + } +} + +public class RefreshTokenCommandValidator : AbstractValidator +{ + public RefreshTokenCommandValidator() + { + RuleFor(x => x.RefreshToken).NotEmpty().WithMessage("RefreshToken 不能为空"); + } +} + +public class RevokeTokenCommandValidator : AbstractValidator +{ + public RevokeTokenCommandValidator() + { + RuleFor(x => x.RefreshToken).NotEmpty().WithMessage("RefreshToken 不能为空"); + } +} + +public class LogoutCommandValidator : AbstractValidator +{ + public LogoutCommandValidator() + { + RuleFor(x => x.RefreshToken).NotEmpty().WithMessage("RefreshToken 不能为空"); + } +} diff --git a/src/RAG.Application/Common/ValidationBehavior.cs b/src/RAG.Application/Common/ValidationBehavior.cs new file mode 100644 index 0000000..818d5e9 --- /dev/null +++ b/src/RAG.Application/Common/ValidationBehavior.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using MediatR; + +namespace RAG.Application.Common; + +public class ValidationBehavior(IEnumerable> validators) + : IPipelineBehavior where TRequest : IRequest +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + if (validators.Any()) + { + var context = new ValidationContext(request); + var results = await Task.WhenAll(validators.Select(v => v.ValidateAsync(context, cancellationToken))); + var failures = results.SelectMany(r => r.Errors).Where(f => f != null).ToList(); + if (failures.Count != 0) + throw new ValidationException(failures); + } + + return await next(); + } +} diff --git a/src/RAG.Application/Menus/Queries/GetAllMenusQuery.cs b/src/RAG.Application/Menus/Queries/GetAllMenusQuery.cs index 252730a..d311084 100644 --- a/src/RAG.Application/Menus/Queries/GetAllMenusQuery.cs +++ b/src/RAG.Application/Menus/Queries/GetAllMenusQuery.cs @@ -5,22 +5,24 @@ using RAG.Infrastructure.Persistence; namespace RAG.Application.Menus.Queries; -public record GetAllMenusQuery(List UserRoles) : IRequest>; +public record GetAllMenusQuery(string UserId) : IRequest>; public class GetAllMenusQueryHandler(RagDbContext db) : IRequestHandler> { public async Task> Handle(GetAllMenusQuery request, CancellationToken ct) { - // 加载所有未删除的菜单,按 Order 排序 + var userRoles = await db.Users + .Where(u => u.Id.ToString() == request.UserId) + .SelectMany(u => u.UserRoles.Select(ur => ur.Role.Name)) + .ToListAsync(ct); + var allMenus = await db.Menus .OrderBy(m => m.Order) .ToListAsync(ct); - // 按角色过滤:Authority 为空则所有人可见,否则用户角色匹配则可见 - var userRoleSet = new HashSet(request.UserRoles); + var userRoleSet = new HashSet(userRoles); var visibleMenus = allMenus.Where(m => IsMenuVisible(m, userRoleSet)).ToList(); - // 构建树结构并转换为 DTO return BuildTree(visibleMenus, null); } @@ -59,6 +61,9 @@ public class GetAllMenusQueryHandler(RagDbContext db) : IRequestHandler +{ + public GetAllMenusQueryValidator() + { + RuleFor(x => x.UserId).NotEmpty().WithMessage("用户 ID 不能为空"); + } +} diff --git a/src/RAG.Application/RAG.Application.csproj b/src/RAG.Application/RAG.Application.csproj index ea7572c..8642636 100644 --- a/src/RAG.Application/RAG.Application.csproj +++ b/src/RAG.Application/RAG.Application.csproj @@ -6,6 +6,8 @@ + + diff --git a/src/RAG.Application/RAGApplicationModule.cs b/src/RAG.Application/RAGApplicationModule.cs index b232054..e86335f 100644 --- a/src/RAG.Application/RAGApplicationModule.cs +++ b/src/RAG.Application/RAGApplicationModule.cs @@ -1,6 +1,8 @@ +using FluentValidation; using MediatR; using Microsoft.Extensions.DependencyInjection; using RAG.Application.Auth.Commands; +using RAG.Application.Common; using RAG.Infrastructure; using Volo.Abp.Modularity; @@ -14,7 +16,11 @@ public class RAGApplicationModule : AbpModule { public override void ConfigureServices(ServiceConfigurationContext context) { - context.Services.AddMediatR(cfg => - cfg.RegisterServicesFromAssembly(typeof(RegisterCommand).Assembly)); + var services = context.Services; + var assembly = typeof(RegisterCommand).Assembly; + + services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly)); + services.AddValidatorsFromAssembly(assembly); + services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); } } diff --git a/src/RAG.Application/Roles/Commands/DeleteRoleCommand.cs b/src/RAG.Application/Roles/Commands/DeleteRoleCommand.cs index bcb02e1..fae9f13 100644 --- a/src/RAG.Application/Roles/Commands/DeleteRoleCommand.cs +++ b/src/RAG.Application/Roles/Commands/DeleteRoleCommand.cs @@ -5,8 +5,11 @@ using RAG.Infrastructure.Persistence; namespace RAG.Application.Roles.Commands; + public record DeleteRoleCommand(Guid Id) : IRequest; + + public class DeleteRoleCommandHandler(RagDbContext db) : IRequestHandler { public async Task Handle(DeleteRoleCommand request, CancellationToken ct) diff --git a/src/RAG.Application/Roles/Validators/RoleCommandValidators.cs b/src/RAG.Application/Roles/Validators/RoleCommandValidators.cs new file mode 100644 index 0000000..3ec9c3d --- /dev/null +++ b/src/RAG.Application/Roles/Validators/RoleCommandValidators.cs @@ -0,0 +1,51 @@ +using FluentValidation; +using RAG.Application.Roles.Commands; +using RAG.Application.Roles.Queries; + +namespace RAG.Application.Roles.Validators; + +public class CreateRoleCommandValidator : AbstractValidator +{ + public CreateRoleCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().WithMessage("角色名称不能为空") + .Length(2, 50).WithMessage("角色名称长度 2-50 个字符"); + } +} + +public class UpdateRoleCommandValidator : AbstractValidator +{ + public UpdateRoleCommandValidator() + { + RuleFor(x => x.Id).NotEmpty().WithMessage("角色 ID 不能为空"); + When(x => x.Name != null, () => + { + RuleFor(x => x.Name!).Length(2, 50).WithMessage("角色名称长度 2-50 个字符"); + }); + } +} + +public class DeleteRoleCommandValidator : AbstractValidator +{ + public DeleteRoleCommandValidator() + { + RuleFor(x => x.Id).NotEmpty().WithMessage("角色 ID 不能为空"); + } +} + +public class AssignPermissionsCommandValidator : AbstractValidator +{ + public AssignPermissionsCommandValidator() + { + RuleFor(x => x.RoleId).NotEmpty().WithMessage("角色 ID 不能为空"); + RuleFor(x => x.PermissionIds).NotEmpty().WithMessage("权限列表不能为空"); + } +} + +public class GetRoleByIdQueryValidator : AbstractValidator +{ + public GetRoleByIdQueryValidator() + { + RuleFor(x => x.Id).NotEmpty().WithMessage("角色 ID 不能为空"); + } +} diff --git a/src/RAG.Application/Users/Commands/CreateUserCommand.cs b/src/RAG.Application/Users/Commands/CreateUserCommand.cs index d00cf0f..4c9729d 100644 --- a/src/RAG.Application/Users/Commands/CreateUserCommand.cs +++ b/src/RAG.Application/Users/Commands/CreateUserCommand.cs @@ -18,6 +18,7 @@ public class CreateUserCommandHandler(RagDbContext db) : IRequestHandler +{ + public CreateUserCommandValidator() + { + RuleFor(x => x.Username).NotEmpty().WithMessage("用户名不能为空") + .Length(3, 50).WithMessage("用户名长度 3-50 个字符"); + RuleFor(x => x.Email).NotEmpty().WithMessage("邮箱不能为空") + .EmailAddress().WithMessage("邮箱格式不正确"); + RuleFor(x => x.Password).NotEmpty().WithMessage("密码不能为空") + .Length(6, 100).WithMessage("密码长度 6-100 个字符"); + } +} + +public class UpdateUserCommandValidator : AbstractValidator +{ + public UpdateUserCommandValidator() + { + RuleFor(x => x.Id).NotEmpty().WithMessage("用户 ID 不能为空"); + When(x => x.Email != null, () => + { + RuleFor(x => x.Email!).EmailAddress().WithMessage("邮箱格式不正确"); + }); + When(x => x.Password != null, () => + { + RuleFor(x => x.Password!).Length(6, 100).WithMessage("密码长度 6-100 个字符"); + }); + } +} + +public class DeleteUserCommandValidator : AbstractValidator +{ + public DeleteUserCommandValidator() + { + RuleFor(x => x.Id).NotEmpty().WithMessage("用户 ID 不能为空"); + } +} + +public class AssignRolesCommandValidator : AbstractValidator +{ + public AssignRolesCommandValidator() + { + RuleFor(x => x.UserId).NotEmpty().WithMessage("用户 ID 不能为空"); + RuleFor(x => x.RoleIds).NotEmpty().WithMessage("角色列表不能为空"); + } +} + +public class GetUserByIdQueryValidator : AbstractValidator +{ + public GetUserByIdQueryValidator() + { + RuleFor(x => x.Id).NotEmpty().WithMessage("用户 ID 不能为空"); + } +} diff --git a/src/RAG.Domain/Common/ICurrentUserContext.cs b/src/RAG.Domain/Common/ICurrentUserContext.cs index caf1602..525f6c4 100644 --- a/src/RAG.Domain/Common/ICurrentUserContext.cs +++ b/src/RAG.Domain/Common/ICurrentUserContext.cs @@ -1,3 +1,5 @@ +using RAG.Domain.Exceptions; + namespace RAG.Domain.Common; /// @@ -9,3 +11,14 @@ public interface ICurrentUserContext string UserId { get; } string IPAddress { get; } } + +public static class CurrentUserContextExtensions +{ + public static string GetRequiredUserId(this ICurrentUserContext context) + { + var userId = context.UserId; + if (string.IsNullOrEmpty(userId) || userId == "system") + throw new UnauthorizedException("未授权,请先登录"); + return userId; + } +}