chore: 同步当前开发内容
This commit is contained in:
35
.vscode/launch.json
vendored
Normal file
35
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
// 使用 IntelliSense 找出 C# 调试存在哪些属性
|
||||
// 将悬停用于现有属性的说明
|
||||
// 有关详细信息,请访问 https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md。
|
||||
"name": ".NET Core Launch (web)",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build",
|
||||
// 如果已更改目标框架,请确保更新程序路径。
|
||||
"program": "${workspaceFolder}/src/Api/TakeoutSaaS.AdminApi/bin/Debug/net10.0/TakeoutSaaS.AdminApi.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/src/Api/TakeoutSaaS.AdminApi",
|
||||
"stopAtEntry": false,
|
||||
// 启用在启动 ASP.NET Core 时启动 Web 浏览器。有关详细信息: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser
|
||||
"serverReadyAction": {
|
||||
"action": "openExternally",
|
||||
"pattern": "\\bNow listening on:\\s+(https?://\\S+)"
|
||||
},
|
||||
"env": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"sourceFileMap": {
|
||||
"/Views": "${workspaceFolder}/Views"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": ".NET Core Attach",
|
||||
"type": "coreclr",
|
||||
"request": "attach"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"chatgpt.openOnStartup": true
|
||||
}
|
||||
41
.vscode/tasks.json
vendored
Normal file
41
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "build",
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"build",
|
||||
"${workspaceFolder}/TakeoutSaaS.sln",
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary;ForceNoAlign"
|
||||
],
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
"label": "publish",
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"publish",
|
||||
"${workspaceFolder}/TakeoutSaaS.sln",
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary;ForceNoAlign"
|
||||
],
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
"label": "watch",
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"watch",
|
||||
"run",
|
||||
"--project",
|
||||
"${workspaceFolder}/TakeoutSaaS.sln"
|
||||
],
|
||||
"problemMatcher": "$msCompile"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
# TODO Roadmap
|
||||
# TODO Roadmap
|
||||
|
||||
说明:本清单覆盖当前阶段的骨架搭建与核心基础能力(不含部署与CI/CD,留到项目跑通后再做)。
|
||||
|
||||
@@ -11,10 +11,10 @@
|
||||
- [x] 安全中间件:Security Headers、CORS 策略(按端区分)
|
||||
|
||||
## B. 认证与权限
|
||||
- [ ] JWT 颁发与刷新(AdminApi、MiniApi)
|
||||
- [ ] RBAC 权限模型(角色/权限/策略)与特性授权(AdminApi)
|
||||
- [ ] 小程序登录(微信 code2Session)并绑定用户账户(MiniApi)
|
||||
- [ ] 登录防刷限流(MiniApi)
|
||||
- [x] JWT 颁发与刷新(AdminApi、MiniApi)
|
||||
- [x] RBAC 权限模型(角色/权限/策略)与特性授权(AdminApi)
|
||||
- [x] 小程序登录(微信 code2Session)并绑定用户账户(MiniApi)
|
||||
- [x] 登录防刷限流(MiniApi)
|
||||
|
||||
## C. 多租户与参数字典
|
||||
- [ ] 多租户中间件:从 Header/域名解析租户(Shared.Web + Tenancy)
|
||||
|
||||
@@ -29,8 +29,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Infrastructure"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Modules", "Modules", "{EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Identity", "src\Modules\TakeoutSaaS.Module.Identity\TakeoutSaaS.Module.Identity.csproj", "{582EDD19-3C2F-4693-9595-CC367318CD19}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Authorization", "src\Modules\TakeoutSaaS.Module.Authorization\TakeoutSaaS.Module.Authorization.csproj", "{6CB8487D-5C74-487C-9D84-E57838BDA015}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Tenancy", "src\Modules\TakeoutSaaS.Module.Tenancy\TakeoutSaaS.Module.Tenancy.csproj", "{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}"
|
||||
@@ -129,18 +127,6 @@ Global
|
||||
{80B45C7D-9423-400A-8279-40D95BFEBC9D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{80B45C7D-9423-400A-8279-40D95BFEBC9D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{80B45C7D-9423-400A-8279-40D95BFEBC9D}.Release|x86.Build.0 = Release|Any CPU
|
||||
{582EDD19-3C2F-4693-9595-CC367318CD19}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{582EDD19-3C2F-4693-9595-CC367318CD19}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{582EDD19-3C2F-4693-9595-CC367318CD19}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{582EDD19-3C2F-4693-9595-CC367318CD19}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{582EDD19-3C2F-4693-9595-CC367318CD19}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{582EDD19-3C2F-4693-9595-CC367318CD19}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{582EDD19-3C2F-4693-9595-CC367318CD19}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{582EDD19-3C2F-4693-9595-CC367318CD19}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{582EDD19-3C2F-4693-9595-CC367318CD19}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{582EDD19-3C2F-4693-9595-CC367318CD19}.Release|x64.Build.0 = Release|Any CPU
|
||||
{582EDD19-3C2F-4693-9595-CC367318CD19}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{582EDD19-3C2F-4693-9595-CC367318CD19}.Release|x86.Build.0 = Release|Any CPU
|
||||
{6CB8487D-5C74-487C-9D84-E57838BDA015}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6CB8487D-5C74-487C-9D84-E57838BDA015}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6CB8487D-5C74-487C-9D84-E57838BDA015}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
@@ -242,7 +228,6 @@ Global
|
||||
{9048EB7F-3875-A59E-E36B-5BD4C6F2A282} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{80B45C7D-9423-400A-8279-40D95BFEBC9D} = {9048EB7F-3875-A59E-E36B-5BD4C6F2A282}
|
||||
{EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{582EDD19-3C2F-4693-9595-CC367318CD19} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
||||
{6CB8487D-5C74-487C-9D84-E57838BDA015} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
||||
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
||||
{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D} = {81034408-37C8-1011-444E-4C15C2FADA8E}
|
||||
|
||||
77
src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs
Normal file
77
src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.Shared.Web.Security;
|
||||
|
||||
namespace TakeoutSaaS.AdminApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台认证接口
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/auth")]
|
||||
public sealed class AuthController : BaseApiController
|
||||
{
|
||||
private readonly IAdminAuthService _authService;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="authService"></param>
|
||||
public AuthController(IAdminAuthService authService)
|
||||
{
|
||||
_authService = authService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 登录获取 Token
|
||||
/// </summary>
|
||||
[HttpPost("login")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ApiResponse<TokenResponse>>> Login([FromBody] AdminLoginRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await _authService.LoginAsync(request, cancellationToken);
|
||||
return Ok(ApiResponse<TokenResponse>.Ok(response));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 刷新 Token
|
||||
/// </summary>
|
||||
[HttpPost("refresh")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ApiResponse<TokenResponse>>> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await _authService.RefreshTokenAsync(request, cancellationToken);
|
||||
return Ok(ApiResponse<TokenResponse>.Ok(response));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前用户信息
|
||||
/// </summary>
|
||||
[HttpGet("profile")]
|
||||
[PermissionAuthorize("identity:profile:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<CurrentUserProfile>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ApiResponse<CurrentUserProfile>>> GetProfile(CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == Guid.Empty)
|
||||
{
|
||||
return Unauthorized(ApiResponse<CurrentUserProfile>.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识"));
|
||||
}
|
||||
|
||||
var profile = await _authService.GetProfileAsync(userId, cancellationToken);
|
||||
return Ok(ApiResponse<CurrentUserProfile>.Ok(profile));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Serilog;
|
||||
using TakeoutSaaS.Application.Identity.Extensions;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Extensions;
|
||||
using TakeoutSaaS.Module.Authorization.Extensions;
|
||||
using TakeoutSaaS.Module.Tenancy;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
using TakeoutSaaS.Shared.Web.Extensions;
|
||||
@@ -28,6 +31,11 @@ builder.Services.AddSharedSwagger(options =>
|
||||
options.Description = "管理后台 API 文档";
|
||||
options.EnableAuthorization = true;
|
||||
});
|
||||
builder.Services.AddIdentityApplication();
|
||||
builder.Services.AddIdentityInfrastructure(builder.Configuration, enableAdminSeed: true);
|
||||
builder.Services.AddJwtAuthentication(builder.Configuration);
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddPermissionAuthorization();
|
||||
|
||||
var adminOrigins = ResolveCorsOrigins(builder.Configuration, "Cors:Admin");
|
||||
builder.Services.AddCors(options =>
|
||||
@@ -44,6 +52,8 @@ var app = builder.Build();
|
||||
|
||||
app.UseCors("AdminApiCors");
|
||||
app.UseSharedWebCore();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseSharedSwagger();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
@@ -13,9 +13,7 @@
|
||||
<ProjectReference Include="..\..\Core\TakeoutSaaS.Shared.Web\TakeoutSaaS.Shared.Web.csproj" />
|
||||
<ProjectReference Include="..\..\Application\TakeoutSaaS.Application\TakeoutSaaS.Application.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\TakeoutSaaS.Infrastructure\TakeoutSaaS.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Identity\TakeoutSaaS.Module.Identity.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Authorization\TakeoutSaaS.Module.Authorization.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Tenancy\TakeoutSaaS.Module.Tenancy.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
|
||||
51
src/Api/TakeoutSaaS.MiniApi/Controllers/AuthController.cs
Normal file
51
src/Api/TakeoutSaaS.MiniApi/Controllers/AuthController.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
|
||||
namespace TakeoutSaaS.MiniApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 小程序登录认证
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/mini/v{version:apiVersion}/auth")]
|
||||
public sealed class AuthController : BaseApiController
|
||||
{
|
||||
private readonly IMiniAuthService _authService;
|
||||
|
||||
public AuthController(IMiniAuthService authService)
|
||||
{
|
||||
_authService = authService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 微信登录
|
||||
/// </summary>
|
||||
[HttpPost("wechat/login")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ApiResponse<TokenResponse>>> LoginWithWeChat([FromBody] WeChatLoginRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await _authService.LoginWithWeChatAsync(request, cancellationToken);
|
||||
return Ok(ApiResponse<TokenResponse>.Ok(response));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 刷新 Token
|
||||
/// </summary>
|
||||
[HttpPost("refresh")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ApiResponse<TokenResponse>>> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await _authService.RefreshTokenAsync(request, cancellationToken);
|
||||
return Ok(ApiResponse<TokenResponse>.Ok(response));
|
||||
}
|
||||
}
|
||||
51
src/Api/TakeoutSaaS.MiniApi/Controllers/MeController.cs
Normal file
51
src/Api/TakeoutSaaS.MiniApi/Controllers/MeController.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.Shared.Web.Security;
|
||||
|
||||
namespace TakeoutSaaS.MiniApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 当前用户信息
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/mini/v{version:apiVersion}/me")]
|
||||
public sealed class MeController : BaseApiController
|
||||
{
|
||||
private readonly IMiniAuthService _authService;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="authService"></param>
|
||||
public MeController(IMiniAuthService authService)
|
||||
{
|
||||
_authService = authService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取用户档案
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<CurrentUserProfile>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ApiResponse<CurrentUserProfile>>> Get(CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == Guid.Empty)
|
||||
{
|
||||
return Unauthorized(ApiResponse<CurrentUserProfile>.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识"));
|
||||
}
|
||||
|
||||
var profile = await _authService.GetProfileAsync(userId, cancellationToken);
|
||||
return Ok(ApiResponse<CurrentUserProfile>.Ok(profile));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Serilog;
|
||||
using TakeoutSaaS.Application.Identity.Extensions;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Extensions;
|
||||
using TakeoutSaaS.Module.Tenancy;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
using TakeoutSaaS.Shared.Web.Extensions;
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\TakeoutSaaS.Shared.Web\TakeoutSaaS.Shared.Web.csproj" />
|
||||
<ProjectReference Include="..\..\Application\TakeoutSaaS.Application\TakeoutSaaS.Application.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Identity\TakeoutSaaS.Module.Identity.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Tenancy\TakeoutSaaS.Module.Tenancy.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\TakeoutSaaS.Infrastructure\TakeoutSaaS.Infrastructure.csproj" />
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台认证服务。
|
||||
/// </summary>
|
||||
public interface IAdminAuthService
|
||||
{
|
||||
Task<TokenResponse> LoginAsync(AdminLoginRequest request, CancellationToken cancellationToken = default);
|
||||
Task<TokenResponse> RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default);
|
||||
Task<CurrentUserProfile> GetProfileAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// JWT 令牌服务契约。
|
||||
/// </summary>
|
||||
public interface IJwtTokenService
|
||||
{
|
||||
Task<TokenResponse> CreateTokensAsync(CurrentUserProfile profile, bool isNewUser = false, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 登录限流器。
|
||||
/// </summary>
|
||||
public interface ILoginRateLimiter
|
||||
{
|
||||
Task EnsureAllowedAsync(string key, CancellationToken cancellationToken = default);
|
||||
Task ResetAsync(string key, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 小程序认证服务。
|
||||
/// </summary>
|
||||
public interface IMiniAuthService
|
||||
{
|
||||
Task<TokenResponse> LoginWithWeChatAsync(WeChatLoginRequest request, CancellationToken cancellationToken = default);
|
||||
Task<TokenResponse> RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default);
|
||||
Task<CurrentUserProfile> GetProfileAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using TakeoutSaaS.Application.Identity.Models;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 刷新令牌存储。
|
||||
/// </summary>
|
||||
public interface IRefreshTokenStore
|
||||
{
|
||||
Task<RefreshTokenDescriptor> IssueAsync(Guid userId, DateTime expiresAt, CancellationToken cancellationToken = default);
|
||||
Task<RefreshTokenDescriptor?> GetAsync(string refreshToken, CancellationToken cancellationToken = default);
|
||||
Task RevokeAsync(string refreshToken, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
namespace TakeoutSaaS.Module.Identity.Abstractions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 微信登录服务抽象(code2Session)
|
||||
/// 微信 code2Session 服务契约。
|
||||
/// </summary>
|
||||
public interface IWeChatAuthService
|
||||
{
|
||||
/// <summary>
|
||||
/// 使用小程序登录 code 换取 openid/unionid/session_key
|
||||
/// </summary>
|
||||
Task<WeChatSessionInfo> Code2SessionAsync(string code, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 微信会话信息
|
||||
/// 微信会话信息。
|
||||
/// </summary>
|
||||
public sealed class WeChatSessionInfo
|
||||
{
|
||||
@@ -20,4 +20,3 @@ public sealed class WeChatSessionInfo
|
||||
public string? UnionId { get; init; }
|
||||
public string SessionKey { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台登录请求。
|
||||
/// </summary>
|
||||
public sealed class AdminLoginRequest
|
||||
{
|
||||
[Required]
|
||||
[MaxLength(64)]
|
||||
public string Account { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(128)]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 登录用户档案。
|
||||
/// </summary>
|
||||
public sealed class CurrentUserProfile
|
||||
{
|
||||
public Guid UserId { get; init; }
|
||||
public string Account { get; init; } = string.Empty;
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
public Guid TenantId { get; init; }
|
||||
public Guid? MerchantId { get; init; }
|
||||
public string[] Roles { get; init; } = Array.Empty<string>();
|
||||
public string[] Permissions { get; init; } = Array.Empty<string>();
|
||||
public string? Avatar { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 刷新令牌请求。
|
||||
/// </summary>
|
||||
public sealed class RefreshTokenRequest
|
||||
{
|
||||
[Required]
|
||||
[MaxLength(256)]
|
||||
public string RefreshToken { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Access/Refresh 令牌响应。
|
||||
/// </summary>
|
||||
public class TokenResponse
|
||||
{
|
||||
public string AccessToken { get; init; } = string.Empty;
|
||||
public DateTime AccessTokenExpiresAt { get; init; }
|
||||
public string RefreshToken { get; init; } = string.Empty;
|
||||
public DateTime RefreshTokenExpiresAt { get; init; }
|
||||
public CurrentUserProfile? User { get; init; }
|
||||
public bool IsNewUser { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 微信小程序登录请求。
|
||||
/// </summary>
|
||||
public sealed class WeChatLoginRequest
|
||||
{
|
||||
[Required]
|
||||
[MaxLength(128)]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(64)]
|
||||
public string? Nickname { get; set; }
|
||||
|
||||
[MaxLength(256)]
|
||||
public string? Avatar { get; set; }
|
||||
|
||||
public string? EncryptedData { get; set; }
|
||||
|
||||
public string? Iv { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Services;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// 应用层身份认证服务注入
|
||||
/// </summary>
|
||||
public static class IdentityServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册身份认证相关应用服务
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合</param>
|
||||
/// <param name="enableMiniSupport">是否注册小程序认证服务</param>
|
||||
public static IServiceCollection AddIdentityApplication(this IServiceCollection services, bool enableMiniSupport = false)
|
||||
{
|
||||
services.AddScoped<IAdminAuthService, AdminAuthService>();
|
||||
|
||||
if (enableMiniSupport)
|
||||
{
|
||||
services.AddScoped<IMiniAuthService, MiniAuthService>();
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 刷新令牌描述。
|
||||
/// </summary>
|
||||
public sealed record class RefreshTokenDescriptor(
|
||||
string Token,
|
||||
Guid UserId,
|
||||
DateTime ExpiresAt,
|
||||
bool Revoked);
|
||||
@@ -0,0 +1,87 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台认证服务实现。
|
||||
/// </summary>
|
||||
public sealed class AdminAuthService : IAdminAuthService
|
||||
{
|
||||
private readonly IIdentityUserRepository _userRepository;
|
||||
private readonly IPasswordHasher<IdentityUser> _passwordHasher;
|
||||
private readonly IJwtTokenService _jwtTokenService;
|
||||
private readonly IRefreshTokenStore _refreshTokenStore;
|
||||
|
||||
public AdminAuthService(
|
||||
IIdentityUserRepository userRepository,
|
||||
IPasswordHasher<IdentityUser> passwordHasher,
|
||||
IJwtTokenService jwtTokenService,
|
||||
IRefreshTokenStore refreshTokenStore)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_passwordHasher = passwordHasher;
|
||||
_jwtTokenService = jwtTokenService;
|
||||
_refreshTokenStore = refreshTokenStore;
|
||||
}
|
||||
|
||||
public async Task<TokenResponse> LoginAsync(AdminLoginRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await _userRepository.FindByAccountAsync(request.Account, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.Unauthorized, "账号或密码错误");
|
||||
|
||||
var result = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, request.Password);
|
||||
if (result == PasswordVerificationResult.Failed)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, "账号或密码错误");
|
||||
}
|
||||
|
||||
var profile = BuildProfile(user);
|
||||
return await _jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<TokenResponse> RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var descriptor = await _refreshTokenStore.GetAsync(request.RefreshToken, cancellationToken);
|
||||
if (descriptor == null || descriptor.ExpiresAt <= DateTime.UtcNow || descriptor.Revoked)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, "RefreshToken 无效或已过期");
|
||||
}
|
||||
|
||||
var user = await _userRepository.FindByIdAsync(descriptor.UserId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.Unauthorized, "用户不存在");
|
||||
|
||||
await _refreshTokenStore.RevokeAsync(descriptor.Token, cancellationToken);
|
||||
var profile = BuildProfile(user);
|
||||
return await _jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<CurrentUserProfile> GetProfileAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await _userRepository.FindByIdAsync(userId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
|
||||
|
||||
return BuildProfile(user);
|
||||
}
|
||||
|
||||
private static CurrentUserProfile BuildProfile(IdentityUser user)
|
||||
=> new()
|
||||
{
|
||||
UserId = user.Id,
|
||||
Account = user.Account,
|
||||
DisplayName = user.DisplayName,
|
||||
TenantId = user.TenantId,
|
||||
MerchantId = user.MerchantId,
|
||||
Roles = user.Roles,
|
||||
Permissions = user.Permissions,
|
||||
Avatar = user.Avatar
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 小程序认证服务实现。
|
||||
/// </summary>
|
||||
public sealed class MiniAuthService : IMiniAuthService
|
||||
{
|
||||
private readonly IWeChatAuthService _weChatAuthService;
|
||||
private readonly IMiniUserRepository _miniUserRepository;
|
||||
private readonly IJwtTokenService _jwtTokenService;
|
||||
private readonly IRefreshTokenStore _refreshTokenStore;
|
||||
private readonly ILoginRateLimiter _rateLimiter;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly ITenantProvider _tenantProvider;
|
||||
|
||||
public MiniAuthService(
|
||||
IWeChatAuthService weChatAuthService,
|
||||
IMiniUserRepository miniUserRepository,
|
||||
IJwtTokenService jwtTokenService,
|
||||
IRefreshTokenStore refreshTokenStore,
|
||||
ILoginRateLimiter rateLimiter,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ITenantProvider tenantProvider)
|
||||
{
|
||||
_weChatAuthService = weChatAuthService;
|
||||
_miniUserRepository = miniUserRepository;
|
||||
_jwtTokenService = jwtTokenService;
|
||||
_refreshTokenStore = refreshTokenStore;
|
||||
_rateLimiter = rateLimiter;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_tenantProvider = tenantProvider;
|
||||
}
|
||||
|
||||
public async Task<TokenResponse> LoginWithWeChatAsync(WeChatLoginRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var throttleKey = BuildThrottleKey();
|
||||
await _rateLimiter.EnsureAllowedAsync(throttleKey, cancellationToken);
|
||||
|
||||
var session = await _weChatAuthService.Code2SessionAsync(request.Code, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(session.OpenId))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, "获取微信用户信息失败");
|
||||
}
|
||||
|
||||
var tenantId = _tenantProvider.GetCurrentTenantId();
|
||||
if (tenantId == Guid.Empty)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "缺少租户标识");
|
||||
}
|
||||
var (user, isNew) = await GetOrBindMiniUserAsync(session.OpenId, session.UnionId, request.Nickname, request.Avatar, tenantId, cancellationToken);
|
||||
|
||||
await _rateLimiter.ResetAsync(throttleKey, cancellationToken);
|
||||
var profile = BuildProfile(user);
|
||||
return await _jwtTokenService.CreateTokensAsync(profile, isNew, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<TokenResponse> RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var descriptor = await _refreshTokenStore.GetAsync(request.RefreshToken, cancellationToken);
|
||||
if (descriptor == null || descriptor.ExpiresAt <= DateTime.UtcNow || descriptor.Revoked)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, "RefreshToken 无效或已过期");
|
||||
}
|
||||
|
||||
var user = await _miniUserRepository.FindByIdAsync(descriptor.UserId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.Unauthorized, "用户不存在");
|
||||
|
||||
await _refreshTokenStore.RevokeAsync(descriptor.Token, cancellationToken);
|
||||
var profile = BuildProfile(user);
|
||||
return await _jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<CurrentUserProfile> GetProfileAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await _miniUserRepository.FindByIdAsync(userId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
|
||||
|
||||
return BuildProfile(user);
|
||||
}
|
||||
|
||||
private async Task<(MiniUser user, bool isNew)> GetOrBindMiniUserAsync(string openId, string? unionId, string? nickname, string? avatar, Guid tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _miniUserRepository.FindByOpenIdAsync(openId, cancellationToken);
|
||||
if (existing != null)
|
||||
{
|
||||
return (existing, false);
|
||||
}
|
||||
|
||||
var created = await _miniUserRepository.CreateOrUpdateAsync(openId, unionId, nickname, avatar, tenantId, cancellationToken);
|
||||
return (created, true);
|
||||
}
|
||||
|
||||
private static CurrentUserProfile BuildProfile(MiniUser user)
|
||||
=> new()
|
||||
{
|
||||
UserId = user.Id,
|
||||
Account = user.OpenId,
|
||||
DisplayName = user.Nickname,
|
||||
TenantId = user.TenantId,
|
||||
MerchantId = null,
|
||||
Roles = Array.Empty<string>(),
|
||||
Permissions = Array.Empty<string>(),
|
||||
Avatar = user.Avatar
|
||||
};
|
||||
|
||||
private string BuildThrottleKey()
|
||||
{
|
||||
var ip = _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress ?? IPAddress.Loopback;
|
||||
return $"mini-login:{ip}";
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="8.0.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\TakeoutSaaS.Domain\TakeoutSaaS.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace TakeoutSaaS.Shared.Web.Security;
|
||||
|
||||
/// <summary>
|
||||
/// ClaimsPrincipal 便捷扩展
|
||||
/// </summary>
|
||||
public static class ClaimsPrincipalExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取当前用户 Id(不存在时返回 Guid.Empty)
|
||||
/// </summary>
|
||||
public static Guid GetUserId(this ClaimsPrincipal? principal)
|
||||
{
|
||||
if (principal == null)
|
||||
{
|
||||
return Guid.Empty;
|
||||
}
|
||||
|
||||
var identifier = principal.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? principal.FindFirstValue("sub");
|
||||
|
||||
return Guid.TryParse(identifier, out var userId)
|
||||
? userId
|
||||
: Guid.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Identity.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 后台账号实体(平台/商户/员工)。
|
||||
/// </summary>
|
||||
public sealed class IdentityUser
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 登录账号。
|
||||
/// </summary>
|
||||
public string Account { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 展示名称。
|
||||
/// </summary>
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 密码哈希。
|
||||
/// </summary>
|
||||
public string PasswordHash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 所属租户。
|
||||
/// </summary>
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 所属商户(平台管理员为空)。
|
||||
/// </summary>
|
||||
public Guid? MerchantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 角色集合。
|
||||
/// </summary>
|
||||
public string[] Roles { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 权限集合。
|
||||
/// </summary>
|
||||
public string[] Permissions { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 头像地址。
|
||||
/// </summary>
|
||||
public string? Avatar { get; set; }
|
||||
}
|
||||
39
src/Domain/TakeoutSaaS.Domain/Identity/Entities/MiniUser.cs
Normal file
39
src/Domain/TakeoutSaaS.Domain/Identity/Entities/MiniUser.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Identity.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 小程序用户。
|
||||
/// </summary>
|
||||
public sealed class MiniUser
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 微信 OpenId。
|
||||
/// </summary>
|
||||
public string OpenId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 微信 UnionId,可为空。
|
||||
/// </summary>
|
||||
public string? UnionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 昵称。
|
||||
/// </summary>
|
||||
public string Nickname { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 头像地址。
|
||||
/// </summary>
|
||||
public string? Avatar { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 所属租户。
|
||||
/// </summary>
|
||||
public Guid TenantId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Identity.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 后台用户仓储契约。
|
||||
/// </summary>
|
||||
public interface IIdentityUserRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据账号获取后台用户。
|
||||
/// </summary>
|
||||
Task<IdentityUser?> FindByAccountAsync(string account, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 根据 ID 获取后台用户。
|
||||
/// </summary>
|
||||
Task<IdentityUser?> FindByIdAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Identity.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 小程序用户仓储契约。
|
||||
/// </summary>
|
||||
public interface IMiniUserRepository
|
||||
{
|
||||
Task<MiniUser?> FindByOpenIdAsync(string openId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<MiniUser?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<MiniUser> CreateOrUpdateAsync(string openId, string? unionId, string? nickname, string? avatar, Guid tenantId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,49 +1,63 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Yarp.ReverseProxy.Configuration;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddReverseProxy()
|
||||
.LoadFromMemory(new()
|
||||
var routes = new[]
|
||||
{
|
||||
new RouteConfig
|
||||
{
|
||||
Clusters =
|
||||
RouteId = "admin-route",
|
||||
ClusterId = "admin",
|
||||
Match = new() { Path = "/api/admin/{**catch-all}" }
|
||||
},
|
||||
new RouteConfig
|
||||
{
|
||||
RouteId = "mini-route",
|
||||
ClusterId = "mini",
|
||||
Match = new() { Path = "/api/mini/{**catch-all}" }
|
||||
},
|
||||
new RouteConfig
|
||||
{
|
||||
RouteId = "user-route",
|
||||
ClusterId = "user",
|
||||
Match = new() { Path = "/api/user/{**catch-all}" }
|
||||
}
|
||||
};
|
||||
|
||||
var clusters = new[]
|
||||
{
|
||||
new ClusterConfig
|
||||
{
|
||||
ClusterId = "admin",
|
||||
Destinations = new Dictionary<string, DestinationConfig>
|
||||
{
|
||||
["admin"] = new()
|
||||
{
|
||||
Destinations = { ["d1"] = new() { Address = "http://localhost:5001/" } }
|
||||
},
|
||||
["mini"] = new()
|
||||
{
|
||||
Destinations = { ["d1"] = new() { Address = "http://localhost:5002/" } }
|
||||
},
|
||||
["user"] = new()
|
||||
{
|
||||
Destinations = { ["d1"] = new() { Address = "http://localhost:5003/" } }
|
||||
}
|
||||
},
|
||||
Routes =
|
||||
{
|
||||
new()
|
||||
{
|
||||
RouteId = "admin-route",
|
||||
ClusterId = "admin",
|
||||
Match = new() { Path = "/api/admin/{**catch-all}" }
|
||||
},
|
||||
new()
|
||||
{
|
||||
RouteId = "mini-route",
|
||||
ClusterId = "mini",
|
||||
Match = new() { Path = "/api/mini/{**catch-all}" }
|
||||
},
|
||||
new()
|
||||
{
|
||||
RouteId = "user-route",
|
||||
ClusterId = "user",
|
||||
Match = new() { Path = "/api/user/{**catch-all}" }
|
||||
}
|
||||
["d1"] = new() { Address = "http://localhost:5001/" }
|
||||
}
|
||||
});
|
||||
},
|
||||
new ClusterConfig
|
||||
{
|
||||
ClusterId = "mini",
|
||||
Destinations = new Dictionary<string, DestinationConfig>
|
||||
{
|
||||
["d1"] = new() { Address = "http://localhost:5002/" }
|
||||
}
|
||||
},
|
||||
new ClusterConfig
|
||||
{
|
||||
ClusterId = "user",
|
||||
Destinations = new Dictionary<string, DestinationConfig>
|
||||
{
|
||||
["d1"] = new() { Address = "http://localhost:5003/" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
builder.Services.AddReverseProxy()
|
||||
.LoadFromMemory(routes, clusters);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// JWT 认证扩展
|
||||
/// </summary>
|
||||
public static class JwtAuthenticationExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 配置 JWT Bearer 认证
|
||||
/// </summary>
|
||||
public static IServiceCollection AddJwtAuthentication(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var jwtOptions = configuration.GetSection("Identity:Jwt").Get<JwtOptions>()
|
||||
?? throw new InvalidOperationException("缺少 Identity:Jwt 配置");
|
||||
|
||||
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
|
||||
JwtSecurityTokenHandler.DefaultOutboundClaimTypeMap.Clear();
|
||||
|
||||
services
|
||||
.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.RequireHttpsMetadata = false;
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = jwtOptions.Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = jwtOptions.Audience,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.Secret)),
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromMinutes(1),
|
||||
NameClaimType = ClaimTypes.NameIdentifier,
|
||||
RoleClaimType = ClaimTypes.Role
|
||||
};
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Services;
|
||||
using DomainIdentityUser = TakeoutSaaS.Domain.Identity.Entities.IdentityUser;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// 身份认证基础设施注入
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册身份认证基础设施(数据库、Redis、JWT、限流等)
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合</param>
|
||||
/// <param name="configuration">配置源</param>
|
||||
/// <param name="enableMiniFeatures">是否启用小程序相关依赖(如微信登录)</param>
|
||||
/// <param name="enableAdminSeed">是否启用后台账号初始化</param>
|
||||
public static IServiceCollection AddIdentityInfrastructure(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
bool enableMiniFeatures = false,
|
||||
bool enableAdminSeed = false)
|
||||
{
|
||||
var dbConnection = configuration.GetConnectionString("IdentityDatabase");
|
||||
if (string.IsNullOrWhiteSpace(dbConnection))
|
||||
{
|
||||
throw new InvalidOperationException("缺少 IdentityDatabase 连接字符串配置");
|
||||
}
|
||||
|
||||
services.AddDbContext<IdentityDbContext>(options => options.UseNpgsql(dbConnection));
|
||||
|
||||
var redisConnection = configuration.GetConnectionString("Redis");
|
||||
if (string.IsNullOrWhiteSpace(redisConnection))
|
||||
{
|
||||
throw new InvalidOperationException("缺少 Redis 连接字符串配置");
|
||||
}
|
||||
|
||||
services.AddStackExchangeRedisCache(options =>
|
||||
{
|
||||
options.Configuration = redisConnection;
|
||||
});
|
||||
|
||||
services.AddScoped<IIdentityUserRepository, EfIdentityUserRepository>();
|
||||
services.AddScoped<IMiniUserRepository, EfMiniUserRepository>();
|
||||
services.AddScoped<IJwtTokenService, JwtTokenService>();
|
||||
services.AddScoped<IRefreshTokenStore, RedisRefreshTokenStore>();
|
||||
services.AddScoped<ILoginRateLimiter, RedisLoginRateLimiter>();
|
||||
services.AddScoped<IPasswordHasher<DomainIdentityUser>, PasswordHasher<DomainIdentityUser>>();
|
||||
|
||||
services.AddOptions<JwtOptions>()
|
||||
.Bind(configuration.GetSection("Identity:Jwt"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddOptions<LoginRateLimitOptions>()
|
||||
.Bind(configuration.GetSection("Identity:LoginRateLimit"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddOptions<RefreshTokenStoreOptions>()
|
||||
.Bind(configuration.GetSection("Identity:RefreshTokenStore"));
|
||||
|
||||
if (enableMiniFeatures)
|
||||
{
|
||||
services.AddOptions<WeChatMiniOptions>()
|
||||
.Bind(configuration.GetSection("Identity:WeChatMini"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddHttpClient<IWeChatAuthService, WeChatAuthService>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://api.weixin.qq.com/");
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
});
|
||||
}
|
||||
|
||||
if (enableAdminSeed)
|
||||
{
|
||||
services.AddOptions<AdminSeedOptions>()
|
||||
.Bind(configuration.GetSection("Identity:AdminSeed"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddHostedService<IdentityDataSeeder>();
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台初始账号配置。
|
||||
/// </summary>
|
||||
public sealed class AdminSeedOptions
|
||||
{
|
||||
public List<SeedUserOptions> Users { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class SeedUserOptions
|
||||
{
|
||||
[Required]
|
||||
public string Account { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
public Guid TenantId { get; set; }
|
||||
public Guid? MerchantId { get; set; }
|
||||
public string[] Roles { get; set; } = Array.Empty<string>();
|
||||
public string[] Permissions { get; set; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
/// <summary>
|
||||
/// JWT 配置。
|
||||
/// </summary>
|
||||
public sealed class JwtOptions
|
||||
{
|
||||
[Required]
|
||||
public string Issuer { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string Audience { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MinLength(32)]
|
||||
public string Secret { get; set; } = string.Empty;
|
||||
|
||||
[Range(5, 1440)]
|
||||
public int AccessTokenExpirationMinutes { get; set; } = 60;
|
||||
|
||||
[Range(60, 1440 * 14)]
|
||||
public int RefreshTokenExpirationMinutes { get; set; } = 60 * 24 * 7;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 登录限流配置。
|
||||
/// </summary>
|
||||
public sealed class LoginRateLimitOptions
|
||||
{
|
||||
[Range(1, 3600)]
|
||||
public int WindowSeconds { get; set; } = 60;
|
||||
|
||||
[Range(1, 100)]
|
||||
public int MaxAttempts { get; set; } = 5;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 刷新令牌存储配置。
|
||||
/// </summary>
|
||||
public sealed class RefreshTokenStoreOptions
|
||||
{
|
||||
public string Prefix { get; set; } = "identity:refresh:";
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 微信小程序配置。
|
||||
/// </summary>
|
||||
public sealed class WeChatMiniOptions
|
||||
{
|
||||
[Required]
|
||||
public string AppId { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string Secret { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core 后台用户仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfIdentityUserRepository : IIdentityUserRepository
|
||||
{
|
||||
private readonly IdentityDbContext _dbContext;
|
||||
|
||||
public EfIdentityUserRepository(IdentityDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
public Task<IdentityUser?> FindByAccountAsync(string account, CancellationToken cancellationToken = default)
|
||||
=> _dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Account == account, cancellationToken);
|
||||
|
||||
public Task<IdentityUser?> FindByIdAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||
=> _dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core 小程序用户仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfMiniUserRepository : IMiniUserRepository
|
||||
{
|
||||
private readonly IdentityDbContext _dbContext;
|
||||
|
||||
public EfMiniUserRepository(IdentityDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
public Task<MiniUser?> FindByOpenIdAsync(string openId, CancellationToken cancellationToken = default)
|
||||
=> _dbContext.MiniUsers.AsNoTracking().FirstOrDefaultAsync(x => x.OpenId == openId, cancellationToken);
|
||||
|
||||
public Task<MiniUser?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _dbContext.MiniUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
|
||||
public async Task<MiniUser> CreateOrUpdateAsync(string openId, string? unionId, string? nickname, string? avatar, Guid tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await _dbContext.MiniUsers.FirstOrDefaultAsync(x => x.OpenId == openId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
user = new MiniUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OpenId = openId,
|
||||
UnionId = unionId,
|
||||
Nickname = nickname ?? "小程序用户",
|
||||
Avatar = avatar,
|
||||
TenantId = tenantId
|
||||
};
|
||||
_dbContext.MiniUsers.Add(user);
|
||||
}
|
||||
else
|
||||
{
|
||||
user.UnionId = unionId ?? user.UnionId;
|
||||
user.Nickname = nickname ?? user.Nickname;
|
||||
user.Avatar = avatar ?? user.Avatar;
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
using DomainIdentityUser = TakeoutSaaS.Domain.Identity.Entities.IdentityUser;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 后台账号初始化种子任务
|
||||
/// </summary>
|
||||
public sealed class IdentityDataSeeder : IHostedService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<IdentityDataSeeder> _logger;
|
||||
|
||||
public IdentityDataSeeder(IServiceProvider serviceProvider, ILogger<IdentityDataSeeder> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
|
||||
var options = scope.ServiceProvider.GetRequiredService<IOptions<AdminSeedOptions>>().Value;
|
||||
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<DomainIdentityUser>>();
|
||||
|
||||
await context.Database.MigrateAsync(cancellationToken);
|
||||
|
||||
if (options.Users == null || options.Users.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("AdminSeed 未配置账号,跳过后台账号初始化");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var userOptions in options.Users)
|
||||
{
|
||||
var user = await context.IdentityUsers.FirstOrDefaultAsync(x => x.Account == userOptions.Account, cancellationToken);
|
||||
var roles = NormalizeValues(userOptions.Roles);
|
||||
var permissions = NormalizeValues(userOptions.Permissions);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
user = new DomainIdentityUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Account = userOptions.Account,
|
||||
DisplayName = userOptions.DisplayName,
|
||||
TenantId = userOptions.TenantId,
|
||||
MerchantId = userOptions.MerchantId,
|
||||
Avatar = null,
|
||||
Roles = roles,
|
||||
Permissions = permissions,
|
||||
};
|
||||
user.PasswordHash = passwordHasher.HashPassword(user, userOptions.Password);
|
||||
context.IdentityUsers.Add(user);
|
||||
_logger.LogInformation("已创建后台账号 {Account}", user.Account);
|
||||
}
|
||||
else
|
||||
{
|
||||
user.DisplayName = userOptions.DisplayName;
|
||||
user.TenantId = userOptions.TenantId;
|
||||
user.MerchantId = userOptions.MerchantId;
|
||||
user.Roles = roles;
|
||||
user.Permissions = permissions;
|
||||
user.PasswordHash = passwordHasher.HashPassword(user, userOptions.Password);
|
||||
_logger.LogInformation("已更新后台账号 {Account}", user.Account);
|
||||
}
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
private static string[] NormalizeValues(string[]? values)
|
||||
=> values == null
|
||||
? Array.Empty<string>()
|
||||
: values
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||
.Select(v => v.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 身份认证 DbContext。
|
||||
/// </summary>
|
||||
public sealed class IdentityDbContext : DbContext
|
||||
{
|
||||
public IdentityDbContext(DbContextOptions<IdentityDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<IdentityUser> IdentityUsers => Set<IdentityUser>();
|
||||
public DbSet<MiniUser> MiniUsers => Set<MiniUser>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
ConfigureIdentityUser(modelBuilder.Entity<IdentityUser>());
|
||||
ConfigureMiniUser(modelBuilder.Entity<MiniUser>());
|
||||
}
|
||||
|
||||
private static void ConfigureIdentityUser(EntityTypeBuilder<IdentityUser> builder)
|
||||
{
|
||||
builder.ToTable("identity_users");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.Account).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.DisplayName).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.PasswordHash).HasMaxLength(256).IsRequired();
|
||||
builder.Property(x => x.Avatar).HasMaxLength(256);
|
||||
|
||||
var converter = new ValueConverter<string[], string>(
|
||||
v => string.Join(',', v ?? Array.Empty<string>()),
|
||||
v => string.IsNullOrWhiteSpace(v) ? Array.Empty<string>() : v.Split(',', StringSplitOptions.RemoveEmptyEntries));
|
||||
|
||||
var comparer = new ValueComparer<string[]>(
|
||||
(l, r) => l!.SequenceEqual(r!),
|
||||
v => v.Aggregate(0, (current, item) => HashCode.Combine(current, item.GetHashCode())),
|
||||
v => v.ToArray());
|
||||
|
||||
builder.Property(x => x.Roles)
|
||||
.HasConversion(converter)
|
||||
.Metadata.SetValueComparer(comparer);
|
||||
|
||||
builder.Property(x => x.Permissions)
|
||||
.HasConversion(converter)
|
||||
.Metadata.SetValueComparer(comparer);
|
||||
|
||||
builder.HasIndex(x => x.Account).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureMiniUser(EntityTypeBuilder<MiniUser> builder)
|
||||
{
|
||||
builder.ToTable("mini_users");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.OpenId).HasMaxLength(128).IsRequired();
|
||||
builder.Property(x => x.UnionId).HasMaxLength(128);
|
||||
builder.Property(x => x.Nickname).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.Avatar).HasMaxLength(256);
|
||||
|
||||
builder.HasIndex(x => x.OpenId).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Services;
|
||||
|
||||
/// <summary>
|
||||
/// JWT 令牌生成器。
|
||||
/// </summary>
|
||||
public sealed class JwtTokenService : IJwtTokenService
|
||||
{
|
||||
private readonly JwtSecurityTokenHandler _tokenHandler = new();
|
||||
private readonly IRefreshTokenStore _refreshTokenStore;
|
||||
private readonly JwtOptions _options;
|
||||
|
||||
public JwtTokenService(IRefreshTokenStore refreshTokenStore, IOptions<JwtOptions> options)
|
||||
{
|
||||
_refreshTokenStore = refreshTokenStore;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task<TokenResponse> CreateTokensAsync(CurrentUserProfile profile, bool isNewUser = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var accessExpires = now.AddMinutes(_options.AccessTokenExpirationMinutes);
|
||||
var refreshExpires = now.AddMinutes(_options.RefreshTokenExpirationMinutes);
|
||||
|
||||
var claims = BuildClaims(profile);
|
||||
var signingCredentials = new SigningCredentials(
|
||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Secret)),
|
||||
SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var jwt = new JwtSecurityToken(
|
||||
issuer: _options.Issuer,
|
||||
audience: _options.Audience,
|
||||
claims: claims,
|
||||
notBefore: now,
|
||||
expires: accessExpires,
|
||||
signingCredentials: signingCredentials);
|
||||
|
||||
var accessToken = _tokenHandler.WriteToken(jwt);
|
||||
var refreshDescriptor = await _refreshTokenStore.IssueAsync(profile.UserId, refreshExpires, cancellationToken);
|
||||
|
||||
return new TokenResponse
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
AccessTokenExpiresAt = accessExpires,
|
||||
RefreshToken = refreshDescriptor.Token,
|
||||
RefreshTokenExpiresAt = refreshDescriptor.ExpiresAt,
|
||||
User = profile,
|
||||
IsNewUser = isNewUser
|
||||
};
|
||||
}
|
||||
|
||||
private static IEnumerable<Claim> BuildClaims(CurrentUserProfile profile)
|
||||
{
|
||||
var userId = profile.UserId.ToString();
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, userId),
|
||||
new(ClaimTypes.NameIdentifier, userId),
|
||||
new(JwtRegisteredClaimNames.UniqueName, profile.Account),
|
||||
new("tenant_id", profile.TenantId.ToString()),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||
};
|
||||
|
||||
if (profile.MerchantId.HasValue)
|
||||
{
|
||||
claims.Add(new Claim("merchant_id", profile.MerchantId.Value.ToString()));
|
||||
}
|
||||
|
||||
foreach (var role in profile.Roles)
|
||||
{
|
||||
claims.Add(new Claim(ClaimTypes.Role, role));
|
||||
}
|
||||
|
||||
foreach (var permission in profile.Permissions)
|
||||
{
|
||||
claims.Add(new Claim("permission", permission));
|
||||
}
|
||||
|
||||
return claims;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Redis 登录限流实现。
|
||||
/// </summary>
|
||||
public sealed class RedisLoginRateLimiter : ILoginRateLimiter
|
||||
{
|
||||
private readonly IDistributedCache _cache;
|
||||
private readonly LoginRateLimitOptions _options;
|
||||
|
||||
public RedisLoginRateLimiter(IDistributedCache cache, IOptions<LoginRateLimitOptions> options)
|
||||
{
|
||||
_cache = cache;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task EnsureAllowedAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = BuildKey(key);
|
||||
var current = await _cache.GetStringAsync(cacheKey, cancellationToken);
|
||||
var count = string.IsNullOrWhiteSpace(current) ? 0 : int.Parse(current);
|
||||
if (count >= _options.MaxAttempts)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "尝试次数过多,请稍后再试");
|
||||
}
|
||||
|
||||
count++;
|
||||
await _cache.SetStringAsync(
|
||||
cacheKey,
|
||||
count.ToString(),
|
||||
new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(_options.WindowSeconds)
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task ResetAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> _cache.RemoveAsync(BuildKey(key), cancellationToken);
|
||||
|
||||
private static string BuildKey(string key) => $"identity:login:{key}";
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Models;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Redis 刷新令牌存储。
|
||||
/// </summary>
|
||||
public sealed class RedisRefreshTokenStore : IRefreshTokenStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly IDistributedCache _cache;
|
||||
private readonly RefreshTokenStoreOptions _options;
|
||||
|
||||
public RedisRefreshTokenStore(IDistributedCache cache, IOptions<RefreshTokenStoreOptions> options)
|
||||
{
|
||||
_cache = cache;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task<RefreshTokenDescriptor> IssueAsync(Guid userId, DateTime expiresAt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(48));
|
||||
var descriptor = new RefreshTokenDescriptor(token, userId, expiresAt, false);
|
||||
|
||||
var key = BuildKey(token);
|
||||
var entryOptions = new DistributedCacheEntryOptions { AbsoluteExpiration = expiresAt };
|
||||
await _cache.SetStringAsync(key, JsonSerializer.Serialize(descriptor, JsonOptions), entryOptions, cancellationToken);
|
||||
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
public async Task<RefreshTokenDescriptor?> GetAsync(string refreshToken, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var json = await _cache.GetStringAsync(BuildKey(refreshToken), cancellationToken);
|
||||
return string.IsNullOrWhiteSpace(json)
|
||||
? null
|
||||
: JsonSerializer.Deserialize<RefreshTokenDescriptor>(json, JsonOptions);
|
||||
}
|
||||
|
||||
public async Task RevokeAsync(string refreshToken, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var descriptor = await GetAsync(refreshToken, cancellationToken);
|
||||
if (descriptor == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var updated = descriptor with { Revoked = true };
|
||||
var entryOptions = new DistributedCacheEntryOptions { AbsoluteExpiration = updated.ExpiresAt };
|
||||
await _cache.SetStringAsync(BuildKey(refreshToken), JsonSerializer.Serialize(updated, JsonOptions), entryOptions, cancellationToken);
|
||||
}
|
||||
|
||||
private string BuildKey(string token) => $"{_options.Prefix}{token}";
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 微信 code2Session 实现
|
||||
/// </summary>
|
||||
public sealed class WeChatAuthService : IWeChatAuthService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly WeChatMiniOptions _options;
|
||||
|
||||
public WeChatAuthService(HttpClient httpClient, IOptions<WeChatMiniOptions> options)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task<WeChatSessionInfo> Code2SessionAsync(string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var requestUri = $"sns/jscode2session?appid={Uri.EscapeDataString(_options.AppId)}&secret={Uri.EscapeDataString(_options.Secret)}&js_code={Uri.EscapeDataString(code)}&grant_type=authorization_code";
|
||||
using var response = await _httpClient.GetAsync(requestUri, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<WeChatSessionResponse>(cancellationToken: cancellationToken);
|
||||
if (payload == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, "微信登录失败:响应为空");
|
||||
}
|
||||
|
||||
if (payload.ErrorCode.HasValue && payload.ErrorCode.Value != 0)
|
||||
{
|
||||
var message = string.IsNullOrWhiteSpace(payload.ErrorMessage)
|
||||
? $"微信登录失败,错误码:{payload.ErrorCode}"
|
||||
: payload.ErrorMessage;
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, message);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(payload.OpenId) || string.IsNullOrWhiteSpace(payload.SessionKey))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, "微信登录失败:返回数据无效");
|
||||
}
|
||||
|
||||
return new WeChatSessionInfo
|
||||
{
|
||||
OpenId = payload.OpenId,
|
||||
UnionId = payload.UnionId,
|
||||
SessionKey = payload.SessionKey
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class WeChatSessionResponse
|
||||
{
|
||||
[JsonPropertyName("openid")]
|
||||
public string? OpenId { get; set; }
|
||||
|
||||
[JsonPropertyName("unionid")]
|
||||
public string? UnionId { get; set; }
|
||||
|
||||
[JsonPropertyName("session_key")]
|
||||
public string? SessionKey { get; set; }
|
||||
|
||||
[JsonPropertyName("errcode")]
|
||||
public int? ErrorCode { get; set; }
|
||||
|
||||
[JsonPropertyName("errmsg")]
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,18 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0-rc.2" />
|
||||
<PackageReference Include="Dapper" Version="2.1.66" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.1" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.5.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\TakeoutSaaS.Domain\TakeoutSaaS.Domain.csproj" />
|
||||
<ProjectReference Include="..\..\Application\TakeoutSaaS.Application\TakeoutSaaS.Application.csproj" />
|
||||
<ProjectReference Include="..\..\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using TakeoutSaaS.Module.Authorization.Policies;
|
||||
|
||||
namespace TakeoutSaaS.Module.Authorization.Attributes;
|
||||
|
||||
/// <summary>
|
||||
/// 权限校验特性
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
|
||||
public sealed class PermissionAuthorizeAttribute : AuthorizeAttribute
|
||||
{
|
||||
public PermissionAuthorizeAttribute(params string[] permissions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(permissions);
|
||||
var normalized = permissions
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||
.Select(p => p.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("至少需要一个权限标识", nameof(permissions));
|
||||
}
|
||||
|
||||
Permissions = normalized;
|
||||
Policy = PermissionAuthorizationPolicyProvider.BuildPolicyName(normalized);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 所需权限集合
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> Permissions { get; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TakeoutSaaS.Module.Authorization.Policies;
|
||||
|
||||
namespace TakeoutSaaS.Module.Authorization.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// 权限授权注入扩展
|
||||
/// </summary>
|
||||
public static class AuthorizationServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 启用自定义权限策略提供者与处理器
|
||||
/// </summary>
|
||||
public static IServiceCollection AddPermissionAuthorization(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IAuthorizationPolicyProvider, PermissionAuthorizationPolicyProvider>();
|
||||
services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace TakeoutSaaS.Module.Authorization.Policies;
|
||||
|
||||
/// <summary>
|
||||
/// 权限校验处理器
|
||||
/// </summary>
|
||||
public sealed class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
|
||||
{
|
||||
public const string PermissionClaimType = "permission";
|
||||
|
||||
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, PermissionRequirement requirement)
|
||||
{
|
||||
if (context.User?.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var userPermissions = context.User
|
||||
.FindAll(PermissionClaimType)
|
||||
.Select(claim => claim.Value)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (userPermissions.Count == 0)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (requirement.Permissions.Any(userPermissions.Contains))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace TakeoutSaaS.Module.Authorization.Policies;
|
||||
|
||||
/// <summary>
|
||||
/// 权限策略提供者(按需动态构建策略)
|
||||
/// </summary>
|
||||
public sealed class PermissionAuthorizationPolicyProvider : DefaultAuthorizationPolicyProvider
|
||||
{
|
||||
public const string PolicyPrefix = "PERMISSION:";
|
||||
private readonly AuthorizationOptions _options;
|
||||
|
||||
public PermissionAuthorizationPolicyProvider(IOptions<AuthorizationOptions> options)
|
||||
: base(options)
|
||||
{
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public override Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
|
||||
{
|
||||
if (policyName.StartsWith(PolicyPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var existingPolicy = _options.GetPolicy(policyName);
|
||||
if (existingPolicy != null)
|
||||
{
|
||||
return Task.FromResult(existingPolicy);
|
||||
}
|
||||
|
||||
var permissions = ParsePermissions(policyName);
|
||||
if (permissions.Length == 0)
|
||||
{
|
||||
return Task.FromResult<AuthorizationPolicy?>(null);
|
||||
}
|
||||
|
||||
var policy = new AuthorizationPolicyBuilder()
|
||||
.AddRequirements(new PermissionRequirement(permissions))
|
||||
.Build();
|
||||
|
||||
_options.AddPolicy(policyName, policy);
|
||||
return Task.FromResult<AuthorizationPolicy?>(policy);
|
||||
}
|
||||
|
||||
return base.GetPolicyAsync(policyName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据权限集合构建策略名称
|
||||
/// </summary>
|
||||
public static string BuildPolicyName(IEnumerable<string> permissions)
|
||||
=> $"{PolicyPrefix}{string.Join('|', NormalizePermissions(permissions))}";
|
||||
|
||||
private static string[] ParsePermissions(string policyName)
|
||||
{
|
||||
var raw = policyName[PolicyPrefix.Length..];
|
||||
return NormalizePermissions(raw.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
}
|
||||
|
||||
private static string[] NormalizePermissions(IEnumerable<string> permissions)
|
||||
=> permissions
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||
.Select(p => p.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace TakeoutSaaS.Module.Authorization.Policies;
|
||||
|
||||
/// <summary>
|
||||
/// 权限要求
|
||||
/// </summary>
|
||||
public sealed class PermissionRequirement : IAuthorizationRequirement
|
||||
{
|
||||
public PermissionRequirement(IReadOnlyCollection<string> permissions)
|
||||
{
|
||||
Permissions = permissions ?? throw new ArgumentNullException(nameof(permissions));
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<string> Permissions { get; }
|
||||
}
|
||||
@@ -4,8 +4,11 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user