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;
+ }
+}