From c6a95fa28d59130cd0ae3fc2fa80194002d67b43 Mon Sep 17 00:00:00 2001
From: Seany <17074267@qq.com>
Date: Thu, 4 Dec 2025 00:40:12 +0800
Subject: [PATCH] chore
---
.../Extensions/AuthorizationExtension.cs | 52 ++-
.../Extensions/RateLimiterExtension.cs | 56 +++
.../Extensions/SecurityHeadersExtensions.cs | 12 +
.../Extensions/SignalRExtension.cs | 36 ++
.../ExceptionHandlingMiddleware.cs | 86 ++++
.../Middlewares/SecurityHeadersMiddleware.cs | 56 +++
Atomx.Admin/Atomx.Admin/Program.cs | 91 ++++-
.../Atomx.Admin/Services/TokenService.cs | 383 ++++++++++++++++++
Atomx.Admin/Atomx.Admin/appsettings.json | 5 +-
Atomx.Common/Entities/Admin.cs | 6 +
Atomx.Common/Entities/RefreshToken.cs | 6 +
Atomx.Common/Entities/User.cs | 25 +-
Atomx.Common/Models/AuthResponse.cs | 20 +
Atomx.Common/Models/JwtSetting.cs | 5 +
Atomx.Data/CacheServices/CacheService.cs | 11 +
Atomx.Test/Atomx.Test.csproj | 10 +
Atomx.Test/MSTestSettings.cs | 1 +
Atomx.Test/Test1.cs | 11 +
Atomx.sln | 6 +
global.json | 5 +
20 files changed, 874 insertions(+), 9 deletions(-)
create mode 100644 Atomx.Admin/Atomx.Admin/Extensions/RateLimiterExtension.cs
create mode 100644 Atomx.Admin/Atomx.Admin/Extensions/SecurityHeadersExtensions.cs
create mode 100644 Atomx.Admin/Atomx.Admin/Extensions/SignalRExtension.cs
create mode 100644 Atomx.Admin/Atomx.Admin/Middlewares/ExceptionHandlingMiddleware.cs
create mode 100644 Atomx.Admin/Atomx.Admin/Middlewares/SecurityHeadersMiddleware.cs
create mode 100644 Atomx.Admin/Atomx.Admin/Services/TokenService.cs
create mode 100644 Atomx.Common/Models/AuthResponse.cs
create mode 100644 Atomx.Test/Atomx.Test.csproj
create mode 100644 Atomx.Test/MSTestSettings.cs
create mode 100644 Atomx.Test/Test1.cs
create mode 100644 global.json
diff --git a/Atomx.Admin/Atomx.Admin/Extensions/AuthorizationExtension.cs b/Atomx.Admin/Atomx.Admin/Extensions/AuthorizationExtension.cs
index c89f47a..541608d 100644
--- a/Atomx.Admin/Atomx.Admin/Extensions/AuthorizationExtension.cs
+++ b/Atomx.Admin/Atomx.Admin/Extensions/AuthorizationExtension.cs
@@ -4,6 +4,7 @@ using Atomx.Common.Models;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
+using System.Text.Json;
namespace Atomx.Admin.Extensions
{
@@ -14,7 +15,9 @@ namespace Atomx.Admin.Extensions
///
///
///
- public static void AddAuthorize(this IServiceCollection services, IConfiguration Configuration)
+ ///
+ ///
+ public static void AddAuthorize(this IServiceCollection services, IConfiguration Configuration, IWebHostEnvironment environment)
{
var jwtSetting = Configuration.GetSection("Authentication:JwtBearer").Get();
if (jwtSetting == null)
@@ -27,6 +30,9 @@ namespace Atomx.Admin.Extensions
services.AddAuthentication()
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
{
+ options.RequireHttpsMetadata = !environment.IsDevelopment();
+ options.SaveToken = true;
+
options.ClaimsIssuer = jwtSetting.Issuer;
options.TokenValidationParameters = new TokenValidationParameters
{
@@ -36,17 +42,59 @@ namespace Atomx.Admin.Extensions
ValidateIssuerSigningKey = true,
ValidAudience = jwtSetting.Audience,//Audience
ValidIssuer = jwtSetting.Issuer,
- ClockSkew = TimeSpan.FromMinutes(Convert.ToDouble(jwtSetting.ClockSkew)),
+ ClockSkew = TimeSpan.FromMinutes(Convert.ToDouble(jwtSetting.ClockSkew)), //过期时钟偏差
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSetting.SecurityKey))
};
options.Events = new JwtBearerEvents
{
+ //OnTokenValidated = async context =>
+ //{
+ // var userService = context.HttpContext.RequestServices.GetRequiredService();
+ // var userId = context.Principal?.FindFirst("sub")?.Value;
+
+ // if (userId != null)
+ // {
+ // var user = await userService.GetUserByIdAsync(userId);
+ // if (user == null || !user.IsActive)
+ // {
+ // context.Fail("用户不存在或已被禁用");
+ // }
+ // }
+ //},
+ //OnMessageReceived = context =>
+ //{
+ // // SignalR JWT支持
+ // var accessToken = context.Request.Query["access_token"];
+ // var path = context.HttpContext.Request.Path;
+
+ // if (!string.IsNullOrEmpty(accessToken) &&
+ // (path.StartsWithSegments("/hubs") || path.StartsWithSegments("/notification")))
+ // {
+ // context.Token = accessToken;
+ // }
+ // return Task.CompletedTask;
+ //},
+
OnChallenge = context =>
{
var absoluteUri = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}{context.Request.QueryString}";
context.HandleResponse();
context.Response.Redirect($"/account/login?returnUrl={Uri.EscapeDataString(absoluteUri)}");
return Task.CompletedTask;
+
+ //context.HandleResponse();
+ //context.Response.StatusCode = StatusCodes.Status401Unauthorized;
+ //context.Response.ContentType = "application/json";
+
+ //var result = JsonSerializer.Serialize(new
+ //{
+ // StatusCode = 401,
+ // Message = "未授权访问",
+ // Error = context.Error,
+ // ErrorDescription = context.ErrorDescription
+ //});
+
+ //return context.Response.WriteAsync(result);
},
OnAuthenticationFailed = context =>
diff --git a/Atomx.Admin/Atomx.Admin/Extensions/RateLimiterExtension.cs b/Atomx.Admin/Atomx.Admin/Extensions/RateLimiterExtension.cs
new file mode 100644
index 0000000..d54ac35
--- /dev/null
+++ b/Atomx.Admin/Atomx.Admin/Extensions/RateLimiterExtension.cs
@@ -0,0 +1,56 @@
+using System.Threading.RateLimiting;
+
+namespace Atomx.Admin.Extensions
+{
+ public static class RateLimiterExtension
+ {
+ ///
+ /// 添加速率限制
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static void AddRateLimiter(this IServiceCollection services, IConfiguration Configuration, IWebHostEnvironment environment)
+ {
+ // 速率限制
+ services.AddRateLimiter(limiterOptions =>
+ {
+ limiterOptions.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
+
+ // 全局限制
+ limiterOptions.AddPolicy("global", context =>
+ RateLimitPartition.GetFixedWindowLimiter(
+ partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
+ factory: _ => new FixedWindowRateLimiterOptions
+ {
+ PermitLimit = 100,
+ Window = TimeSpan.FromMinutes(1),
+ QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
+ QueueLimit = 2
+ }));
+
+ // 登录限制
+ limiterOptions.AddPolicy("login", context =>
+ RateLimitPartition.GetFixedWindowLimiter(
+ partitionKey: $"{context.Connection.RemoteIpAddress}_{context.Request.Path}",
+ factory: _ => new FixedWindowRateLimiterOptions
+ {
+ PermitLimit = 5,
+ Window = TimeSpan.FromMinutes(15),
+ QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
+ QueueLimit = 0
+ }));
+ });
+
+
+ //// HTTP客户端工厂
+ //services.AddHttpClient("ApiClient")
+ // .AddTransientHttpErrorPolicy(policy =>
+ // policy.WaitAndRetryAsync(3, retryAttempt =>
+ // TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))))
+ // .AddTransientHttpErrorPolicy(policy =>
+ // policy.CircuitBreakerAsync(5, TimeSpan.FromSeconds(30)));
+ }
+ }
+}
diff --git a/Atomx.Admin/Atomx.Admin/Extensions/SecurityHeadersExtensions.cs b/Atomx.Admin/Atomx.Admin/Extensions/SecurityHeadersExtensions.cs
new file mode 100644
index 0000000..5c8aad5
--- /dev/null
+++ b/Atomx.Admin/Atomx.Admin/Extensions/SecurityHeadersExtensions.cs
@@ -0,0 +1,12 @@
+using Atomx.Admin.Middlewares;
+
+namespace Atomx.Admin.Extensions
+{
+ public static class SecurityHeadersExtensions
+ {
+ public static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder builder)
+ {
+ return builder.UseMiddleware();
+ }
+ }
+}
diff --git a/Atomx.Admin/Atomx.Admin/Extensions/SignalRExtension.cs b/Atomx.Admin/Atomx.Admin/Extensions/SignalRExtension.cs
new file mode 100644
index 0000000..96b3d4b
--- /dev/null
+++ b/Atomx.Admin/Atomx.Admin/Extensions/SignalRExtension.cs
@@ -0,0 +1,36 @@
+namespace Atomx.Admin.Extensions
+{
+ public static class SignalRExtension
+ {
+ ///
+ /// 添加SignalR
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static void AddSignalR(this IServiceCollection services, IConfiguration Configuration, IWebHostEnvironment environment)
+ {
+ //// SignalR
+ //services.AddSignalR(options =>
+ //{
+ // options.EnableDetailedErrors = environment.IsDevelopment();
+ // options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
+ // options.HandshakeTimeout = TimeSpan.FromSeconds(15);
+ // options.KeepAliveInterval = TimeSpan.FromSeconds(10);
+ // options.MaximumReceiveMessageSize = 1024 * 1024; // 1MB
+
+ // if (!environment.IsDevelopment())
+ // {
+ // options.StreamBufferCapacity = 10;
+ // }
+ //})
+ //.AddMessagePackProtocol()
+ //.AddStackExchangeRedis(redisConnectionString, options =>
+ //{
+ // options.Configuration.ChannelPrefix = "BlazorAuthApp:SignalR";
+ //});
+
+ }
+ }
+}
diff --git a/Atomx.Admin/Atomx.Admin/Middlewares/ExceptionHandlingMiddleware.cs b/Atomx.Admin/Atomx.Admin/Middlewares/ExceptionHandlingMiddleware.cs
new file mode 100644
index 0000000..e95cfdd
--- /dev/null
+++ b/Atomx.Admin/Atomx.Admin/Middlewares/ExceptionHandlingMiddleware.cs
@@ -0,0 +1,86 @@
+using Microsoft.AspNetCore.Mvc;
+using System.Net;
+using System.Text.Json;
+
+namespace Atomx.Admin.Middlewares
+{
+ ///
+ /// 异常处理中间件
+ ///
+ public class ExceptionHandlingMiddleware
+ {
+ private readonly RequestDelegate _next;
+ private readonly ILogger _logger;
+ private readonly IWebHostEnvironment _environment;
+
+ public ExceptionHandlingMiddleware(
+ RequestDelegate next,
+ ILogger logger,
+ IWebHostEnvironment environment)
+ {
+ _next = next;
+ _logger = logger;
+ _environment = environment;
+ }
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ try
+ {
+ await _next(context);
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(context, ex);
+ }
+ }
+
+ private async Task HandleExceptionAsync(HttpContext context, Exception exception)
+ {
+ _logger.LogError(exception, "未处理的异常: {Message}", exception.Message);
+
+ var response = context.Response;
+ response.ContentType = "application/json";
+
+ var problemDetails = new ProblemDetails
+ {
+ Title = "发生错误",
+ Status = (int)HttpStatusCode.InternalServerError,
+ Instance = context.Request.Path
+ };
+
+ // 根据异常类型设置状态码
+ switch (exception)
+ {
+ case UnauthorizedAccessException:
+ problemDetails.Status = (int)HttpStatusCode.Unauthorized;
+ problemDetails.Title = "未授权访问";
+ break;
+ case KeyNotFoundException:
+ problemDetails.Status = (int)HttpStatusCode.NotFound;
+ problemDetails.Title = "资源未找到";
+ break;
+ case ArgumentException:
+ case InvalidOperationException:
+ problemDetails.Status = (int)HttpStatusCode.BadRequest;
+ problemDetails.Title = "无效请求";
+ break;
+ }
+
+ // 开发环境包含详细错误信息
+ if (_environment.IsDevelopment())
+ {
+ problemDetails.Extensions.Add("exception", exception.Message);
+ problemDetails.Extensions.Add("stackTrace", exception.StackTrace);
+ problemDetails.Extensions.Add("innerException", exception.InnerException?.Message);
+ }
+ else
+ {
+ problemDetails.Detail = "处理您的请求时发生错误。请稍后重试。";
+ }
+
+ response.StatusCode = problemDetails.Status.Value;
+ await response.WriteAsync(JsonSerializer.Serialize(problemDetails));
+ }
+ }
+}
diff --git a/Atomx.Admin/Atomx.Admin/Middlewares/SecurityHeadersMiddleware.cs b/Atomx.Admin/Atomx.Admin/Middlewares/SecurityHeadersMiddleware.cs
new file mode 100644
index 0000000..37e7ca8
--- /dev/null
+++ b/Atomx.Admin/Atomx.Admin/Middlewares/SecurityHeadersMiddleware.cs
@@ -0,0 +1,56 @@
+namespace Atomx.Admin.Middlewares
+{
+ ///
+ /// 安全中间件
+ ///
+ public class SecurityHeadersMiddleware
+ {
+ private readonly RequestDelegate _next;
+ private readonly IWebHostEnvironment _environment;
+
+ public SecurityHeadersMiddleware(RequestDelegate next, IWebHostEnvironment environment)
+ {
+ _next = next;
+ _environment = environment;
+ }
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ // 添加安全头
+ if (!context.Response.HasStarted)
+ {
+ var headers = context.Response.Headers;
+
+ // CSP策略
+ if (!_environment.IsDevelopment())
+ {
+ headers.Append("Content-Security-Policy",
+ "default-src 'self'; " +
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
+ "style-src 'self' 'unsafe-inline'; " +
+ "img-src 'self' data: https:; " +
+ "font-src 'self'; " +
+ "connect-src 'self' wss:; " +
+ "frame-ancestors 'none';");
+ }
+
+ // 其他安全头
+ headers.Append("X-Content-Type-Options", "nosniff");
+ headers.Append("X-Frame-Options", "DENY");
+ headers.Append("X-XSS-Protection", "1; mode=block");
+ headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
+ headers.Append("Permissions-Policy",
+ "camera=(), microphone=(), geolocation=(), interest-cohort=()");
+
+ // HSTS(在生产环境中启用)
+ if (!_environment.IsDevelopment())
+ {
+ headers.Append("Strict-Transport-Security",
+ "max-age=31536000; includeSubDomains; preload");
+ }
+ }
+
+ await _next(context);
+ }
+ }
+}
diff --git a/Atomx.Admin/Atomx.Admin/Program.cs b/Atomx.Admin/Atomx.Admin/Program.cs
index 1555ec9..91bef55 100644
--- a/Atomx.Admin/Atomx.Admin/Program.cs
+++ b/Atomx.Admin/Atomx.Admin/Program.cs
@@ -14,6 +14,9 @@ using Blazored.LocalStorage;
using Mapster;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.AspNetCore.Diagnostics.HealthChecks;
+using Microsoft.AspNetCore.HttpOverrides;
+using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.EntityFrameworkCore;
using Scalar.AspNetCore;
using Serilog;
@@ -68,22 +71,48 @@ builder.Services.AddScoped();
builder.Services.AddHttpClientApiService(builder.Configuration["WebApi:ServerUrl"] ?? "http://localhost");
builder.Services.AddDataService();
-builder.Services.AddAuthorize(builder.Configuration);
+builder.Services.AddAuthorize(builder.Configuration,builder.Environment);
var connection = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
builder.Services.AddDbContext(options => options.UseNpgsql(connection, p => p.MigrationsHistoryTable("__DbMigrationsHistory")));
+var redisConnection = builder.Configuration.GetConnectionString("cache");
+
builder.Services.AddStackExchangeRedisCache(options =>
{
#region
- options.Configuration = $"{builder.Configuration.GetConnectionString("cache")}";
+ options.Configuration = redisConnection;
options.InstanceName = builder.Configuration["RedisCache:InstanceName"];
#endregion
});
+//// Redisֲʽ
+//builder.Services.AddSingleton(sp =>
+// ConnectionMultiplexer.Connect(redisConnection));
+
+// Ӧѹ
+builder.Services.AddResponseCompression(options =>
+{
+ options.EnableForHttps = true;
+ options.Providers.Add();
+ options.Providers.Add();
+});
+
+// Antiforgery
+builder.Services.AddAntiforgery(options =>
+{
+ options.HeaderName = "X-CSRF-TOKEN";
+ options.Cookie.Name = ".Atomx.Antiforgery";
+ options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
+ options.Cookie.SameSite = SameSiteMode.Strict;
+ options.Cookie.HttpOnly = true;
+});
+
+
builder.Services.AddOpenApi();
+
builder.Services.AddAntDesign();
builder.Services.Configure(builder.Configuration.GetSection("Monitoring"));
@@ -92,6 +121,12 @@ var app = builder.Build();
app.AddDataMigrate();
+// HTTPܵ
+app.UseForwardedHeaders(new ForwardedHeadersOptions
+{
+ ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
+});
+
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
@@ -104,7 +139,11 @@ else
app.UseExceptionHandler("/Error", createScopeForErrors: true);
}
+// ȫͷ
+app.UseSecurityHeaders();
+// Ӧѹ
+app.UseResponseCompression();
app.UseCors(option =>
{
@@ -113,7 +152,18 @@ app.UseCors(option =>
option.AllowAnyHeader();
});
-app.UseStaticFiles();
+app.UseStaticFiles(new StaticFileOptions
+{
+ OnPrepareResponse = ctx =>
+ {
+ // 澲̬ļ
+ ctx.Context.Response.Headers.Append(
+ "Cache-Control", $"public, max-age={31536000}");
+ }
+});
+
+//
+app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
@@ -124,10 +174,45 @@ app.MapStaticAssets();
app.UseMiddleware();
+//// ˵
+//app.MapHealthChecks("/health", new HealthCheckOptions
+//{
+// ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse,
+// Predicate = _ => true,
+// AllowCachingResponses = false
+//});
+
+//app.MapHealthChecks("/health/ready", new HealthCheckOptions
+//{
+// ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse,
+// Predicate = check => check.Tags.Contains("ready")
+//});
+
+//app.MapHealthChecks("/health/live", new HealthCheckOptions
+//{
+// ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse,
+// Predicate = _ => false
+//});
+
+// SignalR˵
+//app.MapHub("/hubs/chat");
+//app.MapHub("/hubs/notification");
+
app.MapControllers();
app.MapRazorComponents()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(Atomx.Admin.Client._Imports).Assembly);
+
+//// ȷݿⴴǨ
+//await using (var scope = app.Services.CreateAsyncScope())
+//{
+// var dbContext = scope.ServiceProvider.GetRequiredService();
+// await dbContext.Database.MigrateAsync();
+
+// var seeder = scope.ServiceProvider.GetRequiredService();
+// await seeder.SeedAsync();
+//}
+
app.Run();
diff --git a/Atomx.Admin/Atomx.Admin/Services/TokenService.cs b/Atomx.Admin/Atomx.Admin/Services/TokenService.cs
new file mode 100644
index 0000000..874ce2a
--- /dev/null
+++ b/Atomx.Admin/Atomx.Admin/Services/TokenService.cs
@@ -0,0 +1,383 @@
+using Atomx.Common.Entities;
+using Atomx.Common.Models;
+using Atomx.Data;
+using Atomx.Data.CacheServices;
+using Atomx.Utils.Extension;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Caching.Distributed;
+using Microsoft.Extensions.Options;
+using Microsoft.IdentityModel.Tokens;
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+
+namespace Atomx.Admin.Services
+{
+ public interface ITokenService
+ {
+ Task GenerateTokenAsync(User user, string? ipAddress = null, string? userAgent = null);
+ Task RefreshTokenAsync(string token, string refreshToken, string? ipAddress = null);
+ Task RevokeTokenAsync(string refreshToken, string? ipAddress = null);
+ Task ValidateTokenAsync(string token);
+ Task GetUserFromTokenAsync(string token);
+ }
+
+ public class TokenService : ITokenService
+ {
+ readonly DataContext _dbContext;
+ readonly ICacheService _cacheService;
+ readonly JwtSetting _jwtSetting;
+ private readonly ILogger _logger;
+ private readonly SecurityKey _securityKey;
+ private readonly SigningCredentials _signingCredentials;
+
+ public TokenService(
+ ILogger logger, DataContext dataContext, ICacheService cacheService, JwtSetting jwtSetting)
+ {
+ _logger = logger;
+ _dbContext = dataContext;
+ _cacheService = cacheService;
+ _jwtSetting = jwtSetting;
+
+
+
+ var key = Encoding.UTF8.GetBytes(_jwtSetting.SecurityKey);
+ _securityKey = new SymmetricSecurityKey(key);
+ _signingCredentials = new SigningCredentials(_securityKey, SecurityAlgorithms.HmacSha256);
+ }
+
+ public async Task GenerateTokenAsync(User user, string? ipAddress = null, string? userAgent = null)
+ {
+ if (user == null)
+ throw new ArgumentNullException(nameof(user));
+
+ // 检查用户是否被锁定
+ if (user.LockoutEndTime.HasValue && user.LockoutEndTime > DateTime.UtcNow)
+ throw new InvalidOperationException("账户已被锁定");
+
+ // 生成访问令牌
+ var accessToken = GenerateAccessToken(user);
+
+ // 生成刷新令牌
+ var refreshToken = GenerateRefreshToken();
+
+ // 保存刷新令牌到数据库
+ var refreshTokenEntity = new RefreshToken
+ {
+ Token = HashRefreshToken(refreshToken),
+ UserId = user.Id,
+ IssuedTime = DateTime.UtcNow,
+ ExpiresTime = DateTime.UtcNow.AddDays(_jwtSetting.RefreshTokenExpirationMinutes),
+ Ip = ipAddress,
+ UserAgent = userAgent
+ };
+
+ // 移除旧的刷新令牌(安全策略)
+ await RemoveOldRefreshTokensAsync(user.Id);
+
+ _dbContext.RefreshTokens.Add(refreshTokenEntity);
+ await _dbContext.SaveChangesAsync();
+
+ // 记录审计日志
+ //await _auditService.LogAsync(new AuditLog
+ //{
+ // UserId = user.Id,
+ // Action = "GenerateToken",
+ // Timestamp = DateTime.UtcNow,
+ // IpAddress = ipAddress,
+ // UserAgent = userAgent,
+ // Details = $"Token generated for user {user.Username}"
+ //});
+
+ // 缓存令牌(防止重复使用)
+ await CacheTokenAsync(accessToken, user.Id);
+
+ return new AuthResponse
+ {
+ Token = accessToken,
+ RefreshToken = refreshToken,
+ TokenExpiry = DateTime.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes)
+ };
+ }
+
+ public async Task RefreshTokenAsync(string token, string refreshToken, string? ipAddress = null)
+ {
+ var principal = GetPrincipalFromExpiredToken(token);
+ var userId = principal.FindFirst("sub")?.Value.ToLong();
+
+ if (userId == 0)
+ throw new SecurityTokenException("无效的令牌");
+
+ var user = await _dbContext.Users
+ .SingleOrDefaultAsync(u => u.Id == userId && u.IsActive);
+
+ if (user == null)
+ throw new SecurityTokenException("用户不存在或已被禁用");
+
+ // 验证刷新令牌
+ var hashedRefreshToken = HashRefreshToken(refreshToken);
+ var storedToken = await _dbContext.RefreshTokens
+ .FirstOrDefaultAsync(rt =>
+ rt.Token == hashedRefreshToken &&
+ rt.UserId == userId &&
+ rt.ExpiresTime > DateTime.UtcNow &&
+ !rt.IsRevoked);
+
+ if (storedToken == null)
+ throw new SecurityTokenException("无效的刷新令牌");
+
+ // 撤销旧的刷新令牌
+ storedToken.IsRevoked = true;
+ storedToken.RevokedTime = DateTime.UtcNow;
+ storedToken.Ip = ipAddress;
+
+ // 生成新的令牌对
+ var newTokenResponse = await GenerateTokenAsync(user, ipAddress, storedToken.UserAgent);
+
+ //// 记录审计日志
+ //await _auditService.LogAsync(new AuditLog
+ //{
+ // UserId = user.Id,
+ // Action = "RefreshToken",
+ // Timestamp = DateTime.UtcNow,
+ // IpAddress = ipAddress,
+ // Details = "Token refreshed successfully"
+ //});
+
+ return newTokenResponse;
+ }
+
+ public async Task RevokeTokenAsync(string refreshToken, string? ipAddress = null)
+ {
+ var hashedToken = HashRefreshToken(refreshToken);
+ var token = await _dbContext.RefreshTokens
+ .FirstOrDefaultAsync(rt => rt.Token == hashedToken);
+
+ if (token == null || token.IsRevoked)
+ return false;
+
+ token.IsRevoked = true;
+ token.RevokedTime = DateTime.UtcNow;
+ token.Ip = ipAddress;
+
+ await _dbContext.SaveChangesAsync();
+
+ // 清除缓存
+ await _cacheService.Remove($"user:{token.UserId}");
+
+ //await _auditService.LogAsync(new AuditLog
+ //{
+ // UserId = token.UserId,
+ // Action = "RevokeToken",
+ // Timestamp = DateTime.UtcNow,
+ // IpAddress = ipAddress,
+ // Details = "Token revoked"
+ //});
+
+ return true;
+ }
+
+ public async Task ValidateTokenAsync(string token)
+ {
+ try
+ {
+ // 检查令牌是否在缓存中(已被撤销)
+ var cacheKey = $"revoked_token:{HashToken(token)}";
+ var cached = await _cacheService.GetCacheString(cacheKey);
+ if (cached != null)
+ return false;
+
+ var tokenHandler = new JwtSecurityTokenHandler();
+ var validationParameters = new TokenValidationParameters
+ {
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKey = _securityKey,
+ ValidateIssuer = true,
+ ValidIssuer = _jwtSetting.Issuer,
+ ValidateAudience = true,
+ ValidAudience = _jwtSetting.Audience,
+ ValidateLifetime = true,
+ ClockSkew = TimeSpan.Zero
+ };
+
+ tokenHandler.ValidateToken(token, validationParameters, out _);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ public async Task GetUserFromTokenAsync(string token)
+ {
+ try
+ {
+ var principal = GetPrincipalFromToken(token);
+ var userId = principal.FindFirst("sub")?.Value.ToLong();
+
+ if (userId == 0)
+ return null;
+
+ // 尝试从缓存获取
+ var cacheKey = $"user:{userId}";
+ var cachedUser = await _cacheService.GetCacheString(cacheKey);
+
+ if (!string.IsNullOrEmpty(cachedUser))
+ {
+ return JsonSerializer.Deserialize(cachedUser);
+ }
+
+ // 从数据库获取
+ var user = await _dbContext.Users
+ .FirstOrDefaultAsync(u => u.Id == userId && u.IsActive);
+
+ if (user != null)
+ {
+ // 缓存用户信息(5分钟)
+ await _cacheService.SetCacheAsync(cacheKey, JsonSerializer.Serialize(user), 5);
+ }
+
+ return user;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private string GenerateAccessToken(User user)
+ {
+ var claims = new[]
+ {
+ new Claim("sub", user.Id.ToString()),
+ new Claim("jti", Guid.NewGuid().ToString()),
+ new Claim("name", user.Name),
+ new Claim("email", user.Email),
+ new Claim("email_confirmed", user.EmailConfirmed.ToString().ToLower()),
+ new Claim("iat", DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64)
+ }.ToList();
+
+ // 添加角色声明
+ //foreach (var userRole in user.UserRoles)
+ //{
+ // claims.Add(new Claim("role", userRole.Role.ToString()));
+ //}
+
+ var tokenDescriptor = new SecurityTokenDescriptor
+ {
+ Subject = new ClaimsIdentity(claims),
+ Expires = DateTime.UtcNow.AddMinutes(_jwtSetting.AccessTokenExpirationMinutes),
+ Issuer = _jwtSetting.Issuer,
+ Audience = _jwtSetting.Audience,
+ SigningCredentials = _signingCredentials,
+ NotBefore = DateTime.UtcNow
+ };
+
+ var tokenHandler = new JwtSecurityTokenHandler();
+ var token = tokenHandler.CreateToken(tokenDescriptor);
+ return tokenHandler.WriteToken(token);
+ }
+
+ private string GenerateRefreshToken()
+ {
+ var randomNumber = new byte[64];
+ using var rng = RandomNumberGenerator.Create();
+ rng.GetBytes(randomNumber);
+ return Convert.ToBase64String(randomNumber);
+ }
+
+ private ClaimsPrincipal GetPrincipalFromToken(string token)
+ {
+ var tokenHandler = new JwtSecurityTokenHandler();
+
+ try
+ {
+ var principal = tokenHandler.ValidateToken(token, new TokenValidationParameters
+ {
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKey = _securityKey,
+ ValidateIssuer = true,
+ ValidIssuer = _jwtSetting.Issuer,
+ ValidateAudience = true,
+ ValidAudience = _jwtSetting.Audience,
+ ValidateLifetime = true,
+ ClockSkew = TimeSpan.Zero
+ }, out _);
+
+ return principal;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "令牌验证失败");
+ throw new SecurityTokenException("无效的令牌", ex);
+ }
+ }
+
+ private ClaimsPrincipal GetPrincipalFromExpiredToken(string token)
+ {
+ var tokenHandler = new JwtSecurityTokenHandler();
+
+ try
+ {
+ var principal = tokenHandler.ValidateToken(token, new TokenValidationParameters
+ {
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKey = _securityKey,
+ ValidateIssuer = true,
+ ValidIssuer = _jwtSetting.Issuer,
+ ValidateAudience = true,
+ ValidAudience = _jwtSetting.Audience,
+ ValidateLifetime = false, // 注意:这里不验证过期时间
+ ClockSkew = TimeSpan.Zero
+ }, out _);
+
+ return principal;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "过期令牌验证失败");
+ throw new SecurityTokenException("无效的令牌", ex);
+ }
+ }
+
+ private string HashRefreshToken(string refreshToken)
+ {
+ using var sha256 = SHA256.Create();
+ var bytes = Encoding.UTF8.GetBytes(refreshToken + _jwtSetting.SecurityKey);
+ var hash = sha256.ComputeHash(bytes);
+ return Convert.ToBase64String(hash);
+ }
+
+ private string HashToken(string token)
+ {
+ using var sha256 = SHA256.Create();
+ var bytes = Encoding.UTF8.GetBytes(token);
+ var hash = sha256.ComputeHash(bytes);
+ return Convert.ToBase64String(hash);
+ }
+
+ private async Task RemoveOldRefreshTokensAsync(long userId)
+ {
+ var tokens = await _dbContext.RefreshTokens
+ .Where(rt => rt.UserId == userId && !rt.IsRevoked)
+ .OrderByDescending(rt => rt.ExpiresTime)
+ .Skip(_jwtSetting.MaxRefreshTokensPerUser - 1)
+ .ToListAsync();
+
+ foreach (var token in tokens)
+ {
+ token.IsRevoked = true;
+ token.RevokedTime = DateTime.UtcNow;
+ }
+ }
+
+ private async Task CacheTokenAsync(string token, long userId)
+ {
+ var cacheKey = $"token:{HashToken(token)}";
+ await _cacheService.SetCacheAsync(cacheKey, userId, _jwtSetting.AccessTokenExpirationMinutes);
+ }
+ }
+}
diff --git a/Atomx.Admin/Atomx.Admin/appsettings.json b/Atomx.Admin/Atomx.Admin/appsettings.json
index 3ec0b7a..d53c0bf 100644
--- a/Atomx.Admin/Atomx.Admin/appsettings.json
+++ b/Atomx.Admin/Atomx.Admin/appsettings.json
@@ -16,9 +16,10 @@
"Issuer": "http://api.sampleapi.com",
"Audience": "SampleApi",
"SecurityKey": "SecurityKey23456SecurityKey23456",
- "ClockSkew": "600",
+ "ClockSkew": "10",
"AccessTokenExpirationMinutes": "60",
- "RefreshTokenExpirationMinutes": "60"
+ "RefreshTokenExpirationMinutes": "60",
+ "MaxRefreshTokensPerUser": "3"
}
},
diff --git a/Atomx.Common/Entities/Admin.cs b/Atomx.Common/Entities/Admin.cs
index 85bbc88..2fc7e61 100644
--- a/Atomx.Common/Entities/Admin.cs
+++ b/Atomx.Common/Entities/Admin.cs
@@ -79,6 +79,12 @@ namespace Atomx.Common.Entities
[Column(TypeName = "varchar(50)")]
public string LastIp { get; set; } = string.Empty;
+ ///
+ /// 锁定结束时间
+ ///
+ [Column(TypeName = "timestamptz")]
+ public DateTime? LockoutEndTime { get; set; }
+
///
/// 数据创建时间
///
diff --git a/Atomx.Common/Entities/RefreshToken.cs b/Atomx.Common/Entities/RefreshToken.cs
index a13f506..afe7415 100644
--- a/Atomx.Common/Entities/RefreshToken.cs
+++ b/Atomx.Common/Entities/RefreshToken.cs
@@ -42,6 +42,12 @@ namespace Atomx.Common.Entities
///
public bool IsRevoked { get; set; }
+ ///
+ /// 令牌回收时间
+ ///
+ [Column(TypeName = "timestamptz")]
+ public DateTime RevokedTime { get; set; }
+
///
/// 用户IP
///
diff --git a/Atomx.Common/Entities/User.cs b/Atomx.Common/Entities/User.cs
index bf7a42c..77455f4 100644
--- a/Atomx.Common/Entities/User.cs
+++ b/Atomx.Common/Entities/User.cs
@@ -22,13 +22,13 @@ namespace Atomx.Common.Entities
///
/// 邮箱
///
- [Column(TypeName = "varchar(64)")]
+ [Column(TypeName = "varchar(128)")]
public string Email { get; set; } = string.Empty;
///
/// 手机号
///
- [Column(TypeName = "varchar(64)")]
+ [Column(TypeName = "varchar(32)")]
public string Mobile { get; set; } = string.Empty;
///
@@ -43,6 +43,27 @@ namespace Atomx.Common.Entities
[Column(TypeName = "varchar(32)")]
public string Password { get; set; } = string.Empty;
+ ///
+ /// 账号是否激活
+ ///
+ public bool IsActive { get; set; } = true;
+
+ ///
+ /// 是否Email确认
+ ///
+ public bool EmailConfirmed { get; set; } = false;
+
+ ///
+ /// 锁定结束时间
+ ///
+ [Column(TypeName = "timestamptz")]
+ public DateTime? LockoutEndTime { get; set; }
+
+ ///
+ /// 登录失败次数
+ ///
+ public int FailedLoginAttempts { get; set; } = 0;
+
///
/// 注册数据创建时间
///
diff --git a/Atomx.Common/Models/AuthResponse.cs b/Atomx.Common/Models/AuthResponse.cs
new file mode 100644
index 0000000..1915649
--- /dev/null
+++ b/Atomx.Common/Models/AuthResponse.cs
@@ -0,0 +1,20 @@
+namespace Atomx.Common.Models
+{
+ public class AuthResponse
+ {
+ ///
+ /// 身份令牌
+ ///
+ public string Token { get; set; } = string.Empty;
+
+ ///
+ /// 刷新身份的令牌
+ ///
+ public string RefreshToken { get; set; } = string.Empty;
+
+ ///
+ /// 身份令牌过期时间
+ ///
+ public DateTime TokenExpiry { get; set; }
+ }
+}
diff --git a/Atomx.Common/Models/JwtSetting.cs b/Atomx.Common/Models/JwtSetting.cs
index 09e57b9..6d8199e 100644
--- a/Atomx.Common/Models/JwtSetting.cs
+++ b/Atomx.Common/Models/JwtSetting.cs
@@ -37,5 +37,10 @@ namespace Atomx.Common.Models
/// 刷新令牌过期时间
///
public int RefreshTokenExpirationMinutes { get; set; }
+
+ ///
+ /// 每个用户刷新令牌最大数量
+ ///
+ public int MaxRefreshTokensPerUser { get; set; }
}
}
diff --git a/Atomx.Data/CacheServices/CacheService.cs b/Atomx.Data/CacheServices/CacheService.cs
index b946f6a..99fd278 100644
--- a/Atomx.Data/CacheServices/CacheService.cs
+++ b/Atomx.Data/CacheServices/CacheService.cs
@@ -2,6 +2,7 @@
using Microsoft.Extensions.Caching.Distributed;
using System.Text;
using System.Text.Json;
+using System.Threading.Tasks;
namespace Atomx.Data.CacheServices
{
@@ -40,6 +41,12 @@ namespace Atomx.Data.CacheServices
///
Task Remove(string key);
+ ///
+ /// 获取缓存的字符串内容
+ ///
+ ///
+ ///
+ Task GetCacheString(string key);
}
@@ -61,6 +68,10 @@ namespace Atomx.Data.CacheServices
return Task.CompletedTask;
}
+ public async Task GetCacheString(string key)
+ {
+ return await _cache.GetStringAsync(key);
+ }
public T? GetCache(string key)
{
diff --git a/Atomx.Test/Atomx.Test.csproj b/Atomx.Test/Atomx.Test.csproj
new file mode 100644
index 0000000..4156292
--- /dev/null
+++ b/Atomx.Test/Atomx.Test.csproj
@@ -0,0 +1,10 @@
+
+
+
+ net10.0
+ latest
+ enable
+ enable
+
+
+
diff --git a/Atomx.Test/MSTestSettings.cs b/Atomx.Test/MSTestSettings.cs
new file mode 100644
index 0000000..aaf278c
--- /dev/null
+++ b/Atomx.Test/MSTestSettings.cs
@@ -0,0 +1 @@
+[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]
diff --git a/Atomx.Test/Test1.cs b/Atomx.Test/Test1.cs
new file mode 100644
index 0000000..10ff1e5
--- /dev/null
+++ b/Atomx.Test/Test1.cs
@@ -0,0 +1,11 @@
+namespace Atomx.Test
+{
+ [TestClass]
+ public sealed class Test1
+ {
+ [TestMethod]
+ public void TestMethod1()
+ {
+ }
+ }
+}
diff --git a/Atomx.sln b/Atomx.sln
index b8945fb..2b3ddd2 100644
--- a/Atomx.sln
+++ b/Atomx.sln
@@ -17,6 +17,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atomx.Utils", "Atomx.Utils\
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atomx.WebAPI", "Atomx.WebAPI\Atomx.WebAPI.csproj", "{D214046F-0D80-4361-9964-395234C6FF11}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atomx.Test", "Atomx.Test\Atomx.Test.csproj", "{60D4714E-1DBE-4381-9B22-5894F1310561}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -51,6 +53,10 @@ Global
{D214046F-0D80-4361-9964-395234C6FF11}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D214046F-0D80-4361-9964-395234C6FF11}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D214046F-0D80-4361-9964-395234C6FF11}.Release|Any CPU.Build.0 = Release|Any CPU
+ {60D4714E-1DBE-4381-9B22-5894F1310561}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {60D4714E-1DBE-4381-9B22-5894F1310561}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {60D4714E-1DBE-4381-9B22-5894F1310561}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {60D4714E-1DBE-4381-9B22-5894F1310561}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..802ab21
--- /dev/null
+++ b/global.json
@@ -0,0 +1,5 @@
+{
+ "test": {
+ "runner": "Microsoft.Testing.Platform"
+ }
+}
\ No newline at end of file