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