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
This commit is contained in:
parent
819eac0edc
commit
54db985fa5
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前用户的权限码列表。
|
||||
/// 前端调 GET /auth/codes,解包后得到 string[],用于 v-access:code 指令的按钮级权限控制。
|
||||
/// </summary>
|
||||
public class GetAccessCodesEndpoint(IMediator mediator) : EndpointWithoutRequest<List<string>>
|
||||
public class GetAccessCodesEndpoint(IMediator mediator, ICurrentUserContext userContext) : EndpointWithoutRequest<List<string>>
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前登录用户信息:从 JWT claims 中取 userId,查库返回用户资料+角色。
|
||||
/// 前端登录后调 GET /user/info,解包后得到 { userId, username, realName, roles, ... }。
|
||||
/// </summary>
|
||||
public class GetCurrentUserEndpoint(IMediator mediator) : EndpointWithoutRequest<CurrentUserInfo>
|
||||
public class GetCurrentUserEndpoint(IMediator mediator, ICurrentUserContext userContext) : EndpointWithoutRequest<CurrentUserInfo>
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 登录接口:验证凭据后返回 TokenResponse,同时将 refreshToken 写入 httpOnly Cookie。
|
||||
/// 前端 requestClient 只从响应体取 accessToken,refreshToken 通过 Cookie 自动管理。
|
||||
/// </summary>
|
||||
public class LoginEndpoint(IMediator mediator) : Endpoint<LoginRequest, TokenResponse>
|
||||
public class LoginEndpoint(IMediator mediator, IConfiguration config) : Endpoint<LoginRequest, TokenResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
@ -21,11 +18,10 @@ public class LoginEndpoint(IMediator mediator) : Endpoint<LoginRequest, TokenRes
|
||||
{
|
||||
var result = await mediator.Send(new LoginCommand(req.Username, req.Password), ct);
|
||||
|
||||
// 将 refreshToken 写入 httpOnly Cookie,前端通过 withCredentials 自动携带
|
||||
HttpContext.Response.Cookies.Append("jwt", result.RefreshToken, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = false,
|
||||
Secure = config.GetValue<bool>("Cookie:Secure"),
|
||||
SameSite = SameSiteMode.Lax,
|
||||
Expires = DateTimeOffset.UtcNow.AddDays(7),
|
||||
Path = "/"
|
||||
|
||||
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 刷新令牌接口:从 httpOnly Cookie 读取 refreshToken,颁发新的 token 对。
|
||||
/// 前端通过 baseRequestClient(不解包)调用,返回纯字符串(新 accessToken)。
|
||||
/// 注意:此接口被 ApiResponseMiddleware 排除,不会包裹为 { code, data, message }。
|
||||
/// </summary>
|
||||
public class RefreshTokenEndpoint(IMediator mediator) : EndpointWithoutRequest<string>
|
||||
public class RefreshTokenEndpoint(IMediator mediator, IConfiguration config) : EndpointWithoutRequest<string>
|
||||
{
|
||||
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<s
|
||||
|
||||
var result = await mediator.Send(new RefreshTokenCommand(refreshToken), ct);
|
||||
|
||||
// 轮换 Cookie
|
||||
HttpContext.Response.Cookies.Append("jwt", result.RefreshToken, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = false,
|
||||
Secure = config.GetValue<bool>("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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前用户可见的动态菜单树。
|
||||
/// 前端在 backend 模式下调 GET /menu/all,解包后得到 RouteRecordStringComponent[]。
|
||||
/// 根据 JWT 中的用户 ID 查出角色,再按角色过滤菜单。
|
||||
/// </summary>
|
||||
public class GetAllMenusEndpoint(IMediator mediator, RagDbContext db) : EndpointWithoutRequest<List<MenuRouteDto>>
|
||||
public class GetAllMenusEndpoint(IMediator mediator, ICurrentUserContext userContext) : EndpointWithoutRequest<List<MenuRouteDto>>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
73
src/RAG.Api/Grpc/AuthGrpcService.cs
Normal file
73
src/RAG.Api/Grpc/AuthGrpcService.cs
Normal file
@ -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<ValidateTokenResponse> 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<CheckPermissionResponse> 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);
|
||||
}
|
||||
}
|
||||
@ -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) =>
|
||||
|
||||
@ -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,
|
||||
"服务内部错误,请稍后重试")
|
||||
};
|
||||
|
||||
@ -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<Microsoft.AspNetCore.Builder.IApplicationBuilder>();
|
||||
var webAppAccessor = new ObjectAccessor<Microsoft.AspNetCore.Builder.WebApplication>();
|
||||
var hostAccessor = new ObjectAccessor<Microsoft.Extensions.Hosting.IHost>();
|
||||
var endpointAccessor = new ObjectAccessor<Microsoft.AspNetCore.Routing.IEndpointRouteBuilder>();
|
||||
|
||||
builder.Services.AddSingleton(appBuilderAccessor);
|
||||
builder.Services.AddSingleton<IObjectAccessor<Microsoft.AspNetCore.Builder.IApplicationBuilder>>(appBuilderAccessor);
|
||||
builder.Services.AddSingleton(webAppAccessor);
|
||||
builder.Services.AddSingleton<IObjectAccessor<Microsoft.AspNetCore.Builder.WebApplication>>(webAppAccessor);
|
||||
builder.Services.AddSingleton(hostAccessor);
|
||||
builder.Services.AddSingleton<IObjectAccessor<Microsoft.Extensions.Hosting.IHost>>(hostAccessor);
|
||||
builder.Services.AddSingleton(endpointAccessor);
|
||||
builder.Services.AddSingleton<IObjectAccessor<Microsoft.AspNetCore.Routing.IEndpointRouteBuilder>>(endpointAccessor);
|
||||
|
||||
// ABP 模块化引导:按 DependsOn 依赖顺序调用各模块的 ConfigureServices
|
||||
await builder.AddApplicationAsync<RAGApiModule>();
|
||||
|
||||
|
||||
35
src/RAG.Api/Protos/auth.proto
Normal file
35
src/RAG.Api/Protos/auth.proto
Normal file
@ -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;
|
||||
}
|
||||
@ -10,18 +10,23 @@
|
||||
<PackageReference Include="FastEndpoints" Version="8.1.0" />
|
||||
<PackageReference Include="FastEndpoints.Security" Version="8.1.0" />
|
||||
<PackageReference Include="FastEndpoints.Swagger" Version="8.1.0" />
|
||||
<PackageReference Include="Grpc.AspNetCore" Version="2.71.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Volo.Abp.AspNetCore" Version="10.3.0" />
|
||||
<PackageReference Include="Volo.Abp.Core" Version="10.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\RAG.Application\RAG.Application.csproj" />
|
||||
<ProjectReference Include="..\RAG.Infrastructure\RAG.Infrastructure.csproj" />
|
||||
<ItemGroup>
|
||||
<Protobuf Include="Protos\auth.proto" GrpcServices="Server" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\RAG.Application\RAG.Application.csproj" />
|
||||
<ProjectReference Include="..\RAG.Infrastructure\RAG.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@ -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<ICurrentUserContext, CurrentUserContext>();
|
||||
@ -63,6 +67,9 @@ public class RAGApiModule : AbpModule
|
||||
// 全局异常中间件:捕获所有未处理异常,统一返回 { code, message, data }
|
||||
app.UseMiddleware<GlobalExceptionMiddleware>();
|
||||
|
||||
// gRPC endpoint mapping(必须在 ApiResponseMiddleware 之前)
|
||||
((IEndpointRouteBuilder)app).MapGrpcService<AuthGrpcService>();
|
||||
|
||||
// 响应包裹中间件:统一 { code: 0, data, message } 格式
|
||||
app.UseMiddleware<ApiResponseMiddleware>();
|
||||
|
||||
|
||||
@ -1,12 +1,9 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using RAG.Domain.Common;
|
||||
|
||||
namespace RAG.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 当前用户上下文实现,从 HttpContext 中提取当前认证用户的 ID 和请求 IP。
|
||||
/// 供 AuditInterceptor 使用,实现审计字段的自动填充。
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<TokenRes
|
||||
|
||||
public class LoginCommandHandler(RagDbContext db, IConfiguration config) : IRequestHandler<LoginCommand, TokenResponse>
|
||||
{
|
||||
private static readonly Dictionary<string, (int Count, DateTime LockedUntil)> LoginAttempts = new();
|
||||
|
||||
public async Task<TokenResponse> 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<string> roles, List<string> 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<Claim>
|
||||
{
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
53
src/RAG.Application/Auth/Validators/AuthCommandValidators.cs
Normal file
53
src/RAG.Application/Auth/Validators/AuthCommandValidators.cs
Normal file
@ -0,0 +1,53 @@
|
||||
using FluentValidation;
|
||||
using RAG.Application.Auth.Commands;
|
||||
|
||||
namespace RAG.Application.Auth.Validators;
|
||||
|
||||
public class LoginCommandValidator : AbstractValidator<LoginCommand>
|
||||
{
|
||||
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<RegisterCommand>
|
||||
{
|
||||
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<RefreshTokenCommand>
|
||||
{
|
||||
public RefreshTokenCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.RefreshToken).NotEmpty().WithMessage("RefreshToken 不能为空");
|
||||
}
|
||||
}
|
||||
|
||||
public class RevokeTokenCommandValidator : AbstractValidator<RevokeTokenCommand>
|
||||
{
|
||||
public RevokeTokenCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.RefreshToken).NotEmpty().WithMessage("RefreshToken 不能为空");
|
||||
}
|
||||
}
|
||||
|
||||
public class LogoutCommandValidator : AbstractValidator<LogoutCommand>
|
||||
{
|
||||
public LogoutCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.RefreshToken).NotEmpty().WithMessage("RefreshToken 不能为空");
|
||||
}
|
||||
}
|
||||
23
src/RAG.Application/Common/ValidationBehavior.cs
Normal file
23
src/RAG.Application/Common/ValidationBehavior.cs
Normal file
@ -0,0 +1,23 @@
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace RAG.Application.Common;
|
||||
|
||||
public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
|
||||
: IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>
|
||||
{
|
||||
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (validators.Any())
|
||||
{
|
||||
var context = new ValidationContext<TRequest>(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();
|
||||
}
|
||||
}
|
||||
@ -5,22 +5,24 @@ using RAG.Infrastructure.Persistence;
|
||||
|
||||
namespace RAG.Application.Menus.Queries;
|
||||
|
||||
public record GetAllMenusQuery(List<string> UserRoles) : IRequest<List<MenuRouteDto>>;
|
||||
public record GetAllMenusQuery(string UserId) : IRequest<List<MenuRouteDto>>;
|
||||
|
||||
public class GetAllMenusQueryHandler(RagDbContext db) : IRequestHandler<GetAllMenusQuery, List<MenuRouteDto>>
|
||||
{
|
||||
public async Task<List<MenuRouteDto>> 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<string>(request.UserRoles);
|
||||
var userRoleSet = new HashSet<string>(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<GetAllMe
|
||||
HideInMenu = m.HideInMenu ? true : null,
|
||||
NoBasicLayout = m.NoBasicLayout ? true : null,
|
||||
MenuVisibleWithForbidden = m.MenuVisibleWithForbidden ? true : null,
|
||||
Authority = string.IsNullOrWhiteSpace(m.Authority)
|
||||
? null
|
||||
: m.Authority.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(),
|
||||
},
|
||||
Children = BuildTree(menus, m.Id)
|
||||
})
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
using RAG.Application.Menus.Queries;
|
||||
|
||||
namespace RAG.Application.Menus.Validators;
|
||||
|
||||
public class GetAllMenusQueryValidator : AbstractValidator<GetAllMenusQuery>
|
||||
{
|
||||
public GetAllMenusQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.UserId).NotEmpty().WithMessage("用户 ID 不能为空");
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,8 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentValidation" Version="12.1.1" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||
<PackageReference Include="MediatR" Version="14.1.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.7" />
|
||||
|
||||
@ -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<,>));
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,8 +5,11 @@ using RAG.Infrastructure.Persistence;
|
||||
|
||||
namespace RAG.Application.Roles.Commands;
|
||||
|
||||
|
||||
public record DeleteRoleCommand(Guid Id) : IRequest<Unit>;
|
||||
|
||||
|
||||
|
||||
public class DeleteRoleCommandHandler(RagDbContext db) : IRequestHandler<DeleteRoleCommand, Unit>
|
||||
{
|
||||
public async Task<Unit> Handle(DeleteRoleCommand request, CancellationToken ct)
|
||||
|
||||
@ -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<CreateRoleCommand>
|
||||
{
|
||||
public CreateRoleCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().WithMessage("角色名称不能为空")
|
||||
.Length(2, 50).WithMessage("角色名称长度 2-50 个字符");
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateRoleCommandValidator : AbstractValidator<UpdateRoleCommand>
|
||||
{
|
||||
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<DeleteRoleCommand>
|
||||
{
|
||||
public DeleteRoleCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Id).NotEmpty().WithMessage("角色 ID 不能为空");
|
||||
}
|
||||
}
|
||||
|
||||
public class AssignPermissionsCommandValidator : AbstractValidator<AssignPermissionsCommand>
|
||||
{
|
||||
public AssignPermissionsCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.RoleId).NotEmpty().WithMessage("角色 ID 不能为空");
|
||||
RuleFor(x => x.PermissionIds).NotEmpty().WithMessage("权限列表不能为空");
|
||||
}
|
||||
}
|
||||
|
||||
public class GetRoleByIdQueryValidator : AbstractValidator<GetRoleByIdQuery>
|
||||
{
|
||||
public GetRoleByIdQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.Id).NotEmpty().WithMessage("角色 ID 不能为空");
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,7 @@ public class CreateUserCommandHandler(RagDbContext db) : IRequestHandler<CreateU
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Username = request.Username,
|
||||
Email = request.Email,
|
||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password)
|
||||
|
||||
@ -0,0 +1,59 @@
|
||||
using FluentValidation;
|
||||
using RAG.Application.Users.Commands;
|
||||
using RAG.Application.Users.Queries;
|
||||
|
||||
namespace RAG.Application.Users.Validators;
|
||||
|
||||
public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
|
||||
{
|
||||
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<UpdateUserCommand>
|
||||
{
|
||||
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<DeleteUserCommand>
|
||||
{
|
||||
public DeleteUserCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Id).NotEmpty().WithMessage("用户 ID 不能为空");
|
||||
}
|
||||
}
|
||||
|
||||
public class AssignRolesCommandValidator : AbstractValidator<AssignRolesCommand>
|
||||
{
|
||||
public AssignRolesCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.UserId).NotEmpty().WithMessage("用户 ID 不能为空");
|
||||
RuleFor(x => x.RoleIds).NotEmpty().WithMessage("角色列表不能为空");
|
||||
}
|
||||
}
|
||||
|
||||
public class GetUserByIdQueryValidator : AbstractValidator<GetUserByIdQuery>
|
||||
{
|
||||
public GetUserByIdQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.Id).NotEmpty().WithMessage("用户 ID 不能为空");
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
using RAG.Domain.Exceptions;
|
||||
|
||||
namespace RAG.Domain.Common;
|
||||
|
||||
/// <summary>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user