diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..933c59b --- /dev/null +++ b/.vscode/launch.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..02be578 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "chatgpt.openOnStartup": true +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..a48b929 --- /dev/null +++ b/.vscode/tasks.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/0_Document/10_TODO.md b/0_Document/10_TODO.md index 7157dbf..9190463 100644 --- a/0_Document/10_TODO.md +++ b/0_Document/10_TODO.md @@ -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) diff --git a/TakeoutSaaS.sln b/TakeoutSaaS.sln index 86c3d6d..d924b98 100644 --- a/TakeoutSaaS.sln +++ b/TakeoutSaaS.sln @@ -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} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs new file mode 100644 index 0000000..04bedfc --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs @@ -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; + +/// +/// 管理后台认证接口 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/auth")] +public sealed class AuthController : BaseApiController +{ + private readonly IAdminAuthService _authService; + + /// + /// + /// + /// + public AuthController(IAdminAuthService authService) + { + _authService = authService; + } + + /// + /// 登录获取 Token + /// + [HttpPost("login")] + [AllowAnonymous] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task>> Login([FromBody] AdminLoginRequest request, CancellationToken cancellationToken) + { + var response = await _authService.LoginAsync(request, cancellationToken); + return Ok(ApiResponse.Ok(response)); + } + + /// + /// 刷新 Token + /// + [HttpPost("refresh")] + [AllowAnonymous] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task>> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken) + { + var response = await _authService.RefreshTokenAsync(request, cancellationToken); + return Ok(ApiResponse.Ok(response)); + } + + /// + /// 获取当前用户信息 + /// + [HttpGet("profile")] + [PermissionAuthorize("identity:profile:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task>> GetProfile(CancellationToken cancellationToken) + { + var userId = User.GetUserId(); + if (userId == Guid.Empty) + { + return Unauthorized(ApiResponse.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识")); + } + + var profile = await _authService.GetProfileAsync(userId, cancellationToken); + return Ok(ApiResponse.Ok(profile)); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Program.cs b/src/Api/TakeoutSaaS.AdminApi/Program.cs index 7e650e0..5359f9f 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Program.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Program.cs @@ -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(); diff --git a/src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj b/src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj index 9a71725..d4c9b45 100644 --- a/src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj +++ b/src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj @@ -13,9 +13,7 @@ - - diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/AuthController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/AuthController.cs new file mode 100644 index 0000000..2c80505 --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/AuthController.cs @@ -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; + +/// +/// 小程序登录认证 +/// +[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; + } + + /// + /// 微信登录 + /// + [HttpPost("wechat/login")] + [AllowAnonymous] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task>> LoginWithWeChat([FromBody] WeChatLoginRequest request, CancellationToken cancellationToken) + { + var response = await _authService.LoginWithWeChatAsync(request, cancellationToken); + return Ok(ApiResponse.Ok(response)); + } + + /// + /// 刷新 Token + /// + [HttpPost("refresh")] + [AllowAnonymous] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task>> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken) + { + var response = await _authService.RefreshTokenAsync(request, cancellationToken); + return Ok(ApiResponse.Ok(response)); + } +} diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/MeController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/MeController.cs new file mode 100644 index 0000000..8ccdc14 --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/MeController.cs @@ -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; + +/// +/// 当前用户信息 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/mini/v{version:apiVersion}/me")] +public sealed class MeController : BaseApiController +{ + private readonly IMiniAuthService _authService; + + /// + /// + /// + /// + public MeController(IMiniAuthService authService) + { + _authService = authService; + } + + /// + /// 获取用户档案 + /// + [HttpGet] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task>> Get(CancellationToken cancellationToken) + { + var userId = User.GetUserId(); + if (userId == Guid.Empty) + { + return Unauthorized(ApiResponse.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识")); + } + + var profile = await _authService.GetProfileAsync(userId, cancellationToken); + return Ok(ApiResponse.Ok(profile)); + } +} diff --git a/src/Api/TakeoutSaaS.MiniApi/Program.cs b/src/Api/TakeoutSaaS.MiniApi/Program.cs index d6621d5..ccdb0fd 100644 --- a/src/Api/TakeoutSaaS.MiniApi/Program.cs +++ b/src/Api/TakeoutSaaS.MiniApi/Program.cs @@ -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; diff --git a/src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj b/src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj index 6be5000..4a352b2 100644 --- a/src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj +++ b/src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj @@ -13,7 +13,6 @@ - diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminAuthService.cs new file mode 100644 index 0000000..f60dffb --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminAuthService.cs @@ -0,0 +1,16 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Abstractions; + +/// +/// 管理后台认证服务。 +/// +public interface IAdminAuthService +{ + Task LoginAsync(AdminLoginRequest request, CancellationToken cancellationToken = default); + Task RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default); + Task GetProfileAsync(Guid userId, CancellationToken cancellationToken = default); +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IJwtTokenService.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IJwtTokenService.cs new file mode 100644 index 0000000..4235181 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IJwtTokenService.cs @@ -0,0 +1,13 @@ +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Abstractions; + +/// +/// JWT 令牌服务契约。 +/// +public interface IJwtTokenService +{ + Task CreateTokensAsync(CurrentUserProfile profile, bool isNewUser = false, CancellationToken cancellationToken = default); +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/ILoginRateLimiter.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/ILoginRateLimiter.cs new file mode 100644 index 0000000..f4a7c5c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/ILoginRateLimiter.cs @@ -0,0 +1,13 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace TakeoutSaaS.Application.Identity.Abstractions; + +/// +/// 登录限流器。 +/// +public interface ILoginRateLimiter +{ + Task EnsureAllowedAsync(string key, CancellationToken cancellationToken = default); + Task ResetAsync(string key, CancellationToken cancellationToken = default); +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IMiniAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IMiniAuthService.cs new file mode 100644 index 0000000..11efdb4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IMiniAuthService.cs @@ -0,0 +1,16 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Abstractions; + +/// +/// 小程序认证服务。 +/// +public interface IMiniAuthService +{ + Task LoginWithWeChatAsync(WeChatLoginRequest request, CancellationToken cancellationToken = default); + Task RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default); + Task GetProfileAsync(Guid userId, CancellationToken cancellationToken = default); +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRefreshTokenStore.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRefreshTokenStore.cs new file mode 100644 index 0000000..d966ca2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRefreshTokenStore.cs @@ -0,0 +1,16 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Application.Identity.Models; + +namespace TakeoutSaaS.Application.Identity.Abstractions; + +/// +/// 刷新令牌存储。 +/// +public interface IRefreshTokenStore +{ + Task IssueAsync(Guid userId, DateTime expiresAt, CancellationToken cancellationToken = default); + Task GetAsync(string refreshToken, CancellationToken cancellationToken = default); + Task RevokeAsync(string refreshToken, CancellationToken cancellationToken = default); +} diff --git a/src/Modules/TakeoutSaaS.Module.Identity/Abstractions/IWeChatAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IWeChatAuthService.cs similarity index 64% rename from src/Modules/TakeoutSaaS.Module.Identity/Abstractions/IWeChatAuthService.cs rename to src/Application/TakeoutSaaS.Application/Identity/Abstractions/IWeChatAuthService.cs index f14abfe..417c8b9 100644 --- a/src/Modules/TakeoutSaaS.Module.Identity/Abstractions/IWeChatAuthService.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IWeChatAuthService.cs @@ -1,18 +1,18 @@ -namespace TakeoutSaaS.Module.Identity.Abstractions; +using System.Threading; +using System.Threading.Tasks; + +namespace TakeoutSaaS.Application.Identity.Abstractions; /// -/// 微信登录服务抽象(code2Session) +/// 微信 code2Session 服务契约。 /// public interface IWeChatAuthService { - /// - /// 使用小程序登录 code 换取 openid/unionid/session_key - /// Task Code2SessionAsync(string code, CancellationToken cancellationToken = default); } /// -/// 微信会话信息 +/// 微信会话信息。 /// public sealed class WeChatSessionInfo { @@ -20,4 +20,3 @@ public sealed class WeChatSessionInfo public string? UnionId { get; init; } public string SessionKey { get; init; } = string.Empty; } - diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/AdminLoginRequest.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/AdminLoginRequest.cs new file mode 100644 index 0000000..fbbe58d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/AdminLoginRequest.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Application.Identity.Contracts; + +/// +/// 管理后台登录请求。 +/// +public sealed class AdminLoginRequest +{ + [Required] + [MaxLength(64)] + public string Account { get; set; } = string.Empty; + + [Required] + [MaxLength(128)] + public string Password { get; set; } = string.Empty; +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/CurrentUserProfile.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/CurrentUserProfile.cs new file mode 100644 index 0000000..be4eb44 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/CurrentUserProfile.cs @@ -0,0 +1,18 @@ +using System; + +namespace TakeoutSaaS.Application.Identity.Contracts; + +/// +/// 登录用户档案。 +/// +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(); + public string[] Permissions { get; init; } = Array.Empty(); + public string? Avatar { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/RefreshTokenRequest.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/RefreshTokenRequest.cs new file mode 100644 index 0000000..67b3c53 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/RefreshTokenRequest.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Application.Identity.Contracts; + +/// +/// 刷新令牌请求。 +/// +public sealed class RefreshTokenRequest +{ + [Required] + [MaxLength(256)] + public string RefreshToken { get; set; } = string.Empty; +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/TokenResponse.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/TokenResponse.cs new file mode 100644 index 0000000..57b3ded --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/TokenResponse.cs @@ -0,0 +1,16 @@ +using System; + +namespace TakeoutSaaS.Application.Identity.Contracts; + +/// +/// Access/Refresh 令牌响应。 +/// +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; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/WeChatLoginRequest.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/WeChatLoginRequest.cs new file mode 100644 index 0000000..27152e6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/WeChatLoginRequest.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Application.Identity.Contracts; + +/// +/// 微信小程序登录请求。 +/// +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; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Extensions/IdentityServiceCollectionExtensions.cs b/src/Application/TakeoutSaaS.Application/Identity/Extensions/IdentityServiceCollectionExtensions.cs new file mode 100644 index 0000000..c5df667 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Extensions/IdentityServiceCollectionExtensions.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Application.Identity.Abstractions; +using TakeoutSaaS.Application.Identity.Services; + +namespace TakeoutSaaS.Application.Identity.Extensions; + +/// +/// 应用层身份认证服务注入 +/// +public static class IdentityServiceCollectionExtensions +{ + /// + /// 注册身份认证相关应用服务 + /// + /// 服务集合 + /// 是否注册小程序认证服务 + public static IServiceCollection AddIdentityApplication(this IServiceCollection services, bool enableMiniSupport = false) + { + services.AddScoped(); + + if (enableMiniSupport) + { + services.AddScoped(); + } + + return services; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Models/RefreshTokenDescriptor.cs b/src/Application/TakeoutSaaS.Application/Identity/Models/RefreshTokenDescriptor.cs new file mode 100644 index 0000000..f508c3e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Models/RefreshTokenDescriptor.cs @@ -0,0 +1,12 @@ +using System; + +namespace TakeoutSaaS.Application.Identity.Models; + +/// +/// 刷新令牌描述。 +/// +public sealed record class RefreshTokenDescriptor( + string Token, + Guid UserId, + DateTime ExpiresAt, + bool Revoked); diff --git a/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs new file mode 100644 index 0000000..477e53f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs @@ -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; + +/// +/// 管理后台认证服务实现。 +/// +public sealed class AdminAuthService : IAdminAuthService +{ + private readonly IIdentityUserRepository _userRepository; + private readonly IPasswordHasher _passwordHasher; + private readonly IJwtTokenService _jwtTokenService; + private readonly IRefreshTokenStore _refreshTokenStore; + + public AdminAuthService( + IIdentityUserRepository userRepository, + IPasswordHasher passwordHasher, + IJwtTokenService jwtTokenService, + IRefreshTokenStore refreshTokenStore) + { + _userRepository = userRepository; + _passwordHasher = passwordHasher; + _jwtTokenService = jwtTokenService; + _refreshTokenStore = refreshTokenStore; + } + + public async Task 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 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 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 + }; +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Services/MiniAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Services/MiniAuthService.cs new file mode 100644 index 0000000..0d27c61 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Services/MiniAuthService.cs @@ -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; + +/// +/// 小程序认证服务实现。 +/// +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 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 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 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(), + Permissions = Array.Empty(), + Avatar = user.Avatar + }; + + private string BuildThrottleKey() + { + var ip = _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress ?? IPAddress.Loopback; + return $"mini-login:{ip}"; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Services/.gitkeep b/src/Application/TakeoutSaaS.Application/Services/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/src/Application/TakeoutSaaS.Application/Services/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj b/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj index 88dea96..233eb4d 100644 --- a/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj +++ b/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj @@ -4,9 +4,12 @@ enable enable + + + + - diff --git a/src/Core/TakeoutSaaS.Shared.Web/Security/ClaimsPrincipalExtensions.cs b/src/Core/TakeoutSaaS.Shared.Web/Security/ClaimsPrincipalExtensions.cs new file mode 100644 index 0000000..8502109 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Security/ClaimsPrincipalExtensions.cs @@ -0,0 +1,28 @@ +using System; +using System.Security.Claims; + +namespace TakeoutSaaS.Shared.Web.Security; + +/// +/// ClaimsPrincipal 便捷扩展 +/// +public static class ClaimsPrincipalExtensions +{ + /// + /// 获取当前用户 Id(不存在时返回 Guid.Empty) + /// + 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; + } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Entities/IdentityUser.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/IdentityUser.cs new file mode 100644 index 0000000..123208a --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/IdentityUser.cs @@ -0,0 +1,54 @@ +using System; + +namespace TakeoutSaaS.Domain.Identity.Entities; + +/// +/// 后台账号实体(平台/商户/员工)。 +/// +public sealed class IdentityUser +{ + /// + /// 用户 ID。 + /// + public Guid Id { get; set; } + + /// + /// 登录账号。 + /// + public string Account { get; set; } = string.Empty; + + /// + /// 展示名称。 + /// + public string DisplayName { get; set; } = string.Empty; + + /// + /// 密码哈希。 + /// + public string PasswordHash { get; set; } = string.Empty; + + /// + /// 所属租户。 + /// + public Guid TenantId { get; set; } + + /// + /// 所属商户(平台管理员为空)。 + /// + public Guid? MerchantId { get; set; } + + /// + /// 角色集合。 + /// + public string[] Roles { get; set; } = Array.Empty(); + + /// + /// 权限集合。 + /// + public string[] Permissions { get; set; } = Array.Empty(); + + /// + /// 头像地址。 + /// + public string? Avatar { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Entities/MiniUser.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/MiniUser.cs new file mode 100644 index 0000000..3c4fdf8 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/MiniUser.cs @@ -0,0 +1,39 @@ +using System; + +namespace TakeoutSaaS.Domain.Identity.Entities; + +/// +/// 小程序用户。 +/// +public sealed class MiniUser +{ + /// + /// 用户 ID。 + /// + public Guid Id { get; set; } + + /// + /// 微信 OpenId。 + /// + public string OpenId { get; set; } = string.Empty; + + /// + /// 微信 UnionId,可为空。 + /// + public string? UnionId { get; set; } + + /// + /// 昵称。 + /// + public string Nickname { get; set; } = string.Empty; + + /// + /// 头像地址。 + /// + public string? Avatar { get; set; } + + /// + /// 所属租户。 + /// + public Guid TenantId { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs new file mode 100644 index 0000000..2d5eae1 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs @@ -0,0 +1,22 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Identity.Entities; + +namespace TakeoutSaaS.Domain.Identity.Repositories; + +/// +/// 后台用户仓储契约。 +/// +public interface IIdentityUserRepository +{ + /// + /// 根据账号获取后台用户。 + /// + Task FindByAccountAsync(string account, CancellationToken cancellationToken = default); + + /// + /// 根据 ID 获取后台用户。 + /// + Task FindByIdAsync(Guid userId, CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IMiniUserRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IMiniUserRepository.cs new file mode 100644 index 0000000..db3e810 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IMiniUserRepository.cs @@ -0,0 +1,18 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Identity.Entities; + +namespace TakeoutSaaS.Domain.Identity.Repositories; + +/// +/// 小程序用户仓储契约。 +/// +public interface IMiniUserRepository +{ + Task FindByOpenIdAsync(string openId, CancellationToken cancellationToken = default); + + Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default); + + Task CreateOrUpdateAsync(string openId, string? unionId, string? nickname, string? avatar, Guid tenantId, CancellationToken cancellationToken = default); +} diff --git a/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs b/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs index 3ec985c..1c832fa 100644 --- a/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs +++ b/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs @@ -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 { - ["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 + { + ["d1"] = new() { Address = "http://localhost:5002/" } + } + }, + new ClusterConfig + { + ClusterId = "user", + Destinations = new Dictionary + { + ["d1"] = new() { Address = "http://localhost:5003/" } + } + } +}; + +builder.Services.AddReverseProxy() + .LoadFromMemory(routes, clusters); var app = builder.Build(); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/JwtAuthenticationExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/JwtAuthenticationExtensions.cs new file mode 100644 index 0000000..79c475c --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/JwtAuthenticationExtensions.cs @@ -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; + +/// +/// JWT 认证扩展 +/// +public static class JwtAuthenticationExtensions +{ + /// + /// 配置 JWT Bearer 认证 + /// + public static IServiceCollection AddJwtAuthentication(this IServiceCollection services, IConfiguration configuration) + { + var jwtOptions = configuration.GetSection("Identity:Jwt").Get() + ?? 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; + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..6b588c6 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs @@ -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; + +/// +/// 身份认证基础设施注入 +/// +public static class ServiceCollectionExtensions +{ + /// + /// 注册身份认证基础设施(数据库、Redis、JWT、限流等) + /// + /// 服务集合 + /// 配置源 + /// 是否启用小程序相关依赖(如微信登录) + /// 是否启用后台账号初始化 + 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(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(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped, PasswordHasher>(); + + services.AddOptions() + .Bind(configuration.GetSection("Identity:Jwt")) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddOptions() + .Bind(configuration.GetSection("Identity:LoginRateLimit")) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddOptions() + .Bind(configuration.GetSection("Identity:RefreshTokenStore")); + + if (enableMiniFeatures) + { + services.AddOptions() + .Bind(configuration.GetSection("Identity:WeChatMini")) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddHttpClient(client => + { + client.BaseAddress = new Uri("https://api.weixin.qq.com/"); + client.Timeout = TimeSpan.FromSeconds(10); + }); + } + + if (enableAdminSeed) + { + services.AddOptions() + .Bind(configuration.GetSection("Identity:AdminSeed")) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddHostedService(); + } + + return services; + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminSeedOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminSeedOptions.cs new file mode 100644 index 0000000..d1dd243 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminSeedOptions.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Infrastructure.Identity.Options; + +/// +/// 管理后台初始账号配置。 +/// +public sealed class AdminSeedOptions +{ + public List 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(); + public string[] Permissions { get; set; } = Array.Empty(); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/JwtOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/JwtOptions.cs new file mode 100644 index 0000000..28e1052 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/JwtOptions.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Infrastructure.Identity.Options; + +/// +/// JWT 配置。 +/// +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; +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/LoginRateLimitOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/LoginRateLimitOptions.cs new file mode 100644 index 0000000..a5fb4f1 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/LoginRateLimitOptions.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Infrastructure.Identity.Options; + +/// +/// 登录限流配置。 +/// +public sealed class LoginRateLimitOptions +{ + [Range(1, 3600)] + public int WindowSeconds { get; set; } = 60; + + [Range(1, 100)] + public int MaxAttempts { get; set; } = 5; +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/RefreshTokenStoreOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/RefreshTokenStoreOptions.cs new file mode 100644 index 0000000..ab69c3d --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/RefreshTokenStoreOptions.cs @@ -0,0 +1,9 @@ +namespace TakeoutSaaS.Infrastructure.Identity.Options; + +/// +/// 刷新令牌存储配置。 +/// +public sealed class RefreshTokenStoreOptions +{ + public string Prefix { get; set; } = "identity:refresh:"; +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/WeChatMiniOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/WeChatMiniOptions.cs new file mode 100644 index 0000000..e30d274 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/WeChatMiniOptions.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Infrastructure.Identity.Options; + +/// +/// 微信小程序配置。 +/// +public sealed class WeChatMiniOptions +{ + [Required] + public string AppId { get; set; } = string.Empty; + + [Required] + public string Secret { get; set; } = string.Empty; +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs new file mode 100644 index 0000000..e90127c --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs @@ -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; + +/// +/// EF Core 后台用户仓储实现。 +/// +public sealed class EfIdentityUserRepository : IIdentityUserRepository +{ + private readonly IdentityDbContext _dbContext; + + public EfIdentityUserRepository(IdentityDbContext dbContext) + { + _dbContext = dbContext; + } + + public Task FindByAccountAsync(string account, CancellationToken cancellationToken = default) + => _dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Account == account, cancellationToken); + + public Task FindByIdAsync(Guid userId, CancellationToken cancellationToken = default) + => _dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfMiniUserRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfMiniUserRepository.cs new file mode 100644 index 0000000..d5c372b --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfMiniUserRepository.cs @@ -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; + +/// +/// EF Core 小程序用户仓储实现。 +/// +public sealed class EfMiniUserRepository : IMiniUserRepository +{ + private readonly IdentityDbContext _dbContext; + + public EfMiniUserRepository(IdentityDbContext dbContext) + { + _dbContext = dbContext; + } + + public Task FindByOpenIdAsync(string openId, CancellationToken cancellationToken = default) + => _dbContext.MiniUsers.AsNoTracking().FirstOrDefaultAsync(x => x.OpenId == openId, cancellationToken); + + public Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) + => _dbContext.MiniUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + + public async Task 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; + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs new file mode 100644 index 0000000..80327ac --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs @@ -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; + +/// +/// 后台账号初始化种子任务 +/// +public sealed class IdentityDataSeeder : IHostedService +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public IdentityDataSeeder(IServiceProvider serviceProvider, ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + using var scope = _serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + var options = scope.ServiceProvider.GetRequiredService>().Value; + var passwordHasher = scope.ServiceProvider.GetRequiredService>(); + + 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() + : values + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Select(v => v.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs new file mode 100644 index 0000000..6168f7e --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs @@ -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; + +/// +/// 身份认证 DbContext。 +/// +public sealed class IdentityDbContext : DbContext +{ + public IdentityDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet IdentityUsers => Set(); + public DbSet MiniUsers => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ConfigureIdentityUser(modelBuilder.Entity()); + ConfigureMiniUser(modelBuilder.Entity()); + } + + private static void ConfigureIdentityUser(EntityTypeBuilder 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( + v => string.Join(',', v ?? Array.Empty()), + v => string.IsNullOrWhiteSpace(v) ? Array.Empty() : v.Split(',', StringSplitOptions.RemoveEmptyEntries)); + + var comparer = new ValueComparer( + (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 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(); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/JwtTokenService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/JwtTokenService.cs new file mode 100644 index 0000000..fa728ac --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/JwtTokenService.cs @@ -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; + +/// +/// JWT 令牌生成器。 +/// +public sealed class JwtTokenService : IJwtTokenService +{ + private readonly JwtSecurityTokenHandler _tokenHandler = new(); + private readonly IRefreshTokenStore _refreshTokenStore; + private readonly JwtOptions _options; + + public JwtTokenService(IRefreshTokenStore refreshTokenStore, IOptions options) + { + _refreshTokenStore = refreshTokenStore; + _options = options.Value; + } + + public async Task 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 BuildClaims(CurrentUserProfile profile) + { + var userId = profile.UserId.ToString(); + var claims = new List + { + 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; + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisLoginRateLimiter.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisLoginRateLimiter.cs new file mode 100644 index 0000000..9c7e339 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisLoginRateLimiter.cs @@ -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; + +/// +/// Redis 登录限流实现。 +/// +public sealed class RedisLoginRateLimiter : ILoginRateLimiter +{ + private readonly IDistributedCache _cache; + private readonly LoginRateLimitOptions _options; + + public RedisLoginRateLimiter(IDistributedCache cache, IOptions 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}"; +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisRefreshTokenStore.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisRefreshTokenStore.cs new file mode 100644 index 0000000..aabd967 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisRefreshTokenStore.cs @@ -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; + +/// +/// Redis 刷新令牌存储。 +/// +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 options) + { + _cache = cache; + _options = options.Value; + } + + public async Task 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 GetAsync(string refreshToken, CancellationToken cancellationToken = default) + { + var json = await _cache.GetStringAsync(BuildKey(refreshToken), cancellationToken); + return string.IsNullOrWhiteSpace(json) + ? null + : JsonSerializer.Deserialize(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}"; +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/WeChatAuthService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/WeChatAuthService.cs new file mode 100644 index 0000000..6fa0efb --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/WeChatAuthService.cs @@ -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; + +/// +/// 微信 code2Session 实现 +/// +public sealed class WeChatAuthService : IWeChatAuthService +{ + private readonly HttpClient _httpClient; + private readonly WeChatMiniOptions _options; + + public WeChatAuthService(HttpClient httpClient, IOptions options) + { + _httpClient = httpClient; + _options = options.Value; + } + + public async Task 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(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; } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj b/src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj index 10cd70f..92fa65a 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj @@ -6,12 +6,18 @@ + + + + + + + - diff --git a/src/Modules/TakeoutSaaS.Module.Authorization/Attributes/PermissionAuthorizeAttribute.cs b/src/Modules/TakeoutSaaS.Module.Authorization/Attributes/PermissionAuthorizeAttribute.cs new file mode 100644 index 0000000..b55f602 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Authorization/Attributes/PermissionAuthorizeAttribute.cs @@ -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; + +/// +/// 权限校验特性 +/// +[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); + } + + /// + /// 所需权限集合 + /// + public IReadOnlyCollection Permissions { get; } +} diff --git a/src/Modules/TakeoutSaaS.Module.Authorization/Extensions/AuthorizationServiceCollectionExtensions.cs b/src/Modules/TakeoutSaaS.Module.Authorization/Extensions/AuthorizationServiceCollectionExtensions.cs new file mode 100644 index 0000000..0e368a0 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Authorization/Extensions/AuthorizationServiceCollectionExtensions.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Module.Authorization.Policies; + +namespace TakeoutSaaS.Module.Authorization.Extensions; + +/// +/// 权限授权注入扩展 +/// +public static class AuthorizationServiceCollectionExtensions +{ + /// + /// 启用自定义权限策略提供者与处理器 + /// + public static IServiceCollection AddPermissionAuthorization(this IServiceCollection services) + { + services.AddSingleton(); + services.AddScoped(); + return services; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationHandler.cs b/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationHandler.cs new file mode 100644 index 0000000..b8df4fe --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationHandler.cs @@ -0,0 +1,41 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; + +namespace TakeoutSaaS.Module.Authorization.Policies; + +/// +/// 权限校验处理器 +/// +public sealed class PermissionAuthorizationHandler : AuthorizationHandler +{ + 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; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationPolicyProvider.cs b/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationPolicyProvider.cs new file mode 100644 index 0000000..daaa3f0 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationPolicyProvider.cs @@ -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; + +/// +/// 权限策略提供者(按需动态构建策略) +/// +public sealed class PermissionAuthorizationPolicyProvider : DefaultAuthorizationPolicyProvider +{ + public const string PolicyPrefix = "PERMISSION:"; + private readonly AuthorizationOptions _options; + + public PermissionAuthorizationPolicyProvider(IOptions options) + : base(options) + { + _options = options.Value; + } + + public override Task 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(null); + } + + var policy = new AuthorizationPolicyBuilder() + .AddRequirements(new PermissionRequirement(permissions)) + .Build(); + + _options.AddPolicy(policyName, policy); + return Task.FromResult(policy); + } + + return base.GetPolicyAsync(policyName); + } + + /// + /// 根据权限集合构建策略名称 + /// + public static string BuildPolicyName(IEnumerable 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 permissions) + => permissions + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Select(p => p.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); +} diff --git a/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionRequirement.cs b/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionRequirement.cs new file mode 100644 index 0000000..2ed0421 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionRequirement.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Authorization; + +namespace TakeoutSaaS.Module.Authorization.Policies; + +/// +/// 权限要求 +/// +public sealed class PermissionRequirement : IAuthorizationRequirement +{ + public PermissionRequirement(IReadOnlyCollection permissions) + { + Permissions = permissions ?? throw new ArgumentNullException(nameof(permissions)); + } + + public IReadOnlyCollection Permissions { get; } +} diff --git a/src/Modules/TakeoutSaaS.Module.Authorization/TakeoutSaaS.Module.Authorization.csproj b/src/Modules/TakeoutSaaS.Module.Authorization/TakeoutSaaS.Module.Authorization.csproj index b407eac..9dfdb25 100644 --- a/src/Modules/TakeoutSaaS.Module.Authorization/TakeoutSaaS.Module.Authorization.csproj +++ b/src/Modules/TakeoutSaaS.Module.Authorization/TakeoutSaaS.Module.Authorization.csproj @@ -4,8 +4,11 @@ enable enable + + + + - diff --git a/src/Modules/TakeoutSaaS.Module.Identity/TakeoutSaaS.Module.Identity.csproj b/src/Modules/TakeoutSaaS.Module.Identity/TakeoutSaaS.Module.Identity.csproj deleted file mode 100644 index b407eac..0000000 --- a/src/Modules/TakeoutSaaS.Module.Identity/TakeoutSaaS.Module.Identity.csproj +++ /dev/null @@ -1,11 +0,0 @@ - - - net10.0 - enable - enable - - - - - -