chore: 同步当前开发内容

This commit is contained in:
2025-11-23 01:25:20 +08:00
parent ddf584f212
commit 1169e1f220
58 changed files with 1886 additions and 82 deletions

35
.vscode/launch.json vendored Normal file
View 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
View File

@@ -0,0 +1,3 @@
{
"chatgpt.openOnStartup": true
}

41
.vscode/tasks.json vendored Normal file
View 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"
}
]
}

View File

@@ -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

View File

@@ -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}

View 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));
}
}

View File

@@ -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();

View File

@@ -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>

View 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));
}
}

View 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));
}
}

View File

@@ -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;

View File

@@ -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" />

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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; }
}

View File

@@ -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;
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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
};
}

View File

@@ -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}";
}
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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; }
}

View 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; }
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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>();
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,9 @@
namespace TakeoutSaaS.Infrastructure.Identity.Options;
/// <summary>
/// 刷新令牌存储配置。
/// </summary>
public sealed class RefreshTokenStoreOptions
{
public string Prefix { get; set; } = "identity:refresh:";
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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();
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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}";
}

View File

@@ -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}";
}

View File

@@ -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; }
}
}

View File

@@ -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>

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}

View File

@@ -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; }
}

View File

@@ -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>

View File

@@ -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>