This commit is contained in:
2026-03-30 11:07:30 +08:00
parent 2c44b3a4b2
commit d4a8e71733
74 changed files with 1751 additions and 421 deletions

View File

@@ -62,15 +62,12 @@ public static class CommonServiceCollections
options.Cookie.MaxAge = TimeSpan.FromSeconds(jwtOptions!.RefreshTokenExpiresIn);
options.Cookie.HttpOnly = true;
options.Cookie.Name = appOptions.CSRFCookieName;
if (isDevelopment)
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.Domain = appOptions.CookieDomain;
if (!isDevelopment)
{
options.Cookie.SameSite = SameSiteMode.Lax;
}
else
{
options.Cookie.SameSite = SameSiteMode.None;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.Domain = appOptions.CookieDomain;
}
});

View File

@@ -1,10 +1,11 @@
using StopShopping.Services.Extensions;
namespace Microsoft.AspNetCore.Http;
public static class HttpExtensions
{
public const string REFRESH_TOKEN_COOKIE_KEY = "refresh_token";
public const string REFRESH_TOKEN_COOKIE_KEY = "user_refresh_token";
public static IResponseCookies AppendRefreshToken(
this IResponseCookies cookies,
@@ -18,13 +19,11 @@ public static class HttpExtensions
MaxAge = maxAge,
HttpOnly = true,
SameSite = SameSiteMode.Lax,
Domain = appOptions.CookieDomain,
};
if (!env.IsDevelopment())
{
options.SameSite = SameSiteMode.None;
options.Secure = true;
options.Domain = appOptions.CookieDomain;
}
cookies.Append(
REFRESH_TOKEN_COOKIE_KEY,

View File

@@ -10,16 +10,4 @@ public static class MiddlewareExtensions
return applicationBuilder;
}
/// <summary>
/// 解决开发时多客户端localhost端口串cookie的问题
/// </summary>
/// <param name="applicationBuilder"></param>
/// <returns></returns>
public static IApplicationBuilder UseDevelopmentCookie(this IApplicationBuilder applicationBuilder)
{
applicationBuilder.UseMiddleware<DevelopmentCookieMiddleware>();
return applicationBuilder;
}
}

View File

@@ -1,142 +0,0 @@
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;
using StopShopping.Services.Extensions;
namespace StopShopping.Api.Middlewares;
public class DevelopmentCookieMiddleware
{
public DevelopmentCookieMiddleware(
RequestDelegate requestDelegate,
IOptions<AppOptions> options)
{
_next = requestDelegate;
_appOptions = options.Value;
}
private readonly RequestDelegate _next;
private readonly AppOptions _appOptions;
public async Task InvokeAsync(HttpContext httpContext)
{
int? port = null;
var origin = httpContext.Request.Headers[HeaderNames.Origin];
if (origin.Count > 0 && null != origin[0])
{
Uri uri = new(origin[0]!);
port = uri.Port;
}
else
{
var referer = httpContext.Request.Headers[HeaderNames.Referer];
if (referer.Count > 0 && null != referer[0])
{
Uri uri = new(referer[0]!);
port = uri.Port;
}
}
if (port.HasValue)
{
var modified = ModifyCookie(httpContext.Request.Headers[HeaderNames.Cookie], port.Value);
httpContext.Request.Headers[HeaderNames.Cookie] = modified;
}
httpContext.Response.OnStarting(() =>
{
if (port.HasValue)
{
var cookieHeader = httpContext.Response.Headers[HeaderNames.SetCookie];
ModifyResponseCookie(cookieHeader, httpContext, port.Value);
}
return Task.CompletedTask;
});
await _next(httpContext);
}
private void ModifyResponseCookie(StringValues cookieHeader, HttpContext httpContext, int port)
{
foreach (var cookieItem in cookieHeader)
{
if (cookieItem != null)
{
var cookies = cookieItem.Split(';');
foreach (var item in cookies)
{
if (null != item)
{
var pairs = item.Split('=');
if (2 == pairs.Length)
{
if (pairs[0].Trim() == _appOptions.CSRFCookieName)
{
var val = pairs[1];
httpContext.Response.Cookies.Delete(pairs[0]);
httpContext.Response.Cookies.Append($"{_appOptions.CSRFCookieName}_{port}", val);
}
else if (pairs[0].Trim() == HttpExtensions.REFRESH_TOKEN_COOKIE_KEY)
{
var val = pairs[1];
httpContext.Response.Cookies.Delete(pairs[0]);
httpContext.Response.Cookies.Append($"{HttpExtensions.REFRESH_TOKEN_COOKIE_KEY}_{port}", val);
}
}
}
}
}
}
}
private StringValues ModifyCookie(StringValues cookie, int port)
{
List<string> result = [];
var csrfCookieName = $"{_appOptions.CSRFCookieName}_{port}";
var refreshTokenCookieName = $"{HttpExtensions.REFRESH_TOKEN_COOKIE_KEY}_{port}";
foreach (var cookieItem in cookie)
{
if (null != cookieItem)
{
StringBuilder itemBuilder = new();
var cookies = cookieItem.Split(';');
foreach (var item in cookies)
{
if (null != item)
{
var pairs = item.Split('=');
if (pairs.Length == 2)
{
if (0 != itemBuilder.Length)
itemBuilder.Append(';');
if (pairs[0].Trim() == csrfCookieName)
{
itemBuilder.Append(_appOptions.CSRFCookieName);
itemBuilder.Append('=');
itemBuilder.Append(pairs[1].Trim());
}
else if (pairs[0].Trim() == refreshTokenCookieName)
{
itemBuilder.Append(HttpExtensions.REFRESH_TOKEN_COOKIE_KEY);
itemBuilder.Append('=');
itemBuilder.Append(pairs[1].Trim());
}
else
{
itemBuilder.Append(item);
}
}
else
{
itemBuilder.Append(item);
}
}
}
result.Add(itemBuilder.ToString());
}
}
return new StringValues(result.ToArray());
}
}

View File

@@ -24,26 +24,32 @@ public class GlobalExceptionHandlerMiddleware
try
{
await _next(httpContext);
}
catch (BadHttpRequestException ex) when (ex.InnerException is AntiforgeryValidationException)
{
var problemDetails = new ProblemDetails
httpContext.Response.OnStarting(async () =>
{
Detail = ex.InnerException.Message,
Instance = httpContext.Request.Path,
Status = StatusCodes.Status400BadRequest,
Title = "CSRF 错误",
};
var antiforgeryFeature = httpContext.Features.Get<IAntiforgeryValidationFeature>();
if (null != antiforgeryFeature && !antiforgeryFeature.IsValid)
{
var problemDetails = new ProblemDetails
{
Detail = antiforgeryFeature.Error?.Message,
Instance = httpContext.Request.Path,
Status = StatusCodes.Status400BadRequest,
Title = "CSRF 错误",
};
problemDetails.AddErrorCode(ProblemDetailsCodes.CsrfValidationFailed);
problemDetails.AddErrorCode(ProblemDetailsCodes.CsrfValidationFailed);
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
httpContext.Response.ContentType = "application/problem+json";
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
httpContext.Response.ContentType = "application/problem+json";
await _problemDetailsService.WriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
ProblemDetails = problemDetails
await _problemDetailsService.WriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
ProblemDetails = problemDetails
});
}
await Task.CompletedTask;
});
}
catch (BadHttpRequestException ex)

View File

@@ -1,10 +1,12 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.HostFiltering;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Scalar.AspNetCore;
using Serilog;
using StopShopping.Api.Extensions;
using StopShopping.Api.Routes;
using StopShopping.Api.Workers;
const string CORS_POLICY = "default";
// 将启动日志写入控制台用于捕获启动时异常启动后WriteTo被后续配置替代
@@ -41,13 +43,11 @@ try
},
appConfiguration,
builder.Configuration.GetSection("OpenPlatformOptions"));
builder.Services.AddHostedService<DbSeederBackgroundService>();
/**********************************************************************/
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseDevelopmentCookie();
app.MapOpenApi();
app.MapScalarApiReference(options =>
{
@@ -55,18 +55,30 @@ try
});
}
if (!app.Environment.IsDevelopment())
{
app.UseHostFiltering();
var forwardedHeadersOptions = new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.All,
};
var hostFilteringOptions = app.Services.GetRequiredService<IOptions<HostFilteringOptions>>();
if (null != hostFilteringOptions)
forwardedHeadersOptions.AllowedHosts = hostFilteringOptions.Value.AllowedHosts;
app.UseForwardedHeaders(forwardedHeadersOptions);
}
app.UseGlobalExceptionHandler();
app.UseRouting();
app.UseCors(CORS_POLICY);
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapStaticAssets().ShortCircuit();
Root.MapRoutes(app);
app.UseAntiforgery();

View File

@@ -1,44 +0,0 @@
using Microsoft.Extensions.Options;
using StopShopping.Services;
using StopShopping.Services.Extensions;
using StopShopping.Services.Models.Req;
using StopShopping.Services.Models.Resp;
namespace StopShopping.Api.Routes;
public static class Admin
{
public static RouteGroupBuilder MapAdmin(this RouteGroupBuilder routes)
{
routes.MapPost("/admin/signin", SignInAsync)
.AllowAnonymous().WithTags(OpenApiTags..ToString());
return routes;
}
private static async Task<ApiResponse<SignInAdmin>> SignInAsync(
SignInParams model,
IUserService userService,
HttpContext httpContext,
IWebHostEnvironment env,
IOptions<AppOptions> options)
{
var result = await userService.SignInAdminAsync(model);
var resp = new ApiResponse<SignInAdmin>
{
IsSucced = result.IsSucced,
Data = result.User,
Message = result.Message
};
if (result.IsSucced)
{
httpContext.Response.Cookies.AppendRefreshToken(
env,
options.Value,
TimeSpan.FromSeconds(result.RefreshToken!.ExpiresIn),
result.RefreshToken.Token!
);
}
return resp;
}
}

View File

@@ -1,29 +1,13 @@
using StopShopping.Services;
using StopShopping.Services.Models.Req;
using StopShopping.Services.Models.Resp;
namespace StopShopping.Api.Routes;
public static class Category
{
public static RouteGroupBuilder MapCategoryCommon(this RouteGroupBuilder routes)
{
routes.MapGet("/category/list", GetTree)
.WithTags(OpenApiTags..ToString())
.AllowAnonymous();
return routes;
}
public static RouteGroupBuilder MapCategory(this RouteGroupBuilder routes)
{
routes.MapPost("/category/edit", EditCategoryAsync)
.WithTags(OpenApiTags..ToString());
routes.MapPost("/category/resort", ResortCategoryAsync)
.WithTags(OpenApiTags..ToString());
routes.MapPost("/category/delete", DeleteCategoryAsync)
routes.MapGet("/category/list", GetTree)
.WithTags(OpenApiTags..ToString());
return routes;
@@ -35,26 +19,4 @@ public static class Category
{
return categoryService.GetCategoriesTree();
}
private static async Task<ApiResponse<Services.Models.Resp.Category>> EditCategoryAsync(
EditCategoryParams model,
ICategoryService categoryService)
{
return await categoryService.EditCategoryAsync(model);
}
private static async Task<ApiResponse> ResortCategoryAsync(
ResortCategoryParams model,
ICategoryService categoryService)
{
return await categoryService.ResortCategoryAsync(model);
}
private static async Task<ApiResponse> DeleteCategoryAsync(
CategoryIdParams model,
ICategoryService categoryService
)
{
return await categoryService.DeleteCategoryAsync(model);
}
}

View File

@@ -1,6 +1,5 @@
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using StopShopping.Services;
using StopShopping.Services.Models.Req;
using StopShopping.Services.Models.Resp;
@@ -12,23 +11,15 @@ public static class Common
public static RouteGroupBuilder MapCommon(this RouteGroupBuilder routes)
{
routes.MapPost("/common/upload", UploadAsync)
.WithTags(OpenApiTags..ToString());
routes.MapPost("/common/refreshtoken", RefreshTokenAsync)
.AllowAnonymous()
.Produces<ApiResponse<AccessToken>>()
.WithTags(OpenApiTags..ToString());
routes.MapPost("/common/signout", SignOutAsync)
.AllowAnonymous().WithTags(OpenApiTags..ToString());
.WithTags(OpenApiTags..ToString());
routes.MapPost("/common/antiforgery-token", AntiForgeryToken)
.WithTags(OpenApiTags..ToString());
.WithTags(OpenApiTags..ToString());
return routes;
}
private static async Task<ApiResponse<FileUpload>> UploadAsync(
private static async Task<ApiResponse<FileUploadResp>> UploadAsync(
[FromForm] UploadParams payload,
IFileService fileService,
HttpContext httpContext)
@@ -48,40 +39,5 @@ public static class Common
HeaderName = antiforgeryToken.HeaderName
});
}
private static async Task<IResult> RefreshTokenAsync(
HttpContext httpContext,
IAccessTokenService accessTokenService)
{
var refreshToken = httpContext.Request.Cookies[HttpExtensions.REFRESH_TOKEN_COOKIE_KEY];
if (string.IsNullOrWhiteSpace(refreshToken))
return Results.Unauthorized();
var accessToken = await accessTokenService.GenerateAccessTokenAsync(refreshToken);
if (null == accessToken)
return Results.Unauthorized();
return Results.Ok(new ApiResponse<AccessToken>(accessToken));
}
public static async Task<ApiResponse> SignOutAsync(
HttpContext httpContext,
IAccessTokenService accessTokenService)
{
var accessTokenHeader = httpContext.Request.Headers[HeaderNames.Authorization];
if (accessTokenHeader.Count != 0)
{
var accessToken = accessTokenHeader.First()!.Split(" ").Last();
if (!string.IsNullOrWhiteSpace(accessToken))
await accessTokenService.AddAccessTokenBlacklistAsync(accessToken);
}
var refreshToken = httpContext.Request.Cookies[HttpExtensions.REFRESH_TOKEN_COOKIE_KEY];
if (!string.IsNullOrWhiteSpace(refreshToken))
{
await accessTokenService.RevokeRefreshTokenAsync(refreshToken);
httpContext.Response.Cookies.Delete(HttpExtensions.REFRESH_TOKEN_COOKIE_KEY);
}
return ApiResponse.Succed();
}
}

View File

@@ -26,6 +26,14 @@ public static class Product
return routes;
}
public static RouteGroupBuilder MapProductAnonymous(this RouteGroupBuilder routes)
{
routes.MapGet("/product/search", SearchProductsAsync)
.WithTags(OpenApiTags..ToString());
return routes;
}
private static async
Task<ApiResponse<PagedResult<Services.Models.Resp.Product>>>
SearchProductsAsync(

View File

@@ -11,10 +11,6 @@ public static class Request
routes.MapPost("/request/publish", PublishRequestAsync)
.WithTags(OpenApiTags..ToString());
routes.MapGet("/request/search", SearchAsync)
.WithTags(OpenApiTags..ToString())
.AllowAnonymous();
routes.MapGet("/request/orders", OrderSearchAsync)
.WithTags(OpenApiTags..ToString());
@@ -24,6 +20,14 @@ public static class Request
return routes;
}
public static RouteGroupBuilder MapRequestAnonymous(this RouteGroupBuilder routes)
{
routes.MapGet("/request/search", SearchAsync)
.WithTags(OpenApiTags..ToString());
return routes;
}
private static async Task<ApiResponse> PublishRequestAsync(
CreateRequestParams model,
IRequestService requestService)

View File

@@ -17,18 +17,16 @@ public static class Root
.MapRequest()
.MapReply()
.MapDistrict()
.WithDescription("用户端调用")
.MapCommon()
.WithDescription("登录用户调用")
.RequireAuthorization(policy => policy.RequireRole(SystemRoles.User.ToString()));
app.MapGroup("")
.MapCommon()
.MapCategoryCommon()
.WithDescription("公共调用")
.RequireAuthorization();
app.MapGroup("")
.MapAdmin()
.MapRequestAnonymous()
.MapProductAnonymous()
.MapCategory()
.WithDescription("管理端调用")
.RequireAuthorization(policy => policy.RequireRole(SystemRoles.Admin.ToString()));
.MapUserAnonymous()
.WithDescription("匿名用户调用")
.AllowAnonymous();
}
}
@@ -40,6 +38,5 @@ public enum OpenApiTags
,
,
,
,
,
,
}

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
using StopShopping.Services;
using StopShopping.Services.Extensions;
using StopShopping.Services.Models.Req;
@@ -28,6 +29,18 @@ public static class User
return routes;
}
public static RouteGroupBuilder MapUserAnonymous(this RouteGroupBuilder routes)
{
routes.MapPost("/user/refreshtoken", RefreshTokenAsync)
.Produces<ApiResponse<AccessToken>>()
.WithTags(OpenApiTags..ToString());
routes.MapPost("/user/signout", SignOutAsync)
.WithTags(OpenApiTags..ToString());
return routes;
}
private static async Task<ApiResponse> SignUpAsync(
SignUpParams model,
IUserService userService)
@@ -41,8 +54,8 @@ public static class User
SignInParams model,
IUserService userService,
HttpContext httpContext,
IWebHostEnvironment env,
IOptions<AppOptions> options)
IOptions<AppOptions> options,
IWebHostEnvironment env)
{
var result = await userService.SignInAsync(model);
var resp = new ApiResponse<SignInUser>
@@ -73,7 +86,7 @@ public static class User
var resp = await userService.ChangePasswordAsync(model);
if (resp.IsSucced)
await Common.SignOutAsync(httpContext, accessTokenService);
await SignOutAsync(httpContext, accessTokenService);
return resp;
}
@@ -92,4 +105,41 @@ public static class User
{
return await userService.EditAsync(model);
}
private static async Task<IResult> RefreshTokenAsync(
HttpContext httpContext,
IAccessTokenService accessTokenService)
{
var refreshToken = httpContext.Request.Cookies[HttpExtensions.REFRESH_TOKEN_COOKIE_KEY];
if (string.IsNullOrWhiteSpace(refreshToken))
return Results.Unauthorized();
var accessToken = await accessTokenService.GenerateAccessTokenAsync(refreshToken);
if (null == accessToken)
return Results.Unauthorized();
return Results.Ok(new ApiResponse<AccessToken>(accessToken));
}
public static async Task<ApiResponse> SignOutAsync(
HttpContext httpContext,
IAccessTokenService accessTokenService)
{
var accessTokenHeader = httpContext.Request.Headers[HeaderNames.Authorization];
if (accessTokenHeader.Count != 0)
{
var accessToken = accessTokenHeader.First()!.Split(" ").Last();
if (!string.IsNullOrWhiteSpace(accessToken))
await accessTokenService.AddAccessTokenBlacklistAsync(accessToken);
}
var refreshToken = httpContext.Request.Cookies[HttpExtensions.REFRESH_TOKEN_COOKIE_KEY];
if (!string.IsNullOrWhiteSpace(refreshToken))
{
await accessTokenService.RevokeRefreshTokenAsync(refreshToken);
httpContext.Response.Cookies.Delete(HttpExtensions.REFRESH_TOKEN_COOKIE_KEY);
}
return ApiResponse.Succed();
}
}

View File

@@ -17,10 +17,4 @@
<ProjectReference Include="..\StopShopping.Services\StopShopping.Services.csproj" />
</ItemGroup>
<ItemGroup>
<Content Update="wwwroot\images\**\*">
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -1,27 +0,0 @@
using StopShopping.Services;
namespace StopShopping.Api.Workers;
public class DbSeederBackgroundService : BackgroundService
{
public DbSeederBackgroundService(IServiceProvider sp)
{
_sp = sp;
}
private readonly IServiceProvider _sp;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var scope = _sp.CreateScope();
using var scope1 = _sp.CreateScope();
var districtService = scope.ServiceProvider.GetRequiredService<IDistrictService>();
var userService = scope1.ServiceProvider.GetRequiredService<IUserService>();
var districtTask = districtService.InitialDatabaseAsync(stoppingToken);
var adminTask = userService.GenerateDefaultAdminAsync();
await Task.WhenAll(districtTask, adminTask);
}
}

View File

@@ -10,8 +10,9 @@
"RefreshTokenExpiresIn": "604800"
},
"AppOptions": {
"FileApiDomain": "FILE_API_DOMAIN",
"FileApiLocalDomain": "FILE_API_LOCAL_DOMAIN",
"CookieDomain": ".example.com或者localhost开发环境",
"DomainPath": "https://example.com或者http://localhost开发环境",
"CSRFHeaderName": "X-CSRF-TOKEN",
"CSRFCookieName": "csrf_token",
"CorsAllowedOrigins": ["https://web.example.com跨域"]