diff --git a/Atomx.Admin/Atomx.Admin/Controllers/SignController.cs b/Atomx.Admin/Atomx.Admin/Controllers/SignController.cs index 518b906..b1371bd 100644 --- a/Atomx.Admin/Atomx.Admin/Controllers/SignController.cs +++ b/Atomx.Admin/Atomx.Admin/Controllers/SignController.cs @@ -1,8 +1,10 @@  +using AntDesign; using Atomx.Admin.Client.Models; using Atomx.Admin.Client.Validators; using Atomx.Admin.Services; using Atomx.Admin.Utils; +using Atomx.Common.Entities; using Atomx.Common.Models; using Atomx.Common.Utils; using Atomx.Data; @@ -18,6 +20,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.IdentityModel.Tokens; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; +using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; @@ -56,13 +59,15 @@ namespace Atomx.Admin.Controllers [AllowAnonymous] public async Task Login(LoginModel model) { + var result = new ApiResult(); + var validator = new LoginModelValidator(); var validation = validator.Validate(model); if (!validation.IsValid) { var message = validation.Errors.FirstOrDefault()?.ErrorMessage; - var result = new ApiResult().IsFail(message ?? string.Empty, null); + result = result.IsFail(message ?? string.Empty, null); return new JsonResult(result); } @@ -86,12 +91,12 @@ namespace Atomx.Admin.Controllers if (user == null) { - var result = new ApiResult().IsFail("用户不存在", null); + result = result.IsFail("用户不存在", null); return new JsonResult(result); } if (user.Password != model.Password.ToMd5Password()) { - var result = new ApiResult().IsFail("账号密码不正确", null); + result = result.IsFail("账号密码不正确", null); return new JsonResult(result); } @@ -119,19 +124,36 @@ namespace Atomx.Admin.Controllers Audience = audience }; - var tokenString = tokenHandler.WriteToken(tokenHandler.CreateToken(tokenDescriptor)); - var loginResult = new ApiResult().IsSuccess(tokenString); + + var tokenString = tokenHandler.WriteToken(tokenHandler.CreateToken(tokenDescriptor)); + var refreshToken = GenerateRefreshToken(); + result.Data = new AuthResponse + { + Token = tokenString, + RefreshToken = refreshToken, + TokenExpiry = tokenDescriptor.Expires!.Value + }; + + var refreshTokenItem = new RefreshToken + { + Token = refreshToken, + UserId = user.Id, + ExpiresTime = tokenDescriptor.Expires.Value, + Ip = _identityService.GetClientIp(), + IssuedTime = DateTime.UtcNow, + IsRevoked = false, + UserAgent = _identityService.GetUserAgent() + }; user.LastLogin = DateTime.UtcNow; user.LastIp = _identityService.GetClientIp(); user.LoginCount++; + _dbContext.Admins.Update(user); + _dbContext.RefreshTokens.Add(refreshTokenItem); + _dbContext.SaveChanges(); - //((PersistingRevalidatingAuthenticationStateProvider) _authenticationStateProvider). - - await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(claimsIdentity)); - - return new JsonResult(loginResult); + return new JsonResult(result); } @@ -146,5 +168,13 @@ namespace Atomx.Admin.Controllers await HttpContext.SignOutAsync(); return new JsonResult(new ApiResult()); } + + private string GenerateRefreshToken() + { + var randomNumber = new byte[32]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(randomNumber); + return Convert.ToBase64String(randomNumber); + } } } diff --git a/Atomx.Admin/Atomx.Admin/Extensions/AuthorizationExtension.cs b/Atomx.Admin/Atomx.Admin/Extensions/AuthorizationExtension.cs index c89f47a..1beddc8 100644 --- a/Atomx.Admin/Atomx.Admin/Extensions/AuthorizationExtension.cs +++ b/Atomx.Admin/Atomx.Admin/Extensions/AuthorizationExtension.cs @@ -2,8 +2,10 @@ using Atomx.Common.Constant; using Atomx.Common.Models; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.ResponseCompression; using Microsoft.IdentityModel.Tokens; using System.Text; +using System.Text.Json; namespace Atomx.Admin.Extensions { @@ -14,7 +16,7 @@ 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) @@ -28,6 +30,7 @@ namespace Atomx.Admin.Extensions .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => { options.ClaimsIssuer = jwtSetting.Issuer; + options.RequireHttpsMetadata = !environment.IsDevelopment(); //是否要求HTTPS,生产环境建议为true options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, @@ -36,17 +39,46 @@ 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 { + // SignalR JWT支持 + OnMessageReceived = context => + { + //从查询字符串中获取令牌(如果存在) + 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 => @@ -58,6 +90,21 @@ namespace Atomx.Admin.Extensions } return Task.CompletedTask; } + + //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("用户不存在或已被禁用"); + // } + // } + //}, }; }); @@ -85,6 +132,34 @@ namespace Atomx.Admin.Extensions // policy.Requirements.Add(new PermissionRequirement(PermissionConstants.Users.Edit)); //}); }); + + + + //// HTTP客户端工厂 + //services.AddHttpClient("ApiClient") + // .AddTransientHttpErrorPolicy(policy => + // policy.WaitAndRetryAsync(3, retryAttempt => + // TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)))) + // .AddTransientHttpErrorPolicy(policy => + // policy.CircuitBreakerAsync(5, TimeSpan.FromSeconds(30))); + + + //// API版本控制 + //services.AddApiVersioning(options => + //{ + // options.DefaultApiVersion = new Microsoft.AspNetCore.Mvc.ApiVersion(1, 0); + // options.AssumeDefaultVersionWhenUnspecified = true; + // options.ReportApiVersions = true; + //}); + + + //// 添加响应压缩 + //services.AddResponseCompression(options => + //{ + // options.EnableForHttps = true; + // options.Providers.Add(); + // options.Providers.Add(); + //}); } } } diff --git a/Atomx.Admin/Atomx.Admin/Extensions/RateLimiterExtension.cs b/Atomx.Admin/Atomx.Admin/Extensions/RateLimiterExtension.cs new file mode 100644 index 0000000..c1ef3e4 --- /dev/null +++ b/Atomx.Admin/Atomx.Admin/Extensions/RateLimiterExtension.cs @@ -0,0 +1,45 @@ +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 + })); + }); + } + } +} diff --git a/Atomx.Admin/Atomx.Admin/Extensions/SecurityHeadersExtension.cs b/Atomx.Admin/Atomx.Admin/Extensions/SecurityHeadersExtension.cs new file mode 100644 index 0000000..870485f --- /dev/null +++ b/Atomx.Admin/Atomx.Admin/Extensions/SecurityHeadersExtension.cs @@ -0,0 +1,17 @@ +using Atomx.Admin.Middlewares; + +namespace Atomx.Admin.Extensions +{ + public static class SecurityHeadersExtension + { + /// + /// 安全头 + /// + /// + /// + 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..a97f4da --- /dev/null +++ b/Atomx.Admin/Atomx.Admin/Extensions/SignalRExtension.cs @@ -0,0 +1,33 @@ +namespace Atomx.Admin.Extensions +{ + public static class SignalRExtension + { + /// + /// 添加身份验证服务 + /// + /// + /// + 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..1506097 --- /dev/null +++ b/Atomx.Admin/Atomx.Admin/Middlewares/ExceptionHandlingMiddleware.cs @@ -0,0 +1,83 @@ +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..95a98f1 --- /dev/null +++ b/Atomx.Admin/Atomx.Admin/Middlewares/SecurityHeadersMiddleware.cs @@ -0,0 +1,53 @@ +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..4e1de52 100644 --- a/Atomx.Admin/Atomx.Admin/Program.cs +++ b/Atomx.Admin/Atomx.Admin/Program.cs @@ -14,9 +14,12 @@ 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.EntityFrameworkCore; using Scalar.AspNetCore; using Serilog; +using StackExchange.Redis; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Unicode; @@ -68,7 +71,10 @@ 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); + +// SignalR +builder.Services.AddSignalR(); 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"))); @@ -82,6 +88,8 @@ builder.Services.AddStackExchangeRedisCache(options => #endregion }); + + builder.Services.AddOpenApi(); builder.Services.AddAntDesign(); @@ -92,6 +100,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 +118,11 @@ else app.UseExceptionHandler("/Error", createScopeForErrors: true); } +//ȫͷ +app.UseSecurityHeaders(); +// Ӧѹ +app.UseResponseCompression(); app.UseCors(option => { @@ -113,7 +131,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 +153,46 @@ 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/IdentityService.cs b/Atomx.Admin/Atomx.Admin/Services/IdentityService.cs index 58a30f3..1d38f4d 100644 --- a/Atomx.Admin/Atomx.Admin/Services/IdentityService.cs +++ b/Atomx.Admin/Atomx.Admin/Services/IdentityService.cs @@ -31,6 +31,12 @@ namespace Atomx.Admin.Services /// /// int GetTimeZone(); + + /// + /// 获取用户代理信息 + /// + /// + string GetUserAgent(); } /// @@ -102,5 +108,10 @@ namespace Atomx.Admin.Services var timeZone = _httpContextAccessor.HttpContext?.User?.Claims?.SingleOrDefault(p => p.Type == "TimeZone")?.Value ?? "0"; return timeZone.ToInt(); } + + public string GetUserAgent() + { + return _httpContextAccessor.HttpContext?.Request.Headers["User-Agent"].FirstOrDefault() ?? ""; + } } } diff --git a/Atomx.Admin/Atomx.Admin/appsettings.json b/Atomx.Admin/Atomx.Admin/appsettings.json index 3ec0b7a..180bfcb 100644 --- a/Atomx.Admin/Atomx.Admin/appsettings.json +++ b/Atomx.Admin/Atomx.Admin/appsettings.json @@ -16,7 +16,7 @@ "Issuer": "http://api.sampleapi.com", "Audience": "SampleApi", "SecurityKey": "SecurityKey23456SecurityKey23456", - "ClockSkew": "600", + "ClockSkew": "10", // 10分钟时钟偏差 "AccessTokenExpirationMinutes": "60", "RefreshTokenExpirationMinutes": "60" } diff --git a/Atomx.Common/Entities/RefreshToken.cs b/Atomx.Common/Entities/RefreshToken.cs index a13f506..a41009d 100644 --- a/Atomx.Common/Entities/RefreshToken.cs +++ b/Atomx.Common/Entities/RefreshToken.cs @@ -30,11 +30,13 @@ namespace Atomx.Common.Entities /// /// 发布时间 /// + [Column(TypeName = "timestamptz")] public DateTime IssuedTime { get; set; } = DateTime.UtcNow; /// /// 到期时间 /// + [Column(TypeName = "timestamptz")] public DateTime ExpiresTime { get; set; } /// @@ -42,6 +44,12 @@ namespace Atomx.Common.Entities /// public bool IsRevoked { get; set; } + /// + /// 回收时间 + /// + [Column(TypeName = "timestamptz")] + public DateTime? RevokedTime { get; set; } + /// /// 用户IP /// diff --git a/Atomx.Common/Models/AuthResponse.cs b/Atomx.Common/Models/AuthResponse.cs new file mode 100644 index 0000000..314b48f --- /dev/null +++ b/Atomx.Common/Models/AuthResponse.cs @@ -0,0 +1,23 @@ +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; } + } +}