chore
This commit is contained in:
@@ -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
|
||||
/// </summary>
|
||||
/// <param name="services"></param>
|
||||
/// <param name="Configuration"></param>
|
||||
public static void AddAuthorize(this IServiceCollection services, IConfiguration Configuration)
|
||||
/// <param name="environment"></param>
|
||||
/// <exception cref="Exception"></exception>
|
||||
public static void AddAuthorize(this IServiceCollection services, IConfiguration Configuration, IWebHostEnvironment environment)
|
||||
{
|
||||
var jwtSetting = Configuration.GetSection("Authentication:JwtBearer").Get<JwtSetting>();
|
||||
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<IUserService>();
|
||||
// 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 =>
|
||||
|
||||
56
Atomx.Admin/Atomx.Admin/Extensions/RateLimiterExtension.cs
Normal file
56
Atomx.Admin/Atomx.Admin/Extensions/RateLimiterExtension.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System.Threading.RateLimiting;
|
||||
|
||||
namespace Atomx.Admin.Extensions
|
||||
{
|
||||
public static class RateLimiterExtension
|
||||
{
|
||||
/// <summary>
|
||||
/// 添加速率限制
|
||||
/// </summary>
|
||||
/// <param name="services"></param>
|
||||
/// <param name="Configuration"></param>
|
||||
/// <param name="environment"></param>
|
||||
/// <exception cref="Exception"></exception>
|
||||
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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<SecurityHeadersMiddleware>();
|
||||
}
|
||||
}
|
||||
}
|
||||
36
Atomx.Admin/Atomx.Admin/Extensions/SignalRExtension.cs
Normal file
36
Atomx.Admin/Atomx.Admin/Extensions/SignalRExtension.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
namespace Atomx.Admin.Extensions
|
||||
{
|
||||
public static class SignalRExtension
|
||||
{
|
||||
/// <summary>
|
||||
/// 添加SignalR
|
||||
/// </summary>
|
||||
/// <param name="services"></param>
|
||||
/// <param name="Configuration"></param>
|
||||
/// <param name="environment"></param>
|
||||
/// <exception cref="Exception"></exception>
|
||||
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";
|
||||
//});
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Atomx.Admin.Middlewares
|
||||
{
|
||||
/// <summary>
|
||||
/// 异常处理中间件
|
||||
/// </summary>
|
||||
public class ExceptionHandlingMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
|
||||
public ExceptionHandlingMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<ExceptionHandlingMiddleware> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace Atomx.Admin.Middlewares
|
||||
{
|
||||
/// <summary>
|
||||
/// 安全中间件
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<AuthHeaderHandler>();
|
||||
|
||||
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<DataContext>(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<69>ֲ<EFBFBD>ʽ<EFBFBD><CABD><EFBFBD><EFBFBD>
|
||||
//builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
|
||||
// ConnectionMultiplexer.Connect(redisConnection));
|
||||
|
||||
// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ӧѹ<D3A6><D1B9>
|
||||
builder.Services.AddResponseCompression(options =>
|
||||
{
|
||||
options.EnableForHttps = true;
|
||||
options.Providers.Add<BrotliCompressionProvider>();
|
||||
options.Providers.Add<GzipCompressionProvider>();
|
||||
});
|
||||
|
||||
// <20><><EFBFBD><EFBFBD>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<MonitoringOptions>(builder.Configuration.GetSection("Monitoring"));
|
||||
@@ -92,6 +121,12 @@ var app = builder.Build();
|
||||
|
||||
app.AddDataMigrate();
|
||||
|
||||
// <20><><EFBFBD><EFBFBD>HTTP<54><50><EFBFBD><EFBFBD><EFBFBD>ܵ<EFBFBD>
|
||||
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);
|
||||
}
|
||||
|
||||
// <20><>ȫͷ
|
||||
app.UseSecurityHeaders();
|
||||
|
||||
// <20><>Ӧѹ<D3A6><D1B9>
|
||||
app.UseResponseCompression();
|
||||
|
||||
app.UseCors(option =>
|
||||
{
|
||||
@@ -113,7 +152,18 @@ app.UseCors(option =>
|
||||
option.AllowAnyHeader();
|
||||
});
|
||||
|
||||
app.UseStaticFiles();
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
OnPrepareResponse = ctx =>
|
||||
{
|
||||
// <20><><EFBFBD>澲̬<E6BEB2>ļ<EFBFBD>
|
||||
ctx.Context.Response.Headers.Append(
|
||||
"Cache-Control", $"public, max-age={31536000}");
|
||||
}
|
||||
});
|
||||
|
||||
// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
|
||||
app.UseRateLimiter();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
@@ -124,10 +174,45 @@ app.MapStaticAssets();
|
||||
app.UseMiddleware<MonitoringMiddleware>();
|
||||
|
||||
|
||||
//// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>˵<EFBFBD>
|
||||
//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<6C>˵<EFBFBD>
|
||||
//app.MapHub<ChatHub>("/hubs/chat");
|
||||
//app.MapHub<NotificationHub>("/hubs/notification");
|
||||
|
||||
app.MapControllers();
|
||||
app.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode()
|
||||
.AddInteractiveWebAssemblyRenderMode()
|
||||
.AddAdditionalAssemblies(typeof(Atomx.Admin.Client._Imports).Assembly);
|
||||
|
||||
|
||||
//// ȷ<><C8B7><EFBFBD><EFBFBD><EFBFBD>ݿⴴ<DDBF><E2B4B4><EFBFBD><EFBFBD>Ǩ<EFBFBD><C7A8>
|
||||
//await using (var scope = app.Services.CreateAsyncScope())
|
||||
//{
|
||||
// var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
// await dbContext.Database.MigrateAsync();
|
||||
|
||||
// var seeder = scope.ServiceProvider.GetRequiredService<DatabaseSeeder>();
|
||||
// await seeder.SeedAsync();
|
||||
//}
|
||||
|
||||
app.Run();
|
||||
|
||||
383
Atomx.Admin/Atomx.Admin/Services/TokenService.cs
Normal file
383
Atomx.Admin/Atomx.Admin/Services/TokenService.cs
Normal file
@@ -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<AuthResponse> GenerateTokenAsync(User user, string? ipAddress = null, string? userAgent = null);
|
||||
Task<AuthResponse> RefreshTokenAsync(string token, string refreshToken, string? ipAddress = null);
|
||||
Task<bool> RevokeTokenAsync(string refreshToken, string? ipAddress = null);
|
||||
Task<bool> ValidateTokenAsync(string token);
|
||||
Task<User?> GetUserFromTokenAsync(string token);
|
||||
}
|
||||
|
||||
public class TokenService : ITokenService
|
||||
{
|
||||
readonly DataContext _dbContext;
|
||||
readonly ICacheService _cacheService;
|
||||
readonly JwtSetting _jwtSetting;
|
||||
private readonly ILogger<TokenService> _logger;
|
||||
private readonly SecurityKey _securityKey;
|
||||
private readonly SigningCredentials _signingCredentials;
|
||||
|
||||
public TokenService(
|
||||
ILogger<TokenService> 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<AuthResponse> 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<AuthResponse> 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<bool> 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<bool> 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<User?> 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<User>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -79,6 +79,12 @@ namespace Atomx.Common.Entities
|
||||
[Column(TypeName = "varchar(50)")]
|
||||
public string LastIp { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 锁定结束时间
|
||||
/// </summary>
|
||||
[Column(TypeName = "timestamptz")]
|
||||
public DateTime? LockoutEndTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 数据创建时间
|
||||
/// </summary>
|
||||
|
||||
@@ -42,6 +42,12 @@ namespace Atomx.Common.Entities
|
||||
/// </summary>
|
||||
public bool IsRevoked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 令牌回收时间
|
||||
/// </summary>
|
||||
[Column(TypeName = "timestamptz")]
|
||||
public DateTime RevokedTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户IP
|
||||
/// </summary>
|
||||
|
||||
@@ -22,13 +22,13 @@ namespace Atomx.Common.Entities
|
||||
/// <summary>
|
||||
/// 邮箱
|
||||
/// </summary>
|
||||
[Column(TypeName = "varchar(64)")]
|
||||
[Column(TypeName = "varchar(128)")]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号
|
||||
/// </summary>
|
||||
[Column(TypeName = "varchar(64)")]
|
||||
[Column(TypeName = "varchar(32)")]
|
||||
public string Mobile { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
@@ -43,6 +43,27 @@ namespace Atomx.Common.Entities
|
||||
[Column(TypeName = "varchar(32)")]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 账号是否激活
|
||||
/// </summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 是否Email确认
|
||||
/// </summary>
|
||||
public bool EmailConfirmed { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 锁定结束时间
|
||||
/// </summary>
|
||||
[Column(TypeName = "timestamptz")]
|
||||
public DateTime? LockoutEndTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 登录失败次数
|
||||
/// </summary>
|
||||
public int FailedLoginAttempts { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 注册数据创建时间
|
||||
/// </summary>
|
||||
|
||||
20
Atomx.Common/Models/AuthResponse.cs
Normal file
20
Atomx.Common/Models/AuthResponse.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace Atomx.Common.Models
|
||||
{
|
||||
public class AuthResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 身份令牌
|
||||
/// </summary>
|
||||
public string Token { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 刷新身份的令牌
|
||||
/// </summary>
|
||||
public string RefreshToken { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 身份令牌过期时间
|
||||
/// </summary>
|
||||
public DateTime TokenExpiry { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -37,5 +37,10 @@ namespace Atomx.Common.Models
|
||||
/// 刷新令牌过期时间
|
||||
/// </summary>
|
||||
public int RefreshTokenExpirationMinutes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每个用户刷新令牌最大数量
|
||||
/// </summary>
|
||||
public int MaxRefreshTokensPerUser { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// <returns></returns>
|
||||
Task Remove(string key);
|
||||
|
||||
/// <summary>
|
||||
/// 获取缓存的字符串内容
|
||||
/// </summary>
|
||||
/// <param name="key"></param>
|
||||
/// <returns></returns>
|
||||
Task<string?> GetCacheString(string key);
|
||||
|
||||
}
|
||||
|
||||
@@ -61,6 +68,10 @@ namespace Atomx.Data.CacheServices
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task<string?> GetCacheString(string key)
|
||||
{
|
||||
return await _cache.GetStringAsync(key);
|
||||
}
|
||||
|
||||
public T? GetCache<T>(string key)
|
||||
{
|
||||
|
||||
10
Atomx.Test/Atomx.Test.csproj
Normal file
10
Atomx.Test/Atomx.Test.csproj
Normal file
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="MSTest.Sdk/4.0.1">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
1
Atomx.Test/MSTestSettings.cs
Normal file
1
Atomx.Test/MSTestSettings.cs
Normal file
@@ -0,0 +1 @@
|
||||
[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]
|
||||
11
Atomx.Test/Test1.cs
Normal file
11
Atomx.Test/Test1.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace Atomx.Test
|
||||
{
|
||||
[TestClass]
|
||||
public sealed class Test1
|
||||
{
|
||||
[TestMethod]
|
||||
public void TestMethod1()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
5
global.json
Normal file
5
global.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"test": {
|
||||
"runner": "Microsoft.Testing.Platform"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user