chore
This commit is contained in:
@@ -4,6 +4,7 @@ using Atomx.Common.Models;
|
|||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace Atomx.Admin.Extensions
|
namespace Atomx.Admin.Extensions
|
||||||
{
|
{
|
||||||
@@ -14,7 +15,9 @@ namespace Atomx.Admin.Extensions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="services"></param>
|
/// <param name="services"></param>
|
||||||
/// <param name="Configuration"></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>();
|
var jwtSetting = Configuration.GetSection("Authentication:JwtBearer").Get<JwtSetting>();
|
||||||
if (jwtSetting == null)
|
if (jwtSetting == null)
|
||||||
@@ -27,6 +30,9 @@ namespace Atomx.Admin.Extensions
|
|||||||
services.AddAuthentication()
|
services.AddAuthentication()
|
||||||
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
|
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
|
||||||
{
|
{
|
||||||
|
options.RequireHttpsMetadata = !environment.IsDevelopment();
|
||||||
|
options.SaveToken = true;
|
||||||
|
|
||||||
options.ClaimsIssuer = jwtSetting.Issuer;
|
options.ClaimsIssuer = jwtSetting.Issuer;
|
||||||
options.TokenValidationParameters = new TokenValidationParameters
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
{
|
{
|
||||||
@@ -36,17 +42,59 @@ namespace Atomx.Admin.Extensions
|
|||||||
ValidateIssuerSigningKey = true,
|
ValidateIssuerSigningKey = true,
|
||||||
ValidAudience = jwtSetting.Audience,//Audience
|
ValidAudience = jwtSetting.Audience,//Audience
|
||||||
ValidIssuer = jwtSetting.Issuer,
|
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))
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSetting.SecurityKey))
|
||||||
};
|
};
|
||||||
options.Events = new JwtBearerEvents
|
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 =>
|
OnChallenge = context =>
|
||||||
{
|
{
|
||||||
var absoluteUri = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}{context.Request.QueryString}";
|
var absoluteUri = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}{context.Request.QueryString}";
|
||||||
context.HandleResponse();
|
context.HandleResponse();
|
||||||
context.Response.Redirect($"/account/login?returnUrl={Uri.EscapeDataString(absoluteUri)}");
|
context.Response.Redirect($"/account/login?returnUrl={Uri.EscapeDataString(absoluteUri)}");
|
||||||
return Task.CompletedTask;
|
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 =>
|
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 Mapster;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||||
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
|
using Microsoft.AspNetCore.ResponseCompression;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Scalar.AspNetCore;
|
using Scalar.AspNetCore;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
@@ -68,22 +71,48 @@ builder.Services.AddScoped<AuthHeaderHandler>();
|
|||||||
|
|
||||||
builder.Services.AddHttpClientApiService(builder.Configuration["WebApi:ServerUrl"] ?? "http://localhost");
|
builder.Services.AddHttpClientApiService(builder.Configuration["WebApi:ServerUrl"] ?? "http://localhost");
|
||||||
builder.Services.AddDataService();
|
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.");
|
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")));
|
builder.Services.AddDbContext<DataContext>(options => options.UseNpgsql(connection, p => p.MigrationsHistoryTable("__DbMigrationsHistory")));
|
||||||
|
|
||||||
|
|
||||||
|
var redisConnection = builder.Configuration.GetConnectionString("cache");
|
||||||
|
|
||||||
builder.Services.AddStackExchangeRedisCache(options =>
|
builder.Services.AddStackExchangeRedisCache(options =>
|
||||||
{
|
{
|
||||||
#region
|
#region
|
||||||
options.Configuration = $"{builder.Configuration.GetConnectionString("cache")}";
|
options.Configuration = redisConnection;
|
||||||
options.InstanceName = builder.Configuration["RedisCache:InstanceName"];
|
options.InstanceName = builder.Configuration["RedisCache:InstanceName"];
|
||||||
#endregion
|
#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.AddOpenApi();
|
||||||
|
|
||||||
|
|
||||||
builder.Services.AddAntDesign();
|
builder.Services.AddAntDesign();
|
||||||
|
|
||||||
builder.Services.Configure<MonitoringOptions>(builder.Configuration.GetSection("Monitoring"));
|
builder.Services.Configure<MonitoringOptions>(builder.Configuration.GetSection("Monitoring"));
|
||||||
@@ -92,6 +121,12 @@ var app = builder.Build();
|
|||||||
|
|
||||||
app.AddDataMigrate();
|
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.
|
// Configure the HTTP request pipeline.
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
@@ -104,7 +139,11 @@ else
|
|||||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// <20><>ȫͷ
|
||||||
|
app.UseSecurityHeaders();
|
||||||
|
|
||||||
|
// <20><>Ӧѹ<D3A6><D1B9>
|
||||||
|
app.UseResponseCompression();
|
||||||
|
|
||||||
app.UseCors(option =>
|
app.UseCors(option =>
|
||||||
{
|
{
|
||||||
@@ -113,7 +152,18 @@ app.UseCors(option =>
|
|||||||
option.AllowAnyHeader();
|
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.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
@@ -124,10 +174,45 @@ app.MapStaticAssets();
|
|||||||
app.UseMiddleware<MonitoringMiddleware>();
|
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.MapControllers();
|
||||||
app.MapRazorComponents<App>()
|
app.MapRazorComponents<App>()
|
||||||
.AddInteractiveServerRenderMode()
|
.AddInteractiveServerRenderMode()
|
||||||
.AddInteractiveWebAssemblyRenderMode()
|
.AddInteractiveWebAssemblyRenderMode()
|
||||||
.AddAdditionalAssemblies(typeof(Atomx.Admin.Client._Imports).Assembly);
|
.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();
|
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",
|
"Issuer": "http://api.sampleapi.com",
|
||||||
"Audience": "SampleApi",
|
"Audience": "SampleApi",
|
||||||
"SecurityKey": "SecurityKey23456SecurityKey23456",
|
"SecurityKey": "SecurityKey23456SecurityKey23456",
|
||||||
"ClockSkew": "600",
|
"ClockSkew": "10",
|
||||||
"AccessTokenExpirationMinutes": "60",
|
"AccessTokenExpirationMinutes": "60",
|
||||||
"RefreshTokenExpirationMinutes": "60"
|
"RefreshTokenExpirationMinutes": "60",
|
||||||
|
"MaxRefreshTokensPerUser": "3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ namespace Atomx.Common.Entities
|
|||||||
[Column(TypeName = "varchar(50)")]
|
[Column(TypeName = "varchar(50)")]
|
||||||
public string LastIp { get; set; } = string.Empty;
|
public string LastIp { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 锁定结束时间
|
||||||
|
/// </summary>
|
||||||
|
[Column(TypeName = "timestamptz")]
|
||||||
|
public DateTime? LockoutEndTime { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 数据创建时间
|
/// 数据创建时间
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ namespace Atomx.Common.Entities
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsRevoked { get; set; }
|
public bool IsRevoked { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 令牌回收时间
|
||||||
|
/// </summary>
|
||||||
|
[Column(TypeName = "timestamptz")]
|
||||||
|
public DateTime RevokedTime { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 用户IP
|
/// 用户IP
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -22,13 +22,13 @@ namespace Atomx.Common.Entities
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 邮箱
|
/// 邮箱
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Column(TypeName = "varchar(64)")]
|
[Column(TypeName = "varchar(128)")]
|
||||||
public string Email { get; set; } = string.Empty;
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 手机号
|
/// 手机号
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Column(TypeName = "varchar(64)")]
|
[Column(TypeName = "varchar(32)")]
|
||||||
public string Mobile { get; set; } = string.Empty;
|
public string Mobile { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -43,6 +43,27 @@ namespace Atomx.Common.Entities
|
|||||||
[Column(TypeName = "varchar(32)")]
|
[Column(TypeName = "varchar(32)")]
|
||||||
public string Password { get; set; } = string.Empty;
|
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>
|
||||||
/// 注册数据创建时间
|
/// 注册数据创建时间
|
||||||
/// </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>
|
/// </summary>
|
||||||
public int RefreshTokenExpirationMinutes { get; set; }
|
public int RefreshTokenExpirationMinutes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每个用户刷新令牌最大数量
|
||||||
|
/// </summary>
|
||||||
|
public int MaxRefreshTokensPerUser { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
using Microsoft.Extensions.Caching.Distributed;
|
using Microsoft.Extensions.Caching.Distributed;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace Atomx.Data.CacheServices
|
namespace Atomx.Data.CacheServices
|
||||||
{
|
{
|
||||||
@@ -40,6 +41,12 @@ namespace Atomx.Data.CacheServices
|
|||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
Task Remove(string key);
|
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;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<string?> GetCacheString(string key)
|
||||||
|
{
|
||||||
|
return await _cache.GetStringAsync(key);
|
||||||
|
}
|
||||||
|
|
||||||
public T? GetCache<T>(string 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
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atomx.WebAPI", "Atomx.WebAPI\Atomx.WebAPI.csproj", "{D214046F-0D80-4361-9964-395234C6FF11}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atomx.WebAPI", "Atomx.WebAPI\Atomx.WebAPI.csproj", "{D214046F-0D80-4361-9964-395234C6FF11}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atomx.Test", "Atomx.Test\Atomx.Test.csproj", "{60D4714E-1DBE-4381-9B22-5894F1310561}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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}.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.ActiveCfg = Release|Any CPU
|
||||||
{D214046F-0D80-4361-9964-395234C6FF11}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
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