diff --git a/Directory.Packages.props b/Directory.Packages.props index f6274f2..444bacb 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,6 +13,8 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -24,6 +26,7 @@ + diff --git a/README.md b/README.md index 98b2253..70aa76e 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ 阶段设计文档: +- [`docs/architecture/authentication-authorization-security.md`](docs/architecture/authentication-authorization-security.md)(当前认证、RBAC、DataScope、MFA、Session 与 Host 安全策略) - [`docs/migration/phase-1-repository-baseline.md`](docs/migration/phase-1-repository-baseline.md) - [`docs/migration/phase-2-engineering-foundation.md`](docs/migration/phase-2-engineering-foundation.md) - [`docs/migration/phase-3-tenant-isolation-and-shared-question-bank.md`](docs/migration/phase-3-tenant-isolation-and-shared-question-bank.md) @@ -59,7 +60,7 @@ Tiku.IntegrationTests # API / EF 模型集成测试 - ASP.NET Authorization 负责权限策略。 - JWT + 数据库 `auth_sessions` 负责 access/refresh/session 闭环。 - `ICurrentUser` / 只读 `ITenantContext` 统一当前用户和请求租户上下文。 -- Refresh Token 采用 `v1.{tenantId}.{sessionId}.{secret}` 结构,刷新和退出先解析租户再按 `TenantId + SessionId + TokenHash` 定位。 +- Refresh Token 采用 `v2.{t|p}.{tenantId|-}.{sessionId}.{secret}` 结构,刷新和退出先验证 realm/Host/tenant,再通过统一 Session Store 定位并撤销 token family。 - EF Core Query Filter、写入拦截器和 PostgreSQL 组合约束共同阻断跨租户读写。 - PostgreSQL FK / unique / check / index 负责数据完整性底线。 - 审计事件表记录关键行为。 @@ -147,7 +148,7 @@ Tiku.IntegrationTests # API / EF 模型集成测试 数据库模型迁移已经完成到 greenfield 初始 schema: ```text -Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.cs +Tiku.Infrastructure/Persistence/Migrations/20260728031410_InitialSchema.cs ``` 当前 EF 模型覆盖: @@ -171,12 +172,14 @@ Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.cs 已完成: -- JWT Bearer 认证。 -- 数据库 Session 校验;登出/撤销后旧 token 会被拒绝。 -- 手机号 + 密码登录。 -- 短信验证码登录。 -- 微信网页 OAuth 登录。 -- 微信小程序登录。 +- ASP.NET Core Identity 密码、锁定、SecurityStamp、强制改密、TOTP 和恢复码。 +- 带 `kid` 的 RSA JWT Bearer 认证和旧公钥轮换验证。 +- tenant/platform 双 realm 与 Host、tenant claim、数据库 Session 联合校验。 +- Session family 原子 refresh、重放撤销、logout 和 logout-all。 +- 手机号/邮箱/用户名 + 密码登录、短信验证码登录和安全短信发送入口。 +- 微信网页 OAuth 和微信小程序登录,外部身份不保存 `session_key`。 +- 数据库 tenant/platform RBAC、MFA policy、资源型授权和 DataScope SQL。 +- 按有效权限生成 tenant/platform UI 菜单 bootstrap;菜单不作为 API 授权依据。 - 租户级身份 Provider 配置解析。 - 当前用户 `/api/me`。 - 当前租户 `/api/tenants/current`。 @@ -184,6 +187,8 @@ Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.cs - Host 解析的前端运行时配置 `/api/runtime/bootstrap`。 - 统一异常响应和请求日志。 +完整安全策略、Host 判定矩阵和登录/Session 文字流程图见 [`docs/architecture/authentication-authorization-security.md`](docs/architecture/authentication-authorization-security.md)。 + ### 已迁移 API 2026-07-27 运行时 OpenAPI 基线包含 192 个路径、237 个操作。已完成的业务/API 闭环包括: @@ -250,3 +255,14 @@ dotnet ef migrations script \ --project Tiku.Infrastructure \ --startup-project Tiku.DbMigrator ``` + +首次部署可在迁移完成后一次性创建平台超级管理员: + +```bash +export TIKU_BOOTSTRAP_PLATFORM_ADMIN_EMAIL='admin@example.com' +export TIKU_BOOTSTRAP_PLATFORM_ADMIN_PASSWORD='replace-with-a-strong-temporary-password' +export TIKU_BOOTSTRAP_PLATFORM_ADMIN_NAME='Platform Administrator' +dotnet run --project Tiku.DbMigrator -- --bootstrap-platform-admin +``` + +该命令只允许在不存在任何平台角色用户绑定时执行。创建的账号必须在首次登录时修改临时密码并完成 TOTP MFA 注册;检测到已有平台管理员时命令会拒绝重复引导。不要把临时密码写入仓库配置或命令行参数。 diff --git a/Tiku.Api/Contracts/AuthDtos.cs b/Tiku.Api/Contracts/AuthDtos.cs index 32f1d4e..22cca7a 100644 --- a/Tiku.Api/Contracts/AuthDtos.cs +++ b/Tiku.Api/Contracts/AuthDtos.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; using Tiku.Application.Auth; +using Tiku.Domain.Tenancy; namespace Tiku.Api.Contracts; @@ -9,6 +10,8 @@ namespace Tiku.Api.Contracts; /// public sealed class PasswordLoginDto { + [Required] + public AuthRealm? Realm { get; set; } /// /// 平台控制域名登录时使用的租户代码;自定义域名登录可省略。 /// @@ -17,18 +20,21 @@ public sealed class PasswordLoginDto public string? TenantCode { get; set; } /// - /// 手机号,建议前端提交规范化后的中国大陆手机号。 + /// 账号标识。tenant 可使用手机号,platform 可使用邮箱或用户名。 /// - [Required] + [StringLength(320)] + [Description("账号标识。tenant 可使用手机号,platform 可使用邮箱或用户名。")] + public string? Identifier { get; set; } + [StringLength(32)] - [Description("手机号,建议前端提交规范化后的中国大陆手机号。")] - public string Phone { get; set; } = string.Empty; + [Description("兼容手机号字段;新客户端应使用 identifier。")] + public string? Phone { get; set; } /// /// 用户密码。 /// [Required] - [StringLength(128, MinimumLength = 6)] + [StringLength(128, MinimumLength = 10)] [Description("用户密码。")] public string Password { get; set; } = string.Empty; } @@ -38,6 +44,8 @@ public sealed class PasswordLoginDto /// public sealed class SmsLoginDto { + [Required] + public AuthRealm? Realm { get; set; } /// /// 平台控制域名登录时使用的租户代码;自定义域名登录可省略。 /// @@ -62,11 +70,29 @@ public sealed class SmsLoginDto public string Code { get; set; } = string.Empty; } +public sealed class SendSmsCodeDto +{ + [Required] + public AuthRealm? Realm { get; set; } + + [StringLength(100)] + public string? TenantCode { get; set; } + + [Required] + [StringLength(32)] + public string Phone { get; set; } = string.Empty; + + [StringLength(256)] + public string? DeviceId { get; set; } +} + /// /// OAuth code 登录请求。 /// public sealed class OAuthCodeDto { + [Required] + public AuthRealm? Realm { get; set; } /// /// 平台控制域名登录时使用的租户代码;自定义域名登录可省略。 /// @@ -135,10 +161,12 @@ public sealed class AuthenticatedUserDto /// public string? Name { get; init; } + public AuthRealm Realm { get; init; } + /// /// 当前登录租户成员摘要。 /// - public TenantMembershipSummary Tenant { get; init; } = default!; + public TenantMembershipSummary? Tenant { get; init; } /// /// access token 和 refresh token。 @@ -153,8 +181,52 @@ public sealed class AuthenticatedUserDto Phone = user.Phone, Email = user.Email, Name = user.Name, + Realm = user.Realm, Tenant = user.Tenant, Tokens = user.Tokens }; } } + +public sealed class AuthenticationResultDto +{ + public AuthenticationStatus Status { get; init; } + public AuthenticatedUserDto? User { get; init; } + public string? ChallengeToken { get; init; } + public DateTimeOffset? ChallengeExpiresAt { get; init; } + + public static AuthenticationResultDto FromApplication(AuthenticationResult result) => new() + { + Status = result.Status, + User = result.User is null ? null : AuthenticatedUserDto.FromApplication(result.User), + ChallengeToken = result.ChallengeToken, + ChallengeExpiresAt = result.ChallengeExpiresAt + }; +} + +public sealed class MfaChallengeDto +{ + [Required] + [StringLength(2048)] + public string ChallengeToken { get; set; } = string.Empty; + + [StringLength(64)] + public string? Code { get; set; } +} + +public sealed class MfaConfirmDto +{ + public AuthenticationResultDto Authentication { get; init; } = default!; + public IReadOnlyList RecoveryCodes { get; init; } = []; +} + +public sealed class RequiredPasswordChangeDto +{ + [Required] + [StringLength(2048)] + public string ChallengeToken { get; set; } = string.Empty; + + [Required] + [StringLength(128, MinimumLength = 10)] + public string NewPassword { get; set; } = string.Empty; +} diff --git a/Tiku.Api/Contracts/TenantAdminDirectDtos.cs b/Tiku.Api/Contracts/TenantAdminDirectDtos.cs index e7370c2..df808f3 100644 --- a/Tiku.Api/Contracts/TenantAdminDirectDtos.cs +++ b/Tiku.Api/Contracts/TenantAdminDirectDtos.cs @@ -97,19 +97,6 @@ public sealed class TenantAdminAuditLogQueryDto } } -public sealed class TenantAdminRoleTemplateQueryDto -{ - public string? Status { get; set; } - - [Range(1, 500)] - public int? Limit { get; set; } - - public TenantAdminRoleTemplateFilter ToFilter() - { - return new TenantAdminRoleTemplateFilter(Status, Limit); - } -} - public sealed class TenantAdminBadgeQueryDto { public string? Category { get; set; } @@ -324,13 +311,11 @@ public sealed class UpsertTenantAdminMemberDto public TenantAdminUserLookupDto User { get; set; } = new(); public string? Role { get; set; } public string? Status { get; set; } - public Guid? RoleTemplateId { get; set; } - public JsonElement Permissions { get; set; } = JsonDefaults.Object(); public string? PrimaryRole { get; set; } public UpsertTenantAdminMemberCommand ToCommand() { - return new UpsertTenantAdminMemberCommand(MembershipId, User.ToCommand(), Role, Status, RoleTemplateId, Permissions, PrimaryRole); + return new UpsertTenantAdminMemberCommand(MembershipId, User.ToCommand(), Role, Status, PrimaryRole); } } @@ -340,45 +325,6 @@ public sealed class DisableTenantAdminMemberDto public Guid MembershipId { get; set; } } -public sealed class UpsertTenantAdminRoleTemplateDto -{ - public Guid? Id { get; set; } - public string? Code { get; set; } - public required string Name { get; set; } - public string? Description { get; set; } - public string? BaseRole { get; set; } - public string? Status { get; set; } - public JsonElement Permissions { get; set; } = JsonDefaults.Object(); - public JsonElement MenuPermissions { get; set; } = JsonDefaults.Object(); - public JsonElement ModulePermissions { get; set; } = JsonDefaults.Object(); - public JsonElement FieldPermissions { get; set; } = JsonDefaults.Object(); - public JsonElement DataScope { get; set; } = JsonDefaults.Object(); - public int? Order { get; set; } - - public UpsertTenantAdminRoleTemplateCommand ToCommand() - { - return new UpsertTenantAdminRoleTemplateCommand( - Id, - Code, - Name, - Description, - BaseRole, - Status, - Permissions, - MenuPermissions, - ModulePermissions, - FieldPermissions, - DataScope, - Order); - } -} - -public sealed class DisableTenantAdminRoleTemplateDto -{ - [Required] - public Guid RoleTemplateId { get; set; } -} - public sealed class UpsertTenantBrandingDto { public required string BrandName { get; set; } diff --git a/Tiku.Api/Controllers/AuthController.cs b/Tiku.Api/Controllers/AuthController.cs index fd158c5..0b22e0d 100644 --- a/Tiku.Api/Controllers/AuthController.cs +++ b/Tiku.Api/Controllers/AuthController.cs @@ -1,10 +1,14 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Options; using Tiku.Application.Auth; using Tiku.Api.Contracts; +using Tiku.Api.Options; using Tiku.Application.Security; using Tiku.Application.Tenancy; using Tiku.Infrastructure.Content; +using Tiku.Domain.Tenancy; namespace Tiku.Api.Controllers; @@ -13,97 +17,144 @@ namespace Tiku.Api.Controllers; [Produces("application/json")] public sealed class AuthController( IAuthService authService, - ISessionService sessionService, + ISmsVerificationService smsVerificationService, + IAuthSessionStore sessionStore, ITenantContext tenantContext, ITenantContextInitializer tenantContextInitializer, - ITenantDirectory tenantDirectory) : ControllerBase + ITenantDirectory tenantDirectory, + ICurrentUser currentUser, + IOptions tenantResolutionOptions) : ControllerBase { [AllowAnonymous] + [EnableRateLimiting(AuthRateLimitPolicies.Sms)] + [HttpPost("sms/send")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status429TooManyRequests)] + public async Task> SendSmsCode( + [FromBody] SendSmsCodeDto request, + CancellationToken cancellationToken) + { + var realm = request.Realm!.Value; + if (realm != AuthRealm.Tenant) + { + throw new RequiredFieldException("SMS authentication is only available in the tenant realm."); + } + + var tenantId = await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken) + ?? throw new RequiredFieldException("tenantCode is required for SMS authentication."); + var result = await smsVerificationService.CreateCodeAsync( + new SendSmsCodeRequest( + tenantId, + request.Phone, + SmsPurpose.Login, + GetIpAddress(), + Request.Headers.UserAgent.ToString(), + request.DeviceId), + cancellationToken); + return Accepted(result); + } + + [AllowAnonymous] + [EnableRateLimiting(AuthRateLimitPolicies.Password)] [HttpPost("login/password")] [EndpointSummary("手机号密码登录")] [EndpointDescription("使用本地手机号和密码登录,签发 JWT access token 与数据库 refresh/session。")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public async Task> LoginWithPassword( + public async Task> LoginWithPassword( [FromBody] PasswordLoginDto request, CancellationToken cancellationToken) { + var realm = request.Realm!.Value; + var identifier = request.Identifier ?? request.Phone; + if (string.IsNullOrWhiteSpace(identifier)) + { + throw new RequiredFieldException("identifier is required."); + } var result = await authService.LoginWithPasswordAsync( new PasswordLoginRequest( - await ResolveTenantIdAsync(request.TenantCode, cancellationToken), - request.Phone, + realm, + await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken), + identifier, request.Password, GetIpAddress(), Request.Headers.UserAgent.ToString()), cancellationToken); - return Ok(AuthenticatedUserDto.FromApplication(result)); + return Ok(AuthenticationResultDto.FromApplication(result)); } [AllowAnonymous] [HttpPost("login/sms")] [EndpointSummary("短信验证码登录")] [EndpointDescription("校验已发送的登录用途短信验证码,成功后签发 JWT access token 与数据库 refresh/session。")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public async Task> LoginWithSms( + public async Task> LoginWithSms( [FromBody] SmsLoginDto request, CancellationToken cancellationToken) { + var realm = request.Realm!.Value; var result = await authService.LoginWithSmsAsync( new SmsLoginRequest( - await ResolveTenantIdAsync(request.TenantCode, cancellationToken), + realm, + await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken), request.Phone, request.Code, GetIpAddress(), Request.Headers.UserAgent.ToString()), cancellationToken); - return Ok(AuthenticatedUserDto.FromApplication(result)); + return Ok(AuthenticationResultDto.FromApplication(result)); } [AllowAnonymous] [HttpPost("oauth/wechat")] [EndpointSummary("微信网页 OAuth 登录")] [EndpointDescription("使用微信网页授权 code 换取 openid/unionid,upsert 用户身份并创建应用会话。")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - public async Task> LoginWithWechatWeb( + public async Task> LoginWithWechatWeb( [FromBody] OAuthCodeDto request, CancellationToken cancellationToken) { + var realm = request.Realm!.Value; var result = await authService.LoginWithWechatWebAsync( new WechatLoginRequest( - await ResolveTenantIdAsync(request.TenantCode, cancellationToken), + realm, + await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken), request.Code, GetIpAddress(), Request.Headers.UserAgent.ToString()), cancellationToken); - return Ok(AuthenticatedUserDto.FromApplication(result)); + return Ok(AuthenticationResultDto.FromApplication(result)); } [AllowAnonymous] [HttpPost("oauth/wechat-miniapp")] [EndpointSummary("微信小程序登录")] [EndpointDescription("使用小程序 wx.login 返回的 code 换取 openid/session_key,upsert 用户身份并创建应用会话。")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - public async Task> LoginWithWechatMiniApp( + public async Task> LoginWithWechatMiniApp( [FromBody] OAuthCodeDto request, CancellationToken cancellationToken) { + var realm = request.Realm!.Value; var result = await authService.LoginWithWechatMiniAppAsync( new WechatLoginRequest( - await ResolveTenantIdAsync(request.TenantCode, cancellationToken), + realm, + await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken), request.Code, GetIpAddress(), Request.Headers.UserAgent.ToString()), cancellationToken); - return Ok(AuthenticatedUserDto.FromApplication(result)); + return Ok(AuthenticationResultDto.FromApplication(result)); } [AllowAnonymous] @@ -142,14 +193,135 @@ public sealed class AuthController( return NoContent(); } + [HttpPost("logout-all")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task LogoutAll(CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + { + return Unauthorized(); + } + + await authService.LogoutAllAsync(userId, cancellationToken); + return NoContent(); + } + + [AllowAnonymous] + [EnableRateLimiting(AuthRateLimitPolicies.Mfa)] + [HttpPost("mfa/totp/setup")] + public async Task> SetupTotp( + [FromBody] MfaChallengeDto request, + CancellationToken cancellationToken) + { + ResolveAuthChallengeTenant(request.ChallengeToken); + var result = await authService.SetupTotpAsync( + new MfaChallengeRequest( + request.ChallengeToken, null, GetIpAddress(), Request.Headers.UserAgent.ToString()), + cancellationToken); + return Ok(result); + } + + [AllowAnonymous] + [EnableRateLimiting(AuthRateLimitPolicies.Mfa)] + [HttpPost("mfa/totp/confirm")] + public async Task> ConfirmTotp( + [FromBody] MfaChallengeDto request, + CancellationToken cancellationToken) + { + ResolveAuthChallengeTenant(request.ChallengeToken); + var result = await authService.ConfirmTotpAsync( + new MfaChallengeRequest( + request.ChallengeToken, request.Code, GetIpAddress(), Request.Headers.UserAgent.ToString()), + cancellationToken); + return Ok(new MfaConfirmDto + { + Authentication = AuthenticationResultDto.FromApplication(result.Authentication), + RecoveryCodes = result.RecoveryCodes + }); + } + + [AllowAnonymous] + [EnableRateLimiting(AuthRateLimitPolicies.Mfa)] + [HttpPost("mfa/totp/verify")] + public async Task> VerifyTotp( + [FromBody] MfaChallengeDto request, + CancellationToken cancellationToken) + { + ResolveAuthChallengeTenant(request.ChallengeToken); + var result = await authService.VerifyTotpAsync( + new MfaChallengeRequest( + request.ChallengeToken, request.Code, GetIpAddress(), Request.Headers.UserAgent.ToString()), + cancellationToken); + return Ok(AuthenticationResultDto.FromApplication(result)); + } + + [AllowAnonymous] + [HttpPost("password/change-required")] + [EnableRateLimiting(AuthRateLimitPolicies.Mfa)] + public async Task> ChangeRequiredPassword( + [FromBody] RequiredPasswordChangeDto request, + CancellationToken cancellationToken) + { + ResolveAuthChallengeTenant(request.ChallengeToken); + var result = await authService.ChangeRequiredPasswordAsync( + new PasswordChangeChallengeRequest( + request.ChallengeToken, request.NewPassword, GetIpAddress(), Request.Headers.UserAgent.ToString()), + cancellationToken); + return Ok(AuthenticationResultDto.FromApplication(result)); + } + private void ResolveRefreshTokenTenant(string refreshToken) { - if (!sessionService.TryParseRefreshToken(refreshToken, out var locator)) + if (!sessionStore.TryParseRefreshToken(refreshToken, out var locator)) { return; } - tenantContextInitializer.Initialize(locator.TenantId, null, TenantResolutionSource.RefreshToken); + if (locator.Realm == AuthRealm.Platform) + { + EnsurePlatformHost(); + if (tenantContext.IsResolved) + { + throw new TenantContextConflictException(tenantContext.TenantId!.Value, Guid.Empty); + } + return; + } + + if (!tenantContext.IsResolved) + { + throw new RequiredFieldException( + "tenant refresh/logout requires a tenant host or x-tenant-code matching the refresh token."); + } + + tenantContextInitializer.Initialize(locator.TenantId!.Value, null, TenantResolutionSource.RefreshToken); + } + + private void ResolveAuthChallengeTenant(string challengeToken) + { + var parts = challengeToken.Split('.', 4, StringSplitOptions.None); + if (parts.Length != 4 || parts[0] != "c1") + { + return; + } + + if (parts[1] == "p" && parts[2] == "-") + { + EnsurePlatformHost(); + if (tenantContext.IsResolved) + { + throw new TenantContextConflictException(tenantContext.TenantId!.Value, Guid.Empty); + } + return; + } + + if (parts[1] != "t" || !Guid.TryParseExact(parts[2], "N", out var tenantId) || !tenantContext.IsResolved) + { + throw new RequiredFieldException( + "tenant authentication challenge requires a tenant host or x-tenant-code."); + } + + tenantContextInitializer.Initialize(tenantId, null, TenantResolutionSource.RefreshToken); } private string? GetIpAddress() @@ -157,8 +329,22 @@ public sealed class AuthController( return HttpContext.Connection.RemoteIpAddress?.ToString(); } - private async Task ResolveTenantIdAsync(string? tenantCode, CancellationToken cancellationToken) + private async Task ResolveRealmTenantIdAsync( + AuthRealm realm, + string? tenantCode, + CancellationToken cancellationToken) { + if (realm == AuthRealm.Platform) + { + EnsurePlatformHost(); + if (tenantContext.IsResolved || !string.IsNullOrWhiteSpace(tenantCode)) + { + throw new RequiredFieldException("platform realm does not accept tenantCode and must use a platform host."); + } + + return null; + } + if (tenantContext.TenantId.HasValue) { if (!string.IsNullOrWhiteSpace(tenantCode) && @@ -189,4 +375,17 @@ public sealed class AuthController( TenantResolutionSource.TenantCode); return tenant.TenantId; } + + private void EnsurePlatformHost() + { + var requestHost = Request.Host.Host.Trim().TrimEnd('.'); + if (!tenantResolutionOptions.Value.PlatformHosts.Any(host => + string.Equals( + host.Trim().TrimEnd('.'), + requestHost, + StringComparison.OrdinalIgnoreCase))) + { + throw new RequiredFieldException("platform realm is only available on a configured platform host."); + } + } } diff --git a/Tiku.Api/Controllers/BackgroundJobsController.cs b/Tiku.Api/Controllers/BackgroundJobsController.cs index 3a99de0..e377ff3 100644 --- a/Tiku.Api/Controllers/BackgroundJobsController.cs +++ b/Tiku.Api/Controllers/BackgroundJobsController.cs @@ -8,7 +8,7 @@ namespace Tiku.Api.Controllers; [ApiController] [Route("api/backoffice/tenant/jobs")] -[Authorize(Policy = TikuPolicies.TenantAdmin)] +[Authorize(Policy = BackendPermissions.TenantJobManage)] public sealed class BackgroundJobsController( IBackgroundJobService backgroundJobService, ITenantContext tenantContext) : ControllerBase diff --git a/Tiku.Api/Controllers/BackofficeController.cs b/Tiku.Api/Controllers/BackofficeController.cs index 0864303..3999832 100644 --- a/Tiku.Api/Controllers/BackofficeController.cs +++ b/Tiku.Api/Controllers/BackofficeController.cs @@ -10,113 +10,117 @@ namespace Tiku.Api.Controllers; [Route("api/backoffice")] public sealed class BackofficeController( IBackofficeService backofficeService, - ICurrentUser currentUser, - ITenantContext tenantContext) : ControllerBase + ICurrentAccessContext currentAccessContext) : ControllerBase { + [HttpGet("tenant/ui-bootstrap")] + [Authorize(Policy = TikuPolicies.TenantBackofficeBootstrap)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetTenantUiBootstrap(CancellationToken cancellationToken) + { + return Ok(await backofficeService.GetTenantUiBootstrapAsync( + await currentAccessContext.GetAsync(cancellationToken), + cancellationToken)); + } + [HttpGet("tenant/bootstrap")] - [Authorize(Policy = TikuPolicies.TenantAdmin)] + [Authorize(Policy = BackendPermissions.TenantRoleManage)] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> GetTenantBootstrap(CancellationToken cancellationToken) { - return Ok(await backofficeService.GetTenantBootstrapAsync(ResolveTenantActor(), cancellationToken)); + return Ok(await backofficeService.GetTenantBootstrapAsync(await ResolveTenantActorAsync(cancellationToken), cancellationToken)); + } + + [HttpGet("platform/ui-bootstrap")] + [Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetPlatformUiBootstrap(CancellationToken cancellationToken) + { + return Ok(await backofficeService.GetPlatformUiBootstrapAsync( + await currentAccessContext.GetAsync(cancellationToken), + cancellationToken)); } [HttpPost("tenant/roles")] - [Authorize(Policy = TikuPolicies.TenantAdmin)] + [Authorize(Policy = BackendPermissions.TenantRoleManage)] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> UpsertTenantRole( UpsertBackofficeRoleDto request, CancellationToken cancellationToken) { - return Ok(await backofficeService.UpsertTenantRoleAsync(ResolveTenantActor(), request.ToCommand(), cancellationToken)); + return Ok(await backofficeService.UpsertTenantRoleAsync(await ResolveTenantActorAsync(cancellationToken), request.ToCommand(), cancellationToken)); } [HttpPut("tenant/roles/{roleId:guid}/bindings")] - [Authorize(Policy = TikuPolicies.TenantAdmin)] + [Authorize(Policy = BackendPermissions.TenantRoleManage)] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> ReplaceTenantRoleBindings( Guid roleId, ReplaceRoleBindingsDto request, CancellationToken cancellationToken) { - return Ok(await backofficeService.ReplaceTenantRoleBindingsAsync(ResolveTenantActor(), request.ToCommand(roleId), cancellationToken)); + return Ok(await backofficeService.ReplaceTenantRoleBindingsAsync(await ResolveTenantActorAsync(cancellationToken), request.ToCommand(roleId), cancellationToken)); } [HttpPut("tenant/users/{userId:guid}/roles")] - [Authorize(Policy = TikuPolicies.TenantAdmin)] + [Authorize(Policy = BackendPermissions.TenantRoleManage)] [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task ReplaceTenantUserRoles( Guid userId, ReplaceUserRolesDto request, CancellationToken cancellationToken) { - await backofficeService.ReplaceTenantUserRolesAsync(ResolveTenantActor(), request.ToCommand(userId), cancellationToken); + await backofficeService.ReplaceTenantUserRolesAsync(await ResolveTenantActorAsync(cancellationToken), request.ToCommand(userId), cancellationToken); return NoContent(); } [HttpGet("platform/bootstrap")] - [Authorize(Policy = TikuPolicies.TenantAdmin)] + [Authorize(Policy = BackendPermissions.PlatformRoleManage)] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> GetPlatformBootstrap(CancellationToken cancellationToken) { - return Ok(await backofficeService.GetPlatformBootstrapAsync(ResolvePlatformActor(), cancellationToken)); + return Ok(await backofficeService.GetPlatformBootstrapAsync(await ResolvePlatformActorAsync(cancellationToken), cancellationToken)); } [HttpPost("platform/roles")] - [Authorize(Policy = TikuPolicies.TenantAdmin)] + [Authorize(Policy = BackendPermissions.PlatformRoleManage)] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> UpsertPlatformRole( UpsertBackofficeRoleDto request, CancellationToken cancellationToken) { - return Ok(await backofficeService.UpsertPlatformRoleAsync(ResolvePlatformActor(), request.ToCommand(), cancellationToken)); + return Ok(await backofficeService.UpsertPlatformRoleAsync(await ResolvePlatformActorAsync(cancellationToken), request.ToCommand(), cancellationToken)); } [HttpPut("platform/roles/{roleId:guid}/bindings")] - [Authorize(Policy = TikuPolicies.TenantAdmin)] + [Authorize(Policy = BackendPermissions.PlatformRoleManage)] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> ReplacePlatformRoleBindings( Guid roleId, ReplaceRoleBindingsDto request, CancellationToken cancellationToken) { - return Ok(await backofficeService.ReplacePlatformRoleBindingsAsync(ResolvePlatformActor(), request.ToCommand(roleId), cancellationToken)); + return Ok(await backofficeService.ReplacePlatformRoleBindingsAsync(await ResolvePlatformActorAsync(cancellationToken), request.ToCommand(roleId), cancellationToken)); } [HttpPut("platform/users/{userId:guid}/roles")] - [Authorize(Policy = TikuPolicies.TenantAdmin)] + [Authorize(Policy = BackendPermissions.PlatformRoleManage)] [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task ReplacePlatformUserRoles( Guid userId, ReplaceUserRolesDto request, CancellationToken cancellationToken) { - await backofficeService.ReplacePlatformUserRolesAsync(ResolvePlatformActor(), request.ToCommand(userId), cancellationToken); + await backofficeService.ReplacePlatformUserRolesAsync(await ResolvePlatformActorAsync(cancellationToken), request.ToCommand(userId), cancellationToken); return NoContent(); } - private BackofficeActor ResolveTenantActor() + private async Task ResolveTenantActorAsync(CancellationToken cancellationToken) { - if (currentUser.UserId is not { } userId || tenantContext.TenantId is not { } tenantId) - { - throw new InvalidOperationException("Tenant backoffice actor was not resolved."); - } - - return new BackofficeActor(userId, tenantId, IsPlatformAdmin()); + return BackofficeActor.FromTenantAccess(await currentAccessContext.GetAsync(cancellationToken)); } - private BackofficeActor ResolvePlatformActor() + private async Task ResolvePlatformActorAsync(CancellationToken cancellationToken) { - if (currentUser.UserId is not { } userId) - { - throw new InvalidOperationException("Platform backoffice actor was not resolved."); - } - - return new BackofficeActor(userId, tenantContext.TenantId, IsPlatformAdmin()); - } - - private bool IsPlatformAdmin() - { - return string.Equals(currentUser.TenantRole, "PlatformAdmin", StringComparison.OrdinalIgnoreCase); + return BackofficeActor.FromPlatformAccess(await currentAccessContext.GetAsync(cancellationToken)); } } diff --git a/Tiku.Api/Controllers/CommissionController.cs b/Tiku.Api/Controllers/CommissionController.cs index 1dea7c9..a78902b 100644 --- a/Tiku.Api/Controllers/CommissionController.cs +++ b/Tiku.Api/Controllers/CommissionController.cs @@ -7,7 +7,7 @@ using Tiku.Application.Security; namespace Tiku.Api.Controllers; [ApiController] -[Authorize(Policy = TikuPolicies.TenantAdmin)] +[Authorize(Policy = BackendPermissions.TenantCommissionManage)] [Produces("application/json")] [Route("api/commission")] public sealed class CommissionController( diff --git a/Tiku.Api/Controllers/CrmController.cs b/Tiku.Api/Controllers/CrmController.cs index e8bca9c..d2197f1 100644 --- a/Tiku.Api/Controllers/CrmController.cs +++ b/Tiku.Api/Controllers/CrmController.cs @@ -7,7 +7,7 @@ using Tiku.Application.Security; namespace Tiku.Api.Controllers; [ApiController] -[Authorize(Policy = TikuPolicies.TenantAdmin)] +[Authorize(Policy = BackendPermissions.TenantCrmManage)] [Produces("application/json")] [Route("api/crm")] public sealed class CrmController( diff --git a/Tiku.Api/Controllers/ReferralController.cs b/Tiku.Api/Controllers/ReferralController.cs index 316642a..8cdcc8b 100644 --- a/Tiku.Api/Controllers/ReferralController.cs +++ b/Tiku.Api/Controllers/ReferralController.cs @@ -95,7 +95,7 @@ public sealed class ReferralController( } [HttpGet("stats")] - [Authorize(Policy = TikuPolicies.TenantAdmin)] + [Authorize(Policy = BackendPermissions.TenantCrmManage)] [EndpointSummary("查询推荐人个人统计")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> Stats( @@ -109,7 +109,7 @@ public sealed class ReferralController( } [HttpGet("sales-stats")] - [Authorize(Policy = TikuPolicies.TenantAdmin)] + [Authorize(Policy = BackendPermissions.TenantCrmManage)] [EndpointSummary("查询销售推荐统计排行")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> SalesStats( @@ -123,7 +123,7 @@ public sealed class ReferralController( } [HttpGet("conversion-report")] - [Authorize(Policy = TikuPolicies.TenantAdmin)] + [Authorize(Policy = BackendPermissions.TenantCrmManage)] [EndpointSummary("查询推荐转化报告")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> ConversionReport( @@ -137,7 +137,7 @@ public sealed class ReferralController( } [HttpGet("sales-clients")] - [Authorize(Policy = TikuPolicies.TenantAdmin)] + [Authorize(Policy = BackendPermissions.TenantCrmManage)] [EndpointSummary("查询推荐人名下客户")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> SalesClients( @@ -151,7 +151,7 @@ public sealed class ReferralController( } [HttpPost("manual-bind")] - [Authorize(Policy = TikuPolicies.TenantAdmin)] + [Authorize(Policy = BackendPermissions.TenantCrmManage)] [EndpointSummary("人工调整学生推荐归属")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> ManualBind( @@ -165,7 +165,7 @@ public sealed class ReferralController( } [HttpGet("team")] - [Authorize(Policy = TikuPolicies.TenantAdmin)] + [Authorize(Policy = BackendPermissions.TenantCrmManage)] [EndpointSummary("查询推荐团队成员")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> Team( @@ -179,7 +179,7 @@ public sealed class ReferralController( } [HttpPut("team")] - [Authorize(Policy = TikuPolicies.TenantAdmin)] + [Authorize(Policy = BackendPermissions.TenantCrmManage)] [EndpointSummary("新增或更新推荐团队关系")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> UpsertTeam( diff --git a/Tiku.Api/Controllers/SecurityDiagnosticsController.cs b/Tiku.Api/Controllers/SecurityDiagnosticsController.cs index 9c32f1e..32fe4a5 100644 --- a/Tiku.Api/Controllers/SecurityDiagnosticsController.cs +++ b/Tiku.Api/Controllers/SecurityDiagnosticsController.cs @@ -28,8 +28,7 @@ public sealed class SecurityDiagnosticsController( { return Ok(new { - currentTenant.TenantId, - currentUser.TenantRole + currentTenant.TenantId }); } @@ -39,8 +38,7 @@ public sealed class SecurityDiagnosticsController( { return Ok(new { - currentTenant.TenantId, - currentUser.TenantRole + currentTenant.TenantId }); } } diff --git a/Tiku.Api/Controllers/TaxonomyController.cs b/Tiku.Api/Controllers/TaxonomyController.cs index a7206d5..7e3d77e 100644 --- a/Tiku.Api/Controllers/TaxonomyController.cs +++ b/Tiku.Api/Controllers/TaxonomyController.cs @@ -21,7 +21,7 @@ public sealed class TaxonomyController( } [HttpPost] - [Authorize(Policy = TikuPolicies.TenantAdmin)] + [Authorize(Policy = BackendPermissions.TenantContentManage)] public Task Create( CreateTaxonomyNodeDto request, CancellationToken cancellationToken) diff --git a/Tiku.Api/Controllers/TenantAdminDirectController.cs b/Tiku.Api/Controllers/TenantAdminDirectController.cs index 34ee26e..55b073d 100644 --- a/Tiku.Api/Controllers/TenantAdminDirectController.cs +++ b/Tiku.Api/Controllers/TenantAdminDirectController.cs @@ -10,7 +10,6 @@ using Tiku.Domain.Tenancy; namespace Tiku.Api.Controllers; [ApiController] -[Authorize(Policy = TikuPolicies.TenantAdmin)] [Produces("application/json")] [Route("api/tenant-admin")] public sealed class TenantAdminDirectController( @@ -19,6 +18,7 @@ public sealed class TenantAdminDirectController( ITenantContext currentTenant) : ControllerBase { [HttpGet("classes")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] [EndpointSummary("查询租户班级")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> GetClasses( @@ -29,6 +29,7 @@ public sealed class TenantAdminDirectController( } [HttpPut("classes")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] [EndpointSummary("新增或更新租户班级")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpsertClass( @@ -39,6 +40,7 @@ public sealed class TenantAdminDirectController( } [HttpPost("classes/disable")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] [EndpointSummary("停用租户班级")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> DisableClass( @@ -49,6 +51,7 @@ public sealed class TenantAdminDirectController( } [HttpGet("classes/members")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] [EndpointSummary("查询班级成员")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetClassMembers( @@ -59,6 +62,7 @@ public sealed class TenantAdminDirectController( } [HttpPut("classes/members")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] [EndpointSummary("新增或更新班级成员")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpsertClassMember( @@ -69,6 +73,7 @@ public sealed class TenantAdminDirectController( } [HttpPost("classes/members/remove")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] [EndpointSummary("移除班级成员")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> RemoveClassMember( @@ -79,6 +84,7 @@ public sealed class TenantAdminDirectController( } [HttpGet("students")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] [EndpointSummary("查询租户学生")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> GetStudents( @@ -89,6 +95,7 @@ public sealed class TenantAdminDirectController( } [HttpPut("students")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] [EndpointSummary("新增或更新租户学生档案")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpsertStudent( @@ -99,6 +106,7 @@ public sealed class TenantAdminDirectController( } [HttpPost("students/status")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] [EndpointSummary("更新租户学生状态")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpdateStudentStatus( @@ -109,6 +117,7 @@ public sealed class TenantAdminDirectController( } [HttpGet("student-notes")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] [EndpointSummary("查询学生备注")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetStudentNotes( @@ -119,6 +128,7 @@ public sealed class TenantAdminDirectController( } [HttpPut("student-notes")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] [EndpointSummary("新增或更新学生备注")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpsertStudentNote( @@ -129,6 +139,7 @@ public sealed class TenantAdminDirectController( } [HttpGet("student-followups")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] [EndpointSummary("查询学生跟进")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetStudentFollowups( @@ -139,6 +150,7 @@ public sealed class TenantAdminDirectController( } [HttpPut("student-followups")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] [EndpointSummary("新增或更新学生跟进")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpsertStudentFollowup( @@ -149,6 +161,7 @@ public sealed class TenantAdminDirectController( } [HttpGet("members")] + [Authorize(Policy = BackendPermissions.TenantStaffManage)] [EndpointSummary("查询租户成员")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetMembers( @@ -159,6 +172,7 @@ public sealed class TenantAdminDirectController( } [HttpPut("members")] + [Authorize(Policy = BackendPermissions.TenantStaffManage)] [EndpointSummary("新增或更新租户成员")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpsertMember( @@ -169,6 +183,7 @@ public sealed class TenantAdminDirectController( } [HttpPost("members/disable")] + [Authorize(Policy = BackendPermissions.TenantStaffManage)] [EndpointSummary("停用租户成员并撤销会话")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> DisableMember( @@ -179,6 +194,7 @@ public sealed class TenantAdminDirectController( } [HttpGet("audit-logs")] + [Authorize(Policy = BackendPermissions.TenantStaffManage)] [EndpointSummary("查询租户审计日志")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetAuditLogs( @@ -188,45 +204,8 @@ public sealed class TenantAdminDirectController( return Ok(await tenantAdminService.GetAuditLogsAsync(ResolveActor(), query.ToFilter(), cancellationToken)); } - [HttpGet("permissions")] - [EndpointSummary("查询租户后台权限矩阵")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> GetPermissions(CancellationToken cancellationToken) - { - return Ok(await tenantAdminService.GetPermissionMatrixAsync(ResolveActor(), cancellationToken)); - } - - [HttpGet("role-templates")] - [EndpointSummary("查询租户角色模板")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> GetRoleTemplates( - [FromQuery] TenantAdminRoleTemplateQueryDto query, - CancellationToken cancellationToken) - { - return Ok(await tenantAdminService.GetRoleTemplatesAsync(ResolveActor(), query.ToFilter(), cancellationToken)); - } - - [HttpPut("role-templates")] - [EndpointSummary("新增或更新租户角色模板")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> UpsertRoleTemplate( - UpsertTenantAdminRoleTemplateDto request, - CancellationToken cancellationToken) - { - return Ok(await tenantAdminService.UpsertRoleTemplateAsync(ResolveActor(), request.ToCommand(), cancellationToken)); - } - - [HttpPost("role-templates/disable")] - [EndpointSummary("停用租户角色模板")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> DisableRoleTemplate( - DisableTenantAdminRoleTemplateDto request, - CancellationToken cancellationToken) - { - return Ok(await tenantAdminService.DisableRoleTemplateAsync(ResolveActor(), request.RoleTemplateId, cancellationToken)); - } - [HttpPut("branding")] + [Authorize(Policy = BackendPermissions.TenantSettingsManage)] [EndpointSummary("更新租户品牌信息")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpsertBranding( @@ -237,6 +216,7 @@ public sealed class TenantAdminDirectController( } [HttpPut("settings")] + [Authorize(Policy = BackendPermissions.TenantSettingsManage)] [EndpointSummary("更新租户公开设置与功能开关")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpsertSettings( @@ -247,6 +227,7 @@ public sealed class TenantAdminDirectController( } [HttpGet("theme-templates")] + [Authorize(Policy = BackendPermissions.TenantSettingsManage)] [EndpointSummary("查询可用租户主题模板")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetThemeTemplates(CancellationToken cancellationToken) @@ -255,6 +236,7 @@ public sealed class TenantAdminDirectController( } [HttpGet("theme")] + [Authorize(Policy = BackendPermissions.TenantSettingsManage)] [EndpointSummary("查询租户当前主题与草稿")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetTheme(CancellationToken cancellationToken) @@ -263,6 +245,7 @@ public sealed class TenantAdminDirectController( } [HttpPost("theme/preview")] + [Authorize(Policy = BackendPermissions.TenantSettingsManage)] [EndpointSummary("生成租户主题草稿")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> PreviewTheme( @@ -273,6 +256,7 @@ public sealed class TenantAdminDirectController( } [HttpPost("theme/publish")] + [Authorize(Policy = BackendPermissions.TenantSettingsManage)] [EndpointSummary("发布租户主题")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> PublishTheme( @@ -283,6 +267,7 @@ public sealed class TenantAdminDirectController( } [HttpGet("domains")] + [Authorize(Policy = BackendPermissions.TenantSettingsManage)] [EndpointSummary("查询租户域名")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetDomains(CancellationToken cancellationToken) @@ -291,6 +276,7 @@ public sealed class TenantAdminDirectController( } [HttpPost("domains")] + [Authorize(Policy = BackendPermissions.TenantSettingsManage)] [EndpointSummary("添加租户域名")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> CreateDomain( @@ -301,6 +287,7 @@ public sealed class TenantAdminDirectController( } [HttpGet("auth-providers")] + [Authorize(Policy = BackendPermissions.TenantProviderManage)] [EndpointSummary("查询租户登录 Provider 公开配置")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetAuthProviders(CancellationToken cancellationToken) @@ -309,6 +296,7 @@ public sealed class TenantAdminDirectController( } [HttpPut("auth-providers")] + [Authorize(Policy = BackendPermissions.TenantProviderManage)] [EndpointSummary("新增或更新租户登录 Provider")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpsertAuthProvider( @@ -319,6 +307,7 @@ public sealed class TenantAdminDirectController( } [HttpGet("badges")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] [EndpointSummary("查询租户勋章")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetBadges( @@ -329,6 +318,7 @@ public sealed class TenantAdminDirectController( } [HttpPut("badges")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] [EndpointSummary("新增或更新租户勋章")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpsertBadge( @@ -339,6 +329,7 @@ public sealed class TenantAdminDirectController( } [HttpGet("badge-grants")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] [EndpointSummary("查询勋章发放记录")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetBadgeGrants( @@ -349,6 +340,7 @@ public sealed class TenantAdminDirectController( } [HttpPost("badge-grants")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] [EndpointSummary("向租户成员发放勋章")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GrantBadge( @@ -359,6 +351,7 @@ public sealed class TenantAdminDirectController( } [HttpGet("notifications")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] [EndpointSummary("查询用户站内通知")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetNotifications( @@ -369,6 +362,7 @@ public sealed class TenantAdminDirectController( } [HttpPut("notifications")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] [EndpointSummary("新增或更新用户站内通知")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpsertNotification( @@ -379,6 +373,7 @@ public sealed class TenantAdminDirectController( } [HttpGet("feedbacks")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] [EndpointSummary("查询用户反馈")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetFeedbacks( @@ -389,6 +384,7 @@ public sealed class TenantAdminDirectController( } [HttpPost("feedbacks/status")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] [EndpointSummary("处理用户反馈")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpdateFeedback( @@ -400,17 +396,13 @@ public sealed class TenantAdminDirectController( private TenantAdminActor ResolveActor() { - if (currentTenant.TenantId is null || currentUser.UserId is null) + try { - throw new TenantAdminDirectException("Tenant admin actor was not resolved.", "tenant_admin_access_denied"); + return TenantAdminActor.FromResolvedIdentity(currentTenant.TenantId, currentUser.UserId); + } + catch (InvalidOperationException exception) + { + throw new TenantAdminDirectException(exception.Message, "tenant_admin_access_denied"); } - - var role = Enum.TryParse( - currentUser.TenantRole?.Replace("_", string.Empty, StringComparison.Ordinal), - ignoreCase: true, - out var parsedRole) - ? parsedRole - : TenantRole.TenantAdmin; - return new TenantAdminActor(currentTenant.TenantId.Value, currentUser.UserId.Value, role); } } diff --git a/Tiku.Api/Controllers/TenantCommerceController.cs b/Tiku.Api/Controllers/TenantCommerceController.cs index f5a88aa..c1c228c 100644 --- a/Tiku.Api/Controllers/TenantCommerceController.cs +++ b/Tiku.Api/Controllers/TenantCommerceController.cs @@ -8,7 +8,7 @@ using Tiku.Domain.Commerce; namespace Tiku.Api.Controllers; [ApiController] -[Authorize(Policy = TikuPolicies.TenantAdmin)] +[Authorize(Policy = BackendPermissions.TenantCommerceOperate)] [Produces("application/json")] [Route("api/tenant-commerce")] public sealed class TenantCommerceController( @@ -17,6 +17,7 @@ public sealed class TenantCommerceController( ITenantContext currentTenant) : ControllerBase { [HttpGet("payment-accounts")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] [EndpointSummary("查询租户支付账号")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> PaymentAccounts( @@ -30,6 +31,7 @@ public sealed class TenantCommerceController( } [HttpPut("payment-accounts")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] [EndpointSummary("新增或更新租户支付账号")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> UpsertPaymentAccount( @@ -43,6 +45,7 @@ public sealed class TenantCommerceController( } [HttpPut("secrets")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] [EndpointSummary("写入或轮换租户密钥")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> UpsertSecret( @@ -82,6 +85,7 @@ public sealed class TenantCommerceController( } [HttpPost("code-batches")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] [EndpointSummary("创建兑换码批次")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> CreateCodeBatch( @@ -95,6 +99,7 @@ public sealed class TenantCommerceController( } [HttpGet("activation-codes")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] [EndpointSummary("查询兑换码")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> ActivationCodes( @@ -108,6 +113,7 @@ public sealed class TenantCommerceController( } [HttpPost("activation-codes/redeem")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] [EndpointSummary("后台核销兑换码")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> RedeemActivationCode( @@ -121,6 +127,7 @@ public sealed class TenantCommerceController( } [HttpGet("point-activity-tasks")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] [EndpointSummary("查询积分活动任务")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> PointTasks( @@ -134,6 +141,7 @@ public sealed class TenantCommerceController( } [HttpPut("point-activity-tasks")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] [EndpointSummary("新增或更新积分活动任务")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> UpsertPointTask( @@ -147,6 +155,7 @@ public sealed class TenantCommerceController( } [HttpGet("point-activity-claims")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] [EndpointSummary("查询积分任务领取记录")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> PointClaims( @@ -160,6 +169,7 @@ public sealed class TenantCommerceController( } [HttpGet("point-exchange-items")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] [EndpointSummary("查询积分兑换项")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> PointExchangeItems( @@ -173,6 +183,7 @@ public sealed class TenantCommerceController( } [HttpPut("point-exchange-items")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] [EndpointSummary("新增或更新积分兑换项")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> UpsertPointExchangeItem( @@ -186,6 +197,7 @@ public sealed class TenantCommerceController( } [HttpGet("point-exchange-orders")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] [EndpointSummary("查询积分兑换订单")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> PointExchangeOrders( @@ -199,6 +211,7 @@ public sealed class TenantCommerceController( } [HttpPost("point-exchange-orders/status")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] [EndpointSummary("更新积分兑换订单状态")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> UpdatePointExchangeOrderStatus( @@ -212,6 +225,7 @@ public sealed class TenantCommerceController( } [HttpGet("coupons")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] [EndpointSummary("查询租户优惠券")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> Coupons( @@ -225,6 +239,7 @@ public sealed class TenantCommerceController( } [HttpPut("coupons")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] [EndpointSummary("新增或更新租户优惠券")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> UpsertCoupon( @@ -238,6 +253,7 @@ public sealed class TenantCommerceController( } [HttpGet("coupons/redemptions")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] [EndpointSummary("查询优惠券领取和核销记录")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> CouponRedemptions( @@ -251,6 +267,7 @@ public sealed class TenantCommerceController( } [HttpGet("coupons/report")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] [EndpointSummary("查询优惠券基础报表")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> CouponReport( @@ -316,6 +333,7 @@ public sealed class TenantCommerceController( } [HttpGet("reconciliation/batches")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] [EndpointSummary("查询对账批次")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> ReconciliationBatches( @@ -329,6 +347,7 @@ public sealed class TenantCommerceController( } [HttpPost("reconciliation/batches")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] [EndpointSummary("创建对账批次")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> CreateReconciliationBatch( @@ -342,6 +361,7 @@ public sealed class TenantCommerceController( } [HttpGet("reconciliation/issues")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] [EndpointSummary("查询对账异常")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> ReconciliationIssues( @@ -355,6 +375,7 @@ public sealed class TenantCommerceController( } [HttpPost("reconciliation/issues/status")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] [EndpointSummary("更新对账异常状态")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> UpdateReconciliationIssue( diff --git a/Tiku.Api/Controllers/TenantContentController.cs b/Tiku.Api/Controllers/TenantContentController.cs index eff7f03..31a1d75 100644 --- a/Tiku.Api/Controllers/TenantContentController.cs +++ b/Tiku.Api/Controllers/TenantContentController.cs @@ -9,7 +9,7 @@ using Tiku.Application.Security; namespace Tiku.Api.Controllers; [ApiController] -[Authorize(Policy = TikuPolicies.TenantAdmin)] +[Authorize(Policy = BackendPermissions.TenantContentManage)] [Produces("application/json")] [Route("api/tenant-content")] public sealed class TenantContentController( diff --git a/Tiku.Api/Controllers/TenantContentDirectController.cs b/Tiku.Api/Controllers/TenantContentDirectController.cs index c2fdb5c..789ce63 100644 --- a/Tiku.Api/Controllers/TenantContentDirectController.cs +++ b/Tiku.Api/Controllers/TenantContentDirectController.cs @@ -11,7 +11,7 @@ using Tiku.Domain.Content; namespace Tiku.Api.Controllers; [ApiController] -[Authorize(Policy = TikuPolicies.TenantAdmin)] +[Authorize(Policy = BackendPermissions.TenantContentManage)] [Produces("application/json")] [Route("api/tenant-content")] public sealed class TenantContentDirectController( @@ -20,6 +20,7 @@ public sealed class TenantContentDirectController( ITenantContext currentTenant) : ControllerBase { [HttpPost("questions")] + [Authorize(Policy = TikuPolicies.TenantContentManageAllScope)] [EndpointSummary("创建题目及首个版本")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> CreateQuestion( @@ -30,6 +31,7 @@ public sealed class TenantContentDirectController( } [HttpPatch("questions")] + [Authorize(Policy = TikuPolicies.TenantContentManageAllScope)] [EndpointSummary("更新题目并可选择创建新版本")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpdateQuestion( @@ -60,6 +62,7 @@ public sealed class TenantContentDirectController( } [HttpGet("vocabulary-words")] + [Authorize(Policy = TikuPolicies.TenantContentManageAllScope)] [EndpointSummary("查询管理侧词汇")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetVocabularyWords( @@ -70,6 +73,7 @@ public sealed class TenantContentDirectController( } [HttpPut("vocabulary-words")] + [Authorize(Policy = TikuPolicies.TenantContentManageAllScope)] [EndpointSummary("新增或更新词汇")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpsertVocabularyWord( @@ -100,6 +104,7 @@ public sealed class TenantContentDirectController( } [HttpGet("handbook-chapters")] + [Authorize(Policy = TikuPolicies.TenantContentManageAllScope)] [EndpointSummary("查询管理侧知识手册章节")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetHandbookChapters( @@ -110,6 +115,7 @@ public sealed class TenantContentDirectController( } [HttpPut("handbook-chapters")] + [Authorize(Policy = TikuPolicies.TenantContentManageAllScope)] [EndpointSummary("新增或更新知识手册章节")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpsertHandbookChapter( @@ -120,6 +126,7 @@ public sealed class TenantContentDirectController( } [HttpGet("handbook-entries")] + [Authorize(Policy = TikuPolicies.TenantContentManageAllScope)] [EndpointSummary("查询管理侧知识手册条目")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetHandbookEntries( @@ -130,6 +137,7 @@ public sealed class TenantContentDirectController( } [HttpPut("handbook-entries")] + [Authorize(Policy = TikuPolicies.TenantContentManageAllScope)] [EndpointSummary("新增或更新知识手册条目")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpsertHandbookEntry( @@ -240,6 +248,7 @@ public sealed class TenantContentDirectController( } [HttpGet("videos")] + [Authorize(Policy = TikuPolicies.TenantContentManageAllScope)] [EndpointSummary("查询租户视频解析")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetVideos( @@ -250,6 +259,7 @@ public sealed class TenantContentDirectController( } [HttpPut("videos")] + [Authorize(Policy = TikuPolicies.TenantContentManageAllScope)] [EndpointSummary("新增或更新视频解析")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpsertVideo( @@ -260,6 +270,7 @@ public sealed class TenantContentDirectController( } [HttpPost("question-videos")] + [Authorize(Policy = TikuPolicies.TenantContentManageAllScope)] [EndpointSummary("绑定题目与解析视频")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> BindQuestionVideo( @@ -270,6 +281,7 @@ public sealed class TenantContentDirectController( } [HttpGet("operations/{kind}")] + [Authorize(Policy = TikuPolicies.TenantContentManageAllScope)] [EndpointSummary("查询运营内容")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetOperationContent( @@ -281,6 +293,7 @@ public sealed class TenantContentDirectController( } [HttpPut("operations/{kind}")] + [Authorize(Policy = TikuPolicies.TenantContentManageAllScope)] [EndpointSummary("新增或更新运营内容")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpsertOperationContent( @@ -292,6 +305,7 @@ public sealed class TenantContentDirectController( } [HttpPost("imports/preview/{importType}")] + [Authorize(Policy = TikuPolicies.TenantContentManageAllScope)] [EndpointSummary("预览内容导入数据")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> PreviewImport( @@ -303,6 +317,7 @@ public sealed class TenantContentDirectController( } [HttpPost("imports/{importType}")] + [Authorize(Policy = TikuPolicies.TenantContentManageAllScope)] [EndpointSummary("执行同步内容导入")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> ExecuteImport( @@ -314,6 +329,7 @@ public sealed class TenantContentDirectController( } [HttpGet("imports/issues")] + [Authorize(Policy = TikuPolicies.TenantContentManageAllScope)] [EndpointSummary("查询内容导入问题明细")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetImportIssues( @@ -324,6 +340,7 @@ public sealed class TenantContentDirectController( } [HttpPost("imports/post-check")] + [Authorize(Policy = TikuPolicies.TenantContentManageAllScope)] [EndpointSummary("执行内容导入后完整性检查")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> RunImportPostCheck( @@ -334,6 +351,7 @@ public sealed class TenantContentDirectController( } [HttpGet("imports/post-check")] + [Authorize(Policy = TikuPolicies.TenantContentManageAllScope)] [EndpointSummary("查询内容导入后检查状态")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> GetImportPostCheck( diff --git a/Tiku.Api/Controllers/TenantFrontendConfigController.cs b/Tiku.Api/Controllers/TenantFrontendConfigController.cs index 79cd288..b4f2158 100644 --- a/Tiku.Api/Controllers/TenantFrontendConfigController.cs +++ b/Tiku.Api/Controllers/TenantFrontendConfigController.cs @@ -7,7 +7,7 @@ using Tiku.Application.Tenancy; namespace Tiku.Api.Controllers; [ApiController] -[Authorize(Policy = TikuPolicies.TenantAdmin)] +[Authorize(Policy = BackendPermissions.TenantSettingsManage)] [Produces("application/json")] [Route("api/tenant-admin/frontend-config")] public sealed class TenantFrontendConfigController( diff --git a/Tiku.Api/Controllers/TenantsController.cs b/Tiku.Api/Controllers/TenantsController.cs index 5301510..fc446a8 100644 --- a/Tiku.Api/Controllers/TenantsController.cs +++ b/Tiku.Api/Controllers/TenantsController.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; -using System.Text.Json; using Tiku.Application.Auth; using Tiku.Application.Security; using Tiku.Domain.Tenancy; @@ -39,8 +38,7 @@ public sealed class TenantsController( tenant.Name, tenant.Slug, tenant.Status, - membership.Role, - membership.Permissions)) + membership.Role)) .SingleOrDefaultAsync(cancellationToken); if (result is null) @@ -60,11 +58,9 @@ public sealed class TenantsController( /// 租户编码。 /// 租户状态。 /// 当前用户在租户内的角色。 -/// 当前用户在租户内的权限扩展。 public sealed record CurrentTenantResponse( Guid TenantId, string TenantName, string TenantSlug, TenantStatus Status, - TenantRole Role, - JsonElement Permissions); + TenantRole Role); diff --git a/Tiku.Api/Logging/SerilogRequestLogging.cs b/Tiku.Api/Logging/SerilogRequestLogging.cs index 20eba47..43796df 100644 --- a/Tiku.Api/Logging/SerilogRequestLogging.cs +++ b/Tiku.Api/Logging/SerilogRequestLogging.cs @@ -26,7 +26,7 @@ public static class SerilogRequestLogging SetClaim(diagnosticContext, "UserId", user, TikuClaimTypes.UserId); SetClaim(diagnosticContext, "TenantId", user, TikuClaimTypes.TenantId); SetClaim(diagnosticContext, "SessionId", user, TikuClaimTypes.SessionId); - SetClaim(diagnosticContext, "TenantRole", user, TikuClaimTypes.TenantRole); + SetClaim(diagnosticContext, "AuthRealm", user, TikuClaimTypes.Realm); }; } diff --git a/Tiku.Api/Middleware/AuthRateLimitPartitionMiddleware.cs b/Tiku.Api/Middleware/AuthRateLimitPartitionMiddleware.cs new file mode 100644 index 0000000..1b4c778 --- /dev/null +++ b/Tiku.Api/Middleware/AuthRateLimitPartitionMiddleware.cs @@ -0,0 +1,106 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.RateLimiting; +using Tiku.Api.Options; + +namespace Tiku.Api.Middleware; + +public sealed class AuthRateLimitPartitionMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext context) + { + var policy = context.GetEndpoint()? + .Metadata + .GetMetadata()? + .PolicyName; + var propertyName = policy switch + { + AuthRateLimitPolicies.Password => "identifier", + AuthRateLimitPolicies.Sms => "phone", + AuthRateLimitPolicies.Mfa => "challengeToken", + _ => null + }; + + if (HttpMethods.IsPost(context.Request.Method) && propertyName is not null) + { + await CaptureAccountHashAsync(context, propertyName); + } + + await next(context); + } + + private static async Task CaptureAccountHashAsync(HttpContext context, string propertyName) + { + context.Request.EnableBuffering(bufferThreshold: 4096, bufferLimit: 16_384); + try + { + using var document = await JsonDocument.ParseAsync( + context.Request.Body, + cancellationToken: context.RequestAborted); + var captured = TryGetStringProperty(document.RootElement, propertyName) ?? + (propertyName == "identifier" ? TryGetStringProperty(document.RootElement, "phone") : null); + if (captured is { } value && + !string.IsNullOrWhiteSpace(value)) + { + context.Items[AuthRateLimitPartitionKey.AccountHashItemKey] = Hash(value.Trim()); + } + } + catch (JsonException) + { + // MVC will produce the canonical malformed JSON response. + } + catch (IOException) + { + // Oversized or unreadable bodies share the IP-only fallback partition. + } + finally + { + if (context.Request.Body.CanSeek) + { + context.Request.Body.Position = 0; + } + } + } + + private static string? TryGetStringProperty(JsonElement element, string propertyName) + { + if (element.ValueKind != JsonValueKind.Object) + { + return null; + } + + foreach (var property in element.EnumerateObject()) + { + if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase) && + property.Value.ValueKind == JsonValueKind.String) + { + return property.Value.GetString(); + } + } + + return null; + } + + private static string Hash(string value) + { + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))) + .ToLowerInvariant(); + } +} + +public static class AuthRateLimitPartitionKey +{ + internal const string AccountHashItemKey = "tiku.auth_rate_limit.account_hash"; + + public static string Resolve(HttpContext context, string policyName) + { + var ipAddress = context.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"; + var accountHash = context.Items.TryGetValue(AccountHashItemKey, out var value) && + value is string hash && + !string.IsNullOrWhiteSpace(hash) + ? hash + : "unknown-account"; + return $"{policyName}:{ipAddress}:{accountHash}"; + } +} diff --git a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs index 180404e..3dfb826 100644 --- a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs +++ b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -16,6 +16,7 @@ using Tiku.Infrastructure.QuestionBanks; using Tiku.Infrastructure.Scoreline; using Tiku.Application.TenantAdmin; using Tiku.Application.Tenancy; +using Tiku.Infrastructure.Backoffice; namespace Tiku.Api.Middleware; @@ -224,6 +225,16 @@ public sealed class ExceptionHandlingMiddleware( return; } + if (exception is BackofficeException backofficeException) + { + await WriteProblemAsync( + context, + backofficeException.Message, + BackofficeStatusCode(backofficeException.Code), + backofficeException.Code); + return; + } + if (exception is CommerceException commerceException) { await WriteProblemAsync( @@ -404,6 +415,7 @@ public sealed class ExceptionHandlingMiddleware( "entry_not_found" or "node_not_found" or "collection_not_found" or "question_not_found" or "import_type_invalid" => StatusCodes.Status404NotFound, "tenant_content_access_denied" => StatusCodes.Status403Forbidden, + _ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound, _ => StatusCodes.Status400BadRequest }; } @@ -425,8 +437,8 @@ public sealed class ExceptionHandlingMiddleware( { "tenant_admin_access_denied" => StatusCodes.Status403Forbidden, "class_not_found" or "class_member_not_found" or "student_not_found" or "user_not_found" => StatusCodes.Status404NotFound, - "tenant_member_not_found" => StatusCodes.Status400BadRequest, - _ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status400BadRequest, + "tenant_member_not_found" => StatusCodes.Status404NotFound, + _ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound, _ => StatusCodes.Status400BadRequest }; } @@ -442,6 +454,17 @@ public sealed class ExceptionHandlingMiddleware( "payment_provider_not_configured" or "payment_secret_not_configured" => StatusCodes.Status503ServiceUnavailable, "order_status_invalid" or "activation_code_used" or "payment_amount_mismatch" or "coupon_usage_limit_reached" or "coupon_redemption_status_invalid" => StatusCodes.Status409Conflict, + _ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound, + _ => StatusCodes.Status400BadRequest + }; + } + + private static int BackofficeStatusCode(string code) + { + return code switch + { + "platform_access_denied" or "tenant_access_denied" => StatusCodes.Status403Forbidden, + _ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound, _ => StatusCodes.Status400BadRequest }; } diff --git a/Tiku.Api/Options/AuthRateLimitOptions.cs b/Tiku.Api/Options/AuthRateLimitOptions.cs new file mode 100644 index 0000000..f01b6e8 --- /dev/null +++ b/Tiku.Api/Options/AuthRateLimitOptions.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; + +namespace Tiku.Api.Options; + +public sealed class AuthRateLimitOptions +{ + public const string SectionName = "RateLimiting:Authentication"; + + [Range(1, 100)] + public int PasswordPermitLimit { get; set; } = 5; + + [Range(1, 86_400)] + public int PasswordWindowSeconds { get; set; } = 900; + + [Range(1, 100)] + public int SmsPermitLimit { get; set; } = 5; + + [Range(1, 86_400)] + public int SmsWindowSeconds { get; set; } = 300; + + [Range(1, 100)] + public int MfaPermitLimit { get; set; } = 5; + + [Range(1, 86_400)] + public int MfaWindowSeconds { get; set; } = 300; +} + +public static class AuthRateLimitPolicies +{ + public const string Password = "auth-password"; + public const string Sms = "auth-sms"; + public const string Mfa = "auth-mfa"; +} diff --git a/Tiku.Api/Options/OptionsValidation.cs b/Tiku.Api/Options/OptionsValidation.cs index 0af033d..6de776d 100644 --- a/Tiku.Api/Options/OptionsValidation.cs +++ b/Tiku.Api/Options/OptionsValidation.cs @@ -5,8 +5,6 @@ namespace Tiku.Api.Options; public static class OptionsValidation { - public const string DevelopmentSigningKey = "development-only-tiku-signing-key-change-before-production"; - public static string ResolveDatabaseConnectionString( IConfiguration configuration, bool isDevelopment) @@ -30,11 +28,7 @@ public static class OptionsValidation public static bool BeValidJwtOptions(JwtOptions options, bool isProduction) { - return !isProduction || - !string.Equals( - options.SigningKey, - DevelopmentSigningKey, - StringComparison.Ordinal); + return JwtOptions.BeValid(options, isProduction); } public static bool BeValidCorsOptions(CorsOptions options) diff --git a/Tiku.Api/Program.cs b/Tiku.Api/Program.cs index 22ca500..91b1631 100644 --- a/Tiku.Api/Program.cs +++ b/Tiku.Api/Program.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.HttpOverrides; @@ -8,7 +9,6 @@ using Microsoft.IdentityModel.Tokens; using Scalar.AspNetCore; using Serilog; using Serilog.Events; -using System.Text; using System.Text.Json.Serialization; using System.Threading.RateLimiting; using Tiku.Api.Logging; @@ -17,11 +17,13 @@ using Tiku.Api.OpenApi; using Tiku.Api.Options; using Tiku.Api.Security; using Tiku.Application; +using Tiku.Application.Auth; using Tiku.Application.Security; using Tiku.Application.Tenancy; using Tiku.Infrastructure; using Tiku.Infrastructure.Commerce; using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; using Tiku.Infrastructure.Storage; Log.Logger = new LoggerConfiguration() @@ -116,11 +118,18 @@ try var rateLimitOptions = builder.Configuration .GetSection(ApiRateLimitOptions.SectionName) .Get() ?? new ApiRateLimitOptions(); - if (rateLimitOptions.Enabled) + builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(AuthRateLimitOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + var authRateLimitOptions = builder.Configuration + .GetSection(AuthRateLimitOptions.SectionName) + .Get() ?? new AuthRateLimitOptions(); + builder.Services.AddRateLimiter(options => { - builder.Services.AddRateLimiter(options => + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + if (rateLimitOptions.Enabled) { - options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.GlobalLimiter = PartitionedRateLimiter.Create(httpContext => { var partitionKey = @@ -139,33 +148,113 @@ try Window = TimeSpan.FromSeconds(rateLimitOptions.WindowSeconds) }); }); - options.OnRejected = async (context, cancellationToken) => + } + + options.AddPolicy( + AuthRateLimitPolicies.Password, + httpContext => RateLimitPartition.GetFixedWindowLimiter( + AuthRateLimitPartitionKey.Resolve(httpContext, AuthRateLimitPolicies.Password), + _ => new FixedWindowRateLimiterOptions + { + AutoReplenishment = true, + PermitLimit = authRateLimitOptions.PasswordPermitLimit, + QueueLimit = 0, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + Window = TimeSpan.FromSeconds(authRateLimitOptions.PasswordWindowSeconds) + })); + options.AddPolicy( + AuthRateLimitPolicies.Sms, + httpContext => RateLimitPartition.GetFixedWindowLimiter( + AuthRateLimitPartitionKey.Resolve(httpContext, AuthRateLimitPolicies.Sms), + _ => new FixedWindowRateLimiterOptions + { + AutoReplenishment = true, + PermitLimit = authRateLimitOptions.SmsPermitLimit, + QueueLimit = 0, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + Window = TimeSpan.FromSeconds(authRateLimitOptions.SmsWindowSeconds) + })); + options.AddPolicy( + AuthRateLimitPolicies.Mfa, + httpContext => RateLimitPartition.GetFixedWindowLimiter( + AuthRateLimitPartitionKey.Resolve(httpContext, AuthRateLimitPolicies.Mfa), + _ => new FixedWindowRateLimiterOptions + { + AutoReplenishment = true, + PermitLimit = authRateLimitOptions.MfaPermitLimit, + QueueLimit = 0, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + Window = TimeSpan.FromSeconds(authRateLimitOptions.MfaWindowSeconds) + })); + options.OnRejected = async (context, cancellationToken) => + { + if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) { - if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) - { - context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString(); - } + context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString(); + } - var problem = new ProblemDetails - { - Title = "Too many requests.", - Status = StatusCodes.Status429TooManyRequests, - Instance = context.HttpContext.Request.Path - }; - problem.Extensions["code"] = "rate_limited"; - problem.Extensions["traceId"] = context.HttpContext.TraceIdentifier; - - context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; - await context.HttpContext.Response.WriteAsJsonAsync(problem, cancellationToken); + var problem = new ProblemDetails + { + Title = "Too many requests.", + Status = StatusCodes.Status429TooManyRequests, + Instance = context.HttpContext.Request.Path }; - }); - } + problem.Extensions["code"] = "rate_limited"; + problem.Extensions["traceId"] = context.HttpContext.TraceIdentifier; + + context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; + await context.HttpContext.Response.WriteAsJsonAsync(problem, cancellationToken); + }; + }); var connectionString = OptionsValidation.ResolveDatabaseConnectionString( builder.Configuration, builder.Environment.IsDevelopment()); builder.Services.AddInfrastructure(connectionString); + + var requireProtectedDataProtectionKeys = !builder.Environment.IsDevelopment(); + builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(DataProtectionKeyRingOptions.SectionName)) + .PostConfigure(options => + { + options.ApplicationName = + builder.Configuration["TIKU_DATA_PROTECTION_APPLICATION_NAME"] ?? options.ApplicationName; + options.CertificatePath = + builder.Configuration["TIKU_DATA_PROTECTION_CERTIFICATE_PATH"] ?? options.CertificatePath; + options.CertificatePassword = + builder.Configuration["TIKU_DATA_PROTECTION_CERTIFICATE_PASSWORD"] ?? options.CertificatePassword; + }) + .Validate( + options => DataProtectionKeyRingOptions.BeValid(options, requireProtectedDataProtectionKeys), + "Data Protection requires an application name and, outside Development, an X509 certificate path.") + .ValidateOnStart(); + + var dataProtectionOptions = builder.Configuration + .GetSection(DataProtectionKeyRingOptions.SectionName) + .Get() ?? new DataProtectionKeyRingOptions(); + dataProtectionOptions.ApplicationName = + builder.Configuration["TIKU_DATA_PROTECTION_APPLICATION_NAME"] ?? dataProtectionOptions.ApplicationName; + dataProtectionOptions.CertificatePath = + builder.Configuration["TIKU_DATA_PROTECTION_CERTIFICATE_PATH"] ?? dataProtectionOptions.CertificatePath; + dataProtectionOptions.CertificatePassword = + builder.Configuration["TIKU_DATA_PROTECTION_CERTIFICATE_PASSWORD"] ?? dataProtectionOptions.CertificatePassword; + if (!DataProtectionKeyRingOptions.BeValid(dataProtectionOptions, requireProtectedDataProtectionKeys)) + { + throw new InvalidOperationException( + "Data Protection requires an application name and, outside Development, an X509 certificate path."); + } + + var dataProtection = builder.Services + .AddDataProtection() + .SetApplicationName(dataProtectionOptions.ApplicationName.Trim()) + .PersistKeysToDbContext(); + var dataProtectionCertificate = dataProtectionOptions.LoadCertificate(requireProtectedDataProtectionKeys); + if (dataProtectionCertificate is not null) + { + dataProtection.ProtectKeysWithCertificate(dataProtectionCertificate); + } + builder.Services.Configure( builder.Configuration.GetSection(ObjectStorageOptions.SectionName)); builder.Services.Configure( @@ -215,6 +304,18 @@ try "Production tenant secret encryption cannot use the development master key.") .ValidateOnStart(); + builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(SmsSecurityOptions.SectionName)) + .PostConfigure(options => + { + options.CodePepper = builder.Configuration["TIKU_SMS_CODE_PEPPER"] ?? options.CodePepper; + }) + .Validate( + SmsSecurityOptions.BeValid, + "SMS security requires a pepper of at least 32 characters, exactly five verification attempts, " + + "and positive tenant, phone, IP, and device rate limits.") + .ValidateOnStart(); + builder.Services.AddOptions() .Bind(builder.Configuration.GetSection("Security:Jwt")) .ValidateDataAnnotations() @@ -230,6 +331,7 @@ try .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { + options.MapInboundClaims = false; options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, @@ -237,57 +339,93 @@ try ValidateAudience = true, ValidAudience = jwtOptions.Audience, ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)), + RequireSignedTokens = true, + ValidAlgorithms = [SecurityAlgorithms.RsaSha256], ValidateLifetime = true, - ClockSkew = TimeSpan.FromMinutes(1) + RequireExpirationTime = true, + ClockSkew = TimeSpan.FromMinutes(1), + NameClaimType = TikuClaimTypes.UserId }; options.Events = new JwtBearerEvents { OnTokenValidated = async context => { - var tenantIdValue = context.Principal?.FindFirst(TikuClaimTypes.TenantId)?.Value; - if (!Guid.TryParse(tenantIdValue, out var tenantId)) + var principal = context.Principal; + if (!Guid.TryParse(principal?.FindFirst(TikuClaimTypes.UserId)?.Value, out var userId) || + !Guid.TryParse(principal?.FindFirst(TikuClaimTypes.SessionId)?.Value, out var sessionId) || + string.IsNullOrWhiteSpace(principal?.FindFirst(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Jti)?.Value) || + !long.TryParse( + principal?.FindFirst(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Iat)?.Value, + System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, + out _)) { - context.Fail("Missing tenant claim."); + context.Fail("Missing or invalid subject/session/jti/iat claim."); return; } + var realmValue = principal.FindFirst(TikuClaimTypes.Realm)?.Value; + var realm = string.Equals(realmValue, "tenant", StringComparison.Ordinal) + ? Tiku.Domain.Tenancy.AuthRealm.Tenant + : string.Equals(realmValue, "platform", StringComparison.Ordinal) + ? Tiku.Domain.Tenancy.AuthRealm.Platform + : (Tiku.Domain.Tenancy.AuthRealm?)null; + var tenantIdValue = principal.FindFirst(TikuClaimTypes.TenantId)?.Value; + var tenantId = Guid.TryParse(tenantIdValue, out var parsedTenantId) + ? parsedTenantId + : (Guid?)null; + if (realm is null || (realm == Tiku.Domain.Tenancy.AuthRealm.Tenant) != tenantId.HasValue) + { + context.Fail("Token scope and tenant claims are inconsistent."); + return; + } + + var resolutionOptions = context.HttpContext.RequestServices + .GetRequiredService>().Value; + var requestHost = context.HttpContext.Request.Host.Host.Trim().TrimEnd('.'); + var isPlatformHost = resolutionOptions.PlatformHosts.Any(host => + string.Equals(host.Trim().TrimEnd('.'), requestHost, StringComparison.OrdinalIgnoreCase)); + var resolvedTenantContext = context.HttpContext.RequestServices.GetRequiredService(); var tenantInitializer = context.HttpContext.RequestServices .GetRequiredService(); - try + if (realm == Tiku.Domain.Tenancy.AuthRealm.Platform) { - tenantInitializer.Initialize(tenantId, null, TenantResolutionSource.Jwt); + if (!isPlatformHost || resolvedTenantContext.IsResolved) + { + context.HttpContext.Items["tenant_context_conflict"] = true; + context.Fail("Platform tokens are only valid on a platform host."); + return; + } } - catch (TenantContextConflictException) + else { - context.HttpContext.Items["tenant_context_conflict"] = true; - context.Fail("Authenticated tenant does not match the request host."); - return; + if (isPlatformHost && !resolvedTenantContext.IsResolved) + { + context.HttpContext.Items["tenant_context_conflict"] = true; + context.Fail("Tenant tokens on a platform host require a matching tenant code."); + return; + } + + try + { + tenantInitializer.Initialize(tenantId!.Value, null, TenantResolutionSource.Jwt); + } + catch (TenantContextConflictException) + { + context.HttpContext.Items["tenant_context_conflict"] = true; + context.Fail("Authenticated tenant does not match the request host."); + return; + } } - if (!jwtOptions.ValidateSessions) + var sessionStore = context.HttpContext.RequestServices.GetRequiredService(); + var session = await sessionStore.ValidateAccessSessionAsync( + sessionId, userId, realm.Value, tenantId, context.HttpContext.RequestAborted); + var tokenMfaSatisfied = principal.FindAll(TikuClaimTypes.Mfa) + .Any(claim => string.Equals(claim.Value, "mfa", StringComparison.Ordinal)); + if (session is null || session.MfaSatisfied != tokenMfaSatisfied) { - return; - } - - var sessionIdValue = context.Principal?.FindFirst(TikuClaimTypes.SessionId)?.Value; - if (!Guid.TryParse(sessionIdValue, out var sessionId)) - { - context.Fail("Missing session claim."); - return; - } - - var dbContext = context.HttpContext.RequestServices.GetRequiredService(); - var now = DateTimeOffset.UtcNow; - var isSessionActive = await dbContext.AuthSessions.AnyAsync( - session => - session.Id == sessionId && - session.RevokedAt == null && - session.ExpiresAt > now); - - if (!isSessionActive) - { - context.Fail("Session has been revoked or expired."); + context.Fail("Session, identity, membership, tenant, role or MFA state is no longer valid."); } }, OnChallenge = async context => @@ -307,32 +445,44 @@ try }; }); + builder.Services.AddOptions(JwtBearerDefaults.AuthenticationScheme) + .Configure((options, keyRing) => + { + options.TokenValidationParameters.IssuerSigningKeys = keyRing.ValidationKeys; + options.TokenValidationParameters.TryAllIssuerSigningKeys = false; + options.TokenValidationParameters.IssuerSigningKeyResolver = (_, _, kid, _) => + string.IsNullOrWhiteSpace(kid) + ? [] + : keyRing.ValidationKeys.Where(key => + string.Equals(key.KeyId, kid, StringComparison.Ordinal)); + }); + builder.Services.AddAuthorization(options => { + options.FallbackPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build(); options.AddPolicy( TikuPolicies.AuthenticatedUser, policy => policy.RequireAuthenticatedUser()); - options.AddPolicy( - TikuPolicies.CurrentTenantMember, - policy => policy - .RequireAuthenticatedUser() - .RequireAssertion(context => TenantRoleAuthorization.IsTenantMember(context.User))); options.AddPolicy( TikuPolicies.TenantAdmin, - policy => policy - .RequireAuthenticatedUser() - .RequireAssertion(context => TenantRoleAuthorization.IsTenantAdmin(context.User))); + policy => policy.RequireAuthenticatedUser()); }); + builder.Services.AddTikuRbacAuthorization(); + builder.Services.AddSingleton(); var app = builder.Build(); if (app.Environment.IsDevelopment()) { - app.MapOpenApi(); + app.MapOpenApi().AllowAnonymous(); app.MapScalarApiReference(options => options .WithTitle("TIKU Backend API") .AddPreferredSecuritySchemes("BearerAuth") - .EnablePersistentAuthentication()); + .EnablePersistentAuthentication()) + .AllowAnonymous(); } app.UseSerilogRequestLogging(SerilogRequestLogging.ConfigureRequestLogging); @@ -343,10 +493,8 @@ try app.UseCors(CorsOptions.PolicyName); app.UseMiddleware(); app.UseAuthentication(); - if (rateLimitOptions.Enabled) - { - app.UseRateLimiter(); - } + app.UseMiddleware(); + app.UseRateLimiter(); app.UseMiddleware(); app.UseAuthorization(); diff --git a/Tiku.Api/Security/AccessAuthorizationRequirements.cs b/Tiku.Api/Security/AccessAuthorizationRequirements.cs new file mode 100644 index 0000000..b770719 --- /dev/null +++ b/Tiku.Api/Security/AccessAuthorizationRequirements.cs @@ -0,0 +1,288 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; +using Tiku.Application.Security; + +namespace Tiku.Api.Security; + +public sealed record CurrentTenantMemberRequirement : IAuthorizationRequirement; + +public sealed record CurrentPlatformAccessRequirement : IAuthorizationRequirement; + +public sealed record TenantPermissionRequirement : IAuthorizationRequirement +{ + public TenantPermissionRequirement(string permissionCode) + { + BackendPermissions.EnsureTenant(permissionCode); + PermissionCode = permissionCode; + } + + public string PermissionCode { get; } +} + +public sealed record PlatformPermissionRequirement : IAuthorizationRequirement +{ + public PlatformPermissionRequirement(string permissionCode) + { + BackendPermissions.EnsurePlatform(permissionCode); + PermissionCode = permissionCode; + } + + public string PermissionCode { get; } +} + +public sealed record MfaRequirement : IAuthorizationRequirement; + +public sealed record AllDataScopeRequirement : IAuthorizationRequirement; + +public sealed record TenantResourceAccessRequirement : IAuthorizationRequirement; + +public sealed record TenantResourceAuthorizationResource( + Guid TenantId, + Guid? OwnerUserId = null, + Guid? RegionId = null, + Guid? ClassId = null); + +internal sealed class CurrentAccessAuthorizationHandler(ICurrentAccessContext accessContext) : + AuthorizationHandler +{ + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + CurrentTenantMemberRequirement requirement) + { + if (!IsTenantRealm(context.User)) + { + return; + } + + var access = await accessContext.GetAsync(); + if (access.IsCurrentTenantMember && + access.TenantId is { } tenantId && + FindTenantId(context.User) == tenantId) + { + context.Succeed(requirement); + } + } + + internal static bool IsTenantRealm(ClaimsPrincipal principal) => + string.Equals(principal.FindFirst(TikuClaimTypes.Realm)?.Value, "tenant", StringComparison.Ordinal); + + internal static bool IsPlatformRealm(ClaimsPrincipal principal) => + string.Equals(principal.FindFirst(TikuClaimTypes.Realm)?.Value, "platform", StringComparison.Ordinal) && + principal.FindFirst(TikuClaimTypes.TenantId) is null; + + private static Guid? FindTenantId(ClaimsPrincipal principal) => + Guid.TryParse(principal.FindFirst(TikuClaimTypes.TenantId)?.Value, out var tenantId) ? tenantId : null; +} + +internal sealed class TenantPermissionAuthorizationHandler(ICurrentAccessContext accessContext) : + AuthorizationHandler +{ + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + TenantPermissionRequirement requirement) + { + if (!CurrentAccessAuthorizationHandler.IsTenantRealm(context.User)) + { + return; + } + + var access = await accessContext.GetAsync(); + if (access.HasTenantPermission(requirement.PermissionCode)) + { + context.Succeed(requirement); + } + } +} + +internal sealed class CurrentPlatformAccessAuthorizationHandler(ICurrentAccessContext accessContext) : + AuthorizationHandler +{ + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + CurrentPlatformAccessRequirement requirement) + { + if (!CurrentAccessAuthorizationHandler.IsPlatformRealm(context.User)) + { + return; + } + + var access = await accessContext.GetAsync(); + if (access.IsUserActive && access.PlatformPermissions.Count > 0) + { + context.Succeed(requirement); + } + } +} + +internal sealed class TenantResourceAccessAuthorizationHandler(ICurrentAccessContext accessContext) : + AuthorizationHandler +{ + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + TenantResourceAccessRequirement requirement, + TenantResourceAuthorizationResource resource) + { + if (!CurrentAccessAuthorizationHandler.IsTenantRealm(context.User)) + { + return; + } + + var access = await accessContext.GetAsync(); + if (access.UserId is { } userId && + access.IsCurrentTenantMember && + access.TenantId == resource.TenantId && + access.DataScope.AllowsResource(userId, resource.OwnerUserId, resource.RegionId, resource.ClassId)) + { + context.Succeed(requirement); + } + } +} + +internal sealed class PlatformPermissionAuthorizationHandler(ICurrentAccessContext accessContext) : + AuthorizationHandler +{ + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + PlatformPermissionRequirement requirement) + { + if (!CurrentAccessAuthorizationHandler.IsPlatformRealm(context.User)) + { + return; + } + + var access = await accessContext.GetAsync(); + if (access.HasPlatformPermission(requirement.PermissionCode)) + { + context.Succeed(requirement); + } + } +} + +internal sealed class MfaAuthorizationHandler : AuthorizationHandler +{ + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, MfaRequirement requirement) + { + if (context.User.FindAll(TikuClaimTypes.Mfa).Any(claim => + string.Equals(claim.Value, "mfa", StringComparison.OrdinalIgnoreCase) || + string.Equals(claim.Value, "totp", StringComparison.OrdinalIgnoreCase) || + string.Equals(claim.Value, bool.TrueString, StringComparison.OrdinalIgnoreCase))) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } +} + +internal sealed class AllDataScopeAuthorizationHandler(ICurrentAccessContext accessContext) : + AuthorizationHandler +{ + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + AllDataScopeRequirement requirement) + { + var access = await accessContext.GetAsync(); + if (access.IsCurrentTenantMember && access.DataScope.Mode == DataScopeMode.All) + { + context.Succeed(requirement); + } + } +} + +public static class AccessAuthorizationServiceCollectionExtensions +{ + public static IServiceCollection AddTikuRbacAuthorization(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + + services.AddAuthorization(options => + { + options.AddPolicy( + TikuPolicies.CurrentTenantMember, + policy => policy + .RequireAuthenticatedUser() + .AddRequirements(new CurrentTenantMemberRequirement())); + options.AddPolicy( + TikuPolicies.Mfa, + policy => policy + .RequireAuthenticatedUser() + .AddRequirements(new MfaRequirement())); + options.AddPolicy( + TikuPolicies.TenantBackofficeBootstrap, + policy => policy + .RequireAuthenticatedUser() + .AddRequirements( + new CurrentTenantMemberRequirement(), + new MfaRequirement())); + options.AddPolicy( + TikuPolicies.PlatformBackofficeBootstrap, + policy => policy + .RequireAuthenticatedUser() + .AddRequirements( + new CurrentPlatformAccessRequirement(), + new MfaRequirement())); + // Temporary compatibility for controllers that have not yet been + // split into their module-specific permission policy. This must + // remain database-backed; an authenticated-only alias would reopen + // every legacy tenant administration endpoint to ordinary users. + options.AddPolicy( + TikuPolicies.TenantAdmin, + policy => policy + .RequireAuthenticatedUser() + .AddRequirements( + new CurrentTenantMemberRequirement(), + new TenantPermissionRequirement(BackendPermissions.TenantRoleManage), + new MfaRequirement())); + options.AddPolicy( + TikuPolicies.TenantContentManageAllScope, + policy => policy + .RequireAuthenticatedUser() + .AddRequirements( + new CurrentTenantMemberRequirement(), + new TenantPermissionRequirement(BackendPermissions.TenantContentManage), + new AllDataScopeRequirement(), + new MfaRequirement())); + options.AddPolicy( + TikuPolicies.TenantCommerceOperateAllScope, + policy => policy + .RequireAuthenticatedUser() + .AddRequirements( + new CurrentTenantMemberRequirement(), + new TenantPermissionRequirement(BackendPermissions.TenantCommerceOperate), + new AllDataScopeRequirement(), + new MfaRequirement())); + + foreach (var permissionCode in BackendPermissions.Tenant) + { + options.AddPolicy( + permissionCode, + policy => policy + .RequireAuthenticatedUser() + .AddRequirements( + new CurrentTenantMemberRequirement(), + new TenantPermissionRequirement(permissionCode), + new MfaRequirement())); + } + + foreach (var permissionCode in BackendPermissions.Platform) + { + options.AddPolicy( + permissionCode, + policy => policy + .RequireAuthenticatedUser() + .AddRequirements( + new PlatformPermissionRequirement(permissionCode), + new MfaRequirement())); + } + }); + + return services; + } +} diff --git a/Tiku.Api/Security/AuditingAuthorizationMiddlewareResultHandler.cs b/Tiku.Api/Security/AuditingAuthorizationMiddlewareResultHandler.cs new file mode 100644 index 0000000..5dc3950 --- /dev/null +++ b/Tiku.Api/Security/AuditingAuthorizationMiddlewareResultHandler.cs @@ -0,0 +1,59 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Policy; +using Tiku.Application.Security; +using Tiku.Domain.Operations; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Api.Security; + +internal sealed class AuditingAuthorizationMiddlewareResultHandler( + IServiceScopeFactory scopeFactory, + ILogger logger) : IAuthorizationMiddlewareResultHandler +{ + private readonly AuthorizationMiddlewareResultHandler fallback = new(); + + public async Task HandleAsync( + RequestDelegate next, + HttpContext context, + AuthorizationPolicy policy, + PolicyAuthorizationResult authorizeResult) + { + if (authorizeResult.Forbidden && context.User.Identity?.IsAuthenticated == true) + { + try + { + await using var scope = scopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.AuditLogs.Add(new AuditLog + { + TenantId = Guid.TryParse(context.User.FindFirst(TikuClaimTypes.TenantId)?.Value, out var tenantId) + ? tenantId + : null, + ActorUserId = Guid.TryParse(context.User.FindFirst(TikuClaimTypes.UserId)?.Value, out var userId) + ? userId + : null, + Action = "authorization.access_denied", + TargetType = "http_endpoint", + TargetId = context.Request.Path, + IpAddress = context.Connection.RemoteIpAddress?.ToString(), + UserAgent = context.Request.Headers.UserAgent.ToString(), + Details = JsonSerializer.SerializeToElement(new + { + Method = context.Request.Method, + Path = context.Request.Path.Value, + Realm = context.User.FindFirst(TikuClaimTypes.Realm)?.Value, + Failure = authorizeResult.AuthorizationFailure?.FailureReasons.Select(reason => reason.Message).ToArray() + }) + }); + await dbContext.SaveChangesAsync(context.RequestAborted); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Failed to persist authorization denial audit event."); + } + } + + await fallback.HandleAsync(next, context, policy, authorizeResult); + } +} diff --git a/Tiku.Api/Security/TenantRoleAuthorization.cs b/Tiku.Api/Security/TenantRoleAuthorization.cs deleted file mode 100644 index aa01d5b..0000000 --- a/Tiku.Api/Security/TenantRoleAuthorization.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Security.Claims; -using Tiku.Application.Security; -using Tiku.Domain.Tenancy; -using ZLinq; - -namespace Tiku.Api.Security; - -internal static class TenantRoleAuthorization -{ - private static readonly HashSet AdminRoles = new(StringComparer.Ordinal) - { - nameof(TenantRole.PlatformAdmin), - nameof(TenantRole.TenantOwner), - nameof(TenantRole.TenantAdmin) - }; - - public static bool IsTenantMember(ClaimsPrincipal principal) - { - return principal.Identity?.IsAuthenticated == true && - principal.HasClaim(claim => claim.Type == TikuClaimTypes.TenantId); - } - - public static bool IsTenantAdmin(ClaimsPrincipal principal) - { - return IsTenantMember(principal) && - principal.Claims.AsValueEnumerable() - .Where(claim => claim.Type == TikuClaimTypes.TenantRole) - .Select(claim => claim.Value) - .Any(role => AdminRoles.Contains(role)); - } -} diff --git a/Tiku.Api/appsettings.Development.json b/Tiku.Api/appsettings.Development.json index 4027793..9057ce4 100644 --- a/Tiku.Api/appsettings.Development.json +++ b/Tiku.Api/appsettings.Development.json @@ -24,9 +24,14 @@ "WindowSeconds": 60, "QueueLimit": 0 }, + "Authentication": { + "Sms": { + "CodePepper": "development-only-sms-code-pepper-change-before-production" + } + }, "Security": { "Jwt": { - "SigningKey": "development-only-tiku-signing-key-change-before-production" + "KeyId": "development-ephemeral" }, "TenantSecrets": { "KeyId": "development-v1", diff --git a/Tiku.Api/appsettings.json b/Tiku.Api/appsettings.json index e1d7ff8..584d301 100644 --- a/Tiku.Api/appsettings.json +++ b/Tiku.Api/appsettings.json @@ -47,7 +47,25 @@ "Enabled": true, "PermitLimit": 600, "WindowSeconds": 60, - "QueueLimit": 0 + "QueueLimit": 0, + "Authentication": { + "PasswordPermitLimit": 5, + "PasswordWindowSeconds": 900, + "SmsPermitLimit": 5, + "SmsWindowSeconds": 300, + "MfaPermitLimit": 5, + "MfaWindowSeconds": 300 + } + }, + "Authentication": { + "Sms": { + "CodePepper": "", + "MaxVerificationAttempts": 5, + "TenantRequestsPerHour": 100, + "PhoneRequestsPerHour": 5, + "IpRequestsPerHour": 20, + "DeviceRequestsPerHour": 10 + } }, "Storage": { "DefaultProvider": "aliyun_oss", @@ -84,14 +102,20 @@ "Jwt": { "Issuer": "tiku-backend", "Audience": "tiku-api", - "SigningKey": "", - "AccessTokenMinutes": 30, - "RefreshTokenDays": 30, - "ValidateSessions": true + "KeyId": "", + "PrivateKeyPem": "", + "PublicKeys": {}, + "AccessTokenMinutes": 15, + "RefreshTokenDays": 30 }, "TenantSecrets": { "KeyId": "", "MasterKey": "" + }, + "DataProtection": { + "ApplicationName": "Tiku.Api", + "CertificatePath": "", + "CertificatePassword": "" } }, "AllowedHosts": "*" diff --git a/Tiku.Application/Auth/AuthContracts.cs b/Tiku.Application/Auth/AuthContracts.cs index 0587beb..48db6f5 100644 --- a/Tiku.Application/Auth/AuthContracts.cs +++ b/Tiku.Application/Auth/AuthContracts.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Tiku.Domain.Tenancy; namespace Tiku.Application.Auth; @@ -35,6 +36,7 @@ public sealed record TenantMembershipSummary( /// 手机号。 /// 邮箱。 /// 用户显示名称。 +/// 当前令牌的 tenant 或 platform 授权域。 /// 当前登录租户成员摘要。 /// 认证令牌对。 public sealed record AuthenticatedUser( @@ -42,25 +44,47 @@ public sealed record AuthenticatedUser( string? Phone, string? Email, string? Name, - TenantMembershipSummary Tenant, + AuthRealm Realm, + TenantMembershipSummary? Tenant, AuthTokenPair Tokens); +public enum AuthenticationStatus +{ + [JsonStringEnumMemberName("authenticated")] + Authenticated, + [JsonStringEnumMemberName("mfa_required")] + MfaRequired, + [JsonStringEnumMemberName("mfa_enrollment_required")] + MfaEnrollmentRequired, + [JsonStringEnumMemberName("password_change_required")] + PasswordChangeRequired +} + +public sealed record AuthenticationResult( + AuthenticationStatus Status, + AuthenticatedUser? User = null, + string? ChallengeToken = null, + DateTimeOffset? ChallengeExpiresAt = null); + public sealed record PasswordLoginRequest( - Guid TenantId, + AuthRealm Realm, + Guid? TenantId, string Phone, string Password, string? IpAddress, string? UserAgent); public sealed record SmsLoginRequest( - Guid TenantId, + AuthRealm Realm, + Guid? TenantId, string Phone, string Code, string? IpAddress, string? UserAgent); public sealed record WechatLoginRequest( - Guid TenantId, + AuthRealm Realm, + Guid? TenantId, string Code, string? IpAddress, string? UserAgent); @@ -73,6 +97,24 @@ public sealed record RefreshSessionRequest( public sealed record LogoutSessionRequest( string RefreshToken); +public sealed record MfaChallengeRequest( + string ChallengeToken, + string? Code, + string? IpAddress, + string? UserAgent); + +public sealed record PasswordChangeChallengeRequest( + string ChallengeToken, + string NewPassword, + string? IpAddress, + string? UserAgent); + +public sealed record MfaSetupResult(string SharedKey, string AuthenticatorUri); + +public sealed record MfaConfirmResult( + AuthenticationResult Authentication, + IReadOnlyList RecoveryCodes); + public sealed record SmsSendResult( Guid VerificationId, DateTimeOffset ExpiresAt); @@ -82,4 +124,5 @@ public sealed record SendSmsCodeRequest( string Phone, SmsPurpose Purpose, string? IpAddress, - string? UserAgent); + string? UserAgent, + string? DeviceId = null); diff --git a/Tiku.Application/Auth/AuthExceptions.cs b/Tiku.Application/Auth/AuthExceptions.cs index 8c5552a..652a274 100644 --- a/Tiku.Application/Auth/AuthExceptions.cs +++ b/Tiku.Application/Auth/AuthExceptions.cs @@ -19,3 +19,6 @@ public sealed class SmsRateLimitedException() public sealed class AuthProviderNotConfiguredException(string provider) : AuthException("auth_provider_not_configured", $"The {provider} auth provider is not configured."); + +public sealed class InvalidAuthChallengeException(string code = "invalid_auth_challenge") + : AuthException(code, "The authentication challenge is invalid, consumed, or expired."); diff --git a/Tiku.Application/Auth/IAuthService.cs b/Tiku.Application/Auth/IAuthService.cs index ed3b9ee..46a2389 100644 --- a/Tiku.Application/Auth/IAuthService.cs +++ b/Tiku.Application/Auth/IAuthService.cs @@ -2,19 +2,19 @@ namespace Tiku.Application.Auth; public interface IAuthService { - Task LoginWithPasswordAsync( + Task LoginWithPasswordAsync( PasswordLoginRequest request, CancellationToken cancellationToken = default); - Task LoginWithSmsAsync( + Task LoginWithSmsAsync( SmsLoginRequest request, CancellationToken cancellationToken = default); - Task LoginWithWechatWebAsync( + Task LoginWithWechatWebAsync( WechatLoginRequest request, CancellationToken cancellationToken = default); - Task LoginWithWechatMiniAppAsync( + Task LoginWithWechatMiniAppAsync( WechatLoginRequest request, CancellationToken cancellationToken = default); @@ -25,4 +25,22 @@ public interface IAuthService Task LogoutAsync( LogoutSessionRequest request, CancellationToken cancellationToken = default); + + Task LogoutAllAsync(Guid userId, CancellationToken cancellationToken = default); + + Task SetupTotpAsync( + MfaChallengeRequest request, + CancellationToken cancellationToken = default); + + Task ConfirmTotpAsync( + MfaChallengeRequest request, + CancellationToken cancellationToken = default); + + Task VerifyTotpAsync( + MfaChallengeRequest request, + CancellationToken cancellationToken = default); + + Task ChangeRequiredPasswordAsync( + PasswordChangeChallengeRequest request, + CancellationToken cancellationToken = default); } diff --git a/Tiku.Application/Auth/IAuthSessionStore.cs b/Tiku.Application/Auth/IAuthSessionStore.cs new file mode 100644 index 0000000..8ed7b3f --- /dev/null +++ b/Tiku.Application/Auth/IAuthSessionStore.cs @@ -0,0 +1,49 @@ +using Tiku.Domain.Tenancy; + +namespace Tiku.Application.Auth; + +public interface IAuthSessionStore +{ + string GenerateRefreshToken(AuthRealm realm, Guid? tenantId, Guid sessionId); + bool TryParseRefreshToken(string refreshToken, out RefreshTokenLocator locator); + string HashRefreshToken(string refreshToken); + + Task IssueAsync( + AuthSessionIssueRequest request, + CancellationToken cancellationToken = default); + + Task RotateAsync( + string refreshToken, + string? ipAddress, + string? userAgent, + CancellationToken cancellationToken = default); + + Task ValidateAccessSessionAsync( + Guid sessionId, + Guid userId, + AuthRealm realm, + Guid? tenantId, + CancellationToken cancellationToken = default); + + Task RevokeFamilyAsync(string refreshToken, string reason, CancellationToken cancellationToken = default); + Task RevokeRealmAsync(Guid userId, AuthRealm realm, Guid? tenantId, string reason, CancellationToken cancellationToken = default); + Task RevokeAllAsync(Guid userId, string reason, CancellationToken cancellationToken = default); +} + +public sealed record AuthSessionIssueRequest( + Guid UserId, + string? Phone, + string? Email, + string SecurityStamp, + AuthRealm Realm, + Guid? TenantId, + string Provider, + bool MfaSatisfied, + string? IpAddress, + string? UserAgent, + Guid? TokenFamilyId = null, + Guid? ParentSessionId = null); + +public sealed record AuthSessionValidationResult(Guid UserId, AuthRealm Realm, Guid? TenantId, bool MfaSatisfied); + +public readonly record struct RefreshTokenLocator(AuthRealm Realm, Guid? TenantId, Guid SessionId); diff --git a/Tiku.Application/Auth/IPasswordHasher.cs b/Tiku.Application/Auth/IPasswordHasher.cs deleted file mode 100644 index 6e54b61..0000000 --- a/Tiku.Application/Auth/IPasswordHasher.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Tiku.Application.Auth; - -public interface IPasswordHasher -{ - string Hash(string password); - bool Verify(string password, string passwordHash); -} diff --git a/Tiku.Application/Auth/ISessionService.cs b/Tiku.Application/Auth/ISessionService.cs deleted file mode 100644 index b40c3eb..0000000 --- a/Tiku.Application/Auth/ISessionService.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Tiku.Domain.Tenancy; - -namespace Tiku.Application.Auth; - -public interface ISessionService -{ - string GenerateRefreshToken(Guid tenantId, Guid sessionId); - bool TryParseRefreshToken(string refreshToken, out RefreshTokenLocator locator); - string HashRefreshToken(string refreshToken); - - Task IssueAsync( - Guid userId, - string? phone, - string? email, - TenantMembership membership, - string provider, - string? ipAddress, - string? userAgent, - CancellationToken cancellationToken = default); -} - -public readonly record struct RefreshTokenLocator(Guid TenantId, Guid SessionId); diff --git a/Tiku.Application/Auth/ITokenService.cs b/Tiku.Application/Auth/ITokenService.cs index d497c89..9003f8d 100644 --- a/Tiku.Application/Auth/ITokenService.cs +++ b/Tiku.Application/Auth/ITokenService.cs @@ -9,5 +9,7 @@ public interface ITokenService Guid sessionId, string? phone, string? email, - TenantMembership membership); + AuthRealm realm, + Guid? tenantId, + bool mfaSatisfied); } diff --git a/Tiku.Application/Auth/SmsSecurityOptions.cs b/Tiku.Application/Auth/SmsSecurityOptions.cs new file mode 100644 index 0000000..823e4fd --- /dev/null +++ b/Tiku.Application/Auth/SmsSecurityOptions.cs @@ -0,0 +1,23 @@ +namespace Tiku.Application.Auth; + +public sealed class SmsSecurityOptions +{ + public const string SectionName = "Authentication:Sms"; + + public string CodePepper { get; set; } = string.Empty; + public int MaxVerificationAttempts { get; set; } = 5; + public int TenantRequestsPerHour { get; set; } = 100; + public int PhoneRequestsPerHour { get; set; } = 5; + public int IpRequestsPerHour { get; set; } = 20; + public int DeviceRequestsPerHour { get; set; } = 10; + + public static bool BeValid(SmsSecurityOptions options) + { + return options.CodePepper.Length >= 32 && + options.MaxVerificationAttempts == 5 && + options.TenantRequestsPerHour > 0 && + options.PhoneRequestsPerHour > 0 && + options.IpRequestsPerHour > 0 && + options.DeviceRequestsPerHour > 0; + } +} diff --git a/Tiku.Application/Backoffice/BackofficeModels.cs b/Tiku.Application/Backoffice/BackofficeModels.cs index d48b3b2..08dd21e 100644 --- a/Tiku.Application/Backoffice/BackofficeModels.cs +++ b/Tiku.Application/Backoffice/BackofficeModels.cs @@ -1,15 +1,43 @@ using System.Text.Json; +using Tiku.Application.Security; using Tiku.Domain.Operations; namespace Tiku.Application.Backoffice; -public sealed record BackofficeActor(Guid UserId, Guid? TenantId, bool IsPlatform); +public sealed record BackofficeActor(Guid UserId, Guid? TenantId, bool IsPlatform) +{ + public static BackofficeActor FromTenantAccess(CurrentAccessSnapshot access) + { + if (access.UserId is not { } userId || + access.TenantId is not { } tenantId || + !access.IsCurrentTenantMember) + { + throw new InvalidOperationException("Tenant backoffice actor was not resolved."); + } + + return new BackofficeActor(userId, tenantId, false); + } + + public static BackofficeActor FromPlatformAccess(CurrentAccessSnapshot access) + { + if (access.UserId is not { } userId || !access.IsUserActive) + { + throw new InvalidOperationException("Platform backoffice actor was not resolved."); + } + + return new BackofficeActor(userId, null, true); + } +} public sealed record BackofficeBootstrap( IReadOnlyCollection Permissions, IReadOnlyCollection Menus, IReadOnlyCollection Roles); +public sealed record BackofficeUiBootstrap( + IReadOnlyCollection PermissionCodes, + IReadOnlyCollection Menus); + public sealed record BackofficePermissionItem( Guid Id, string Code, diff --git a/Tiku.Application/Backoffice/IBackofficeService.cs b/Tiku.Application/Backoffice/IBackofficeService.cs index ba6b25a..7c7c3d5 100644 --- a/Tiku.Application/Backoffice/IBackofficeService.cs +++ b/Tiku.Application/Backoffice/IBackofficeService.cs @@ -1,7 +1,17 @@ +using Tiku.Application.Security; + namespace Tiku.Application.Backoffice; public interface IBackofficeService { + Task GetTenantUiBootstrapAsync( + CurrentAccessSnapshot access, + CancellationToken cancellationToken = default); + + Task GetPlatformUiBootstrapAsync( + CurrentAccessSnapshot access, + CancellationToken cancellationToken = default); + Task GetTenantBootstrapAsync( BackofficeActor actor, CancellationToken cancellationToken = default); diff --git a/Tiku.Application/Security/BackendPermissions.cs b/Tiku.Application/Security/BackendPermissions.cs new file mode 100644 index 0000000..bf34a06 --- /dev/null +++ b/Tiku.Application/Security/BackendPermissions.cs @@ -0,0 +1,64 @@ +namespace Tiku.Application.Security; + +public static class BackendPermissions +{ + public const string TenantDashboardView = "tenant:dashboard:view"; + public const string TenantStaffManage = "tenant:staff:manage"; + public const string TenantRoleManage = "tenant:role:manage"; + public const string TenantStudentManage = "tenant:student:manage"; + public const string TenantContentManage = "tenant:content:manage"; + public const string TenantSettingsManage = "tenant:settings:manage"; + public const string TenantProviderManage = "tenant:provider:manage"; + public const string TenantCommerceOperate = "tenant:commerce:operate"; + public const string TenantCrmManage = "tenant:crm:manage"; + public const string TenantCommissionManage = "tenant:commission:manage"; + public const string TenantJobManage = "tenant:job:manage"; + + public const string PlatformDashboardView = "platform:dashboard:view"; + public const string PlatformTenantManage = "platform:tenant:manage"; + public const string PlatformStaffManage = "platform:staff:manage"; + public const string PlatformRoleManage = "platform:role:manage"; + public const string PlatformQuestionBankManage = "platform:question-bank:manage"; + public const string PlatformAuditView = "platform:audit:view"; + + public static readonly IReadOnlySet Tenant = new HashSet(StringComparer.Ordinal) + { + TenantDashboardView, + TenantStaffManage, + TenantRoleManage, + TenantStudentManage, + TenantContentManage, + TenantSettingsManage, + TenantProviderManage, + TenantCommerceOperate, + TenantCrmManage, + TenantCommissionManage, + TenantJobManage + }; + + public static readonly IReadOnlySet Platform = new HashSet(StringComparer.Ordinal) + { + PlatformDashboardView, + PlatformTenantManage, + PlatformStaffManage, + PlatformRoleManage, + PlatformQuestionBankManage, + PlatformAuditView + }; + + public static void EnsureTenant(string permissionCode) + { + if (!Tenant.Contains(permissionCode)) + { + throw new ArgumentOutOfRangeException(nameof(permissionCode), permissionCode, "Unknown tenant permission."); + } + } + + public static void EnsurePlatform(string permissionCode) + { + if (!Platform.Contains(permissionCode)) + { + throw new ArgumentOutOfRangeException(nameof(permissionCode), permissionCode, "Unknown platform permission."); + } + } +} diff --git a/Tiku.Application/Security/CurrentUser.cs b/Tiku.Application/Security/CurrentUser.cs index 7f318eb..30b9432 100644 --- a/Tiku.Application/Security/CurrentUser.cs +++ b/Tiku.Application/Security/CurrentUser.cs @@ -8,7 +8,6 @@ public sealed class CurrentUser : ICurrentUser public Guid? SessionId { get; private set; } public string? Phone { get; private set; } public string? Email { get; private set; } - public string? TenantRole { get; private set; } public bool IsAuthenticated { get; private set; } public void Load(ClaimsPrincipal principal) @@ -18,6 +17,5 @@ public sealed class CurrentUser : ICurrentUser SessionId = principal.FindGuid(TikuClaimTypes.SessionId); Phone = principal.FindValue(TikuClaimTypes.Phone); Email = principal.FindValue(TikuClaimTypes.Email); - TenantRole = principal.FindValue(TikuClaimTypes.TenantRole); } } diff --git a/Tiku.Application/Security/ICurrentAccessContext.cs b/Tiku.Application/Security/ICurrentAccessContext.cs new file mode 100644 index 0000000..5ad5489 --- /dev/null +++ b/Tiku.Application/Security/ICurrentAccessContext.cs @@ -0,0 +1,149 @@ +using System.Text.Json; + +namespace Tiku.Application.Security; + +public enum DataScopeMode +{ + Self, + Restricted, + All +} + +public sealed record CurrentDataScope( + DataScopeMode Mode, + IReadOnlySet RegionIds, + IReadOnlySet ClassIds, + bool IncludesSelf) +{ + public static CurrentDataScope Self { get; } = new( + DataScopeMode.Self, + new HashSet(), + new HashSet(), + true); + + public bool AllowsResource(Guid currentUserId, Guid? ownerUserId = null, Guid? regionId = null, Guid? classId = null) + { + if (Mode == DataScopeMode.All) + { + return true; + } + + if (IncludesSelf && ownerUserId == currentUserId) + { + return true; + } + + return Mode == DataScopeMode.Restricted && + ((regionId.HasValue && RegionIds.Contains(regionId.Value)) || + (classId.HasValue && ClassIds.Contains(classId.Value))); + } + + public static CurrentDataScope Merge(IEnumerable roleScopes) + { + var regionIds = new HashSet(); + var classIds = new HashSet(); + var includesSelf = false; + var hasRestrictedScope = false; + + foreach (var roleScope in roleScopes) + { + var parsed = Parse(roleScope); + if (parsed.Mode == DataScopeMode.All) + { + return new CurrentDataScope(DataScopeMode.All, new HashSet(), new HashSet(), true); + } + + includesSelf |= parsed.IncludesSelf; + hasRestrictedScope |= parsed.Mode == DataScopeMode.Restricted; + regionIds.UnionWith(parsed.RegionIds); + classIds.UnionWith(parsed.ClassIds); + } + + return hasRestrictedScope || regionIds.Count > 0 || classIds.Count > 0 + ? new CurrentDataScope(DataScopeMode.Restricted, regionIds, classIds, includesSelf) + : Self; + } + + private static CurrentDataScope Parse(JsonElement value) + { + if (value.ValueKind != JsonValueKind.Object) + { + return Self; + } + + var mode = ReadString(value, "mode") ?? ReadString(value, "type"); + if (string.Equals(mode, nameof(DataScopeMode.All), StringComparison.OrdinalIgnoreCase)) + { + return new CurrentDataScope(DataScopeMode.All, new HashSet(), new HashSet(), true); + } + + if (string.Equals(mode, nameof(DataScopeMode.Self), StringComparison.OrdinalIgnoreCase)) + { + return Self; + } + + var regions = ReadGuids(value, "regionIds"); + var classes = ReadGuids(value, "classIds"); + var restricted = string.Equals(mode, nameof(DataScopeMode.Restricted), StringComparison.OrdinalIgnoreCase) || + regions.Count > 0 || + classes.Count > 0; + + return restricted + ? new CurrentDataScope(DataScopeMode.Restricted, regions, classes, ReadBoolean(value, "includesSelf") || ReadBoolean(value, "ownLeadsOnly")) + : Self; + } + + private static string? ReadString(JsonElement value, string propertyName) + { + return value.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + } + + private static bool ReadBoolean(JsonElement value, string propertyName) + { + return value.TryGetProperty(propertyName, out var property) && + property.ValueKind is JsonValueKind.True or JsonValueKind.False && + property.GetBoolean(); + } + + private static HashSet ReadGuids(JsonElement value, string propertyName) + { + var result = new HashSet(); + if (!value.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.Array) + { + return result; + } + + foreach (var item in property.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String && Guid.TryParse(item.GetString(), out var id)) + { + result.Add(id); + } + } + + return result; + } +} + +public sealed record CurrentAccessSnapshot( + Guid? UserId, + Guid? TenantId, + bool IsUserActive, + bool IsCurrentTenantMember, + IReadOnlySet TenantPermissions, + IReadOnlySet PlatformPermissions, + CurrentDataScope DataScope) +{ + public bool HasTenantPermission(string permissionCode) => + IsCurrentTenantMember && TenantPermissions.Contains(permissionCode); + + public bool HasPlatformPermission(string permissionCode) => + IsUserActive && PlatformPermissions.Contains(permissionCode); +} + +public interface ICurrentAccessContext +{ + Task GetAsync(CancellationToken cancellationToken = default); +} diff --git a/Tiku.Application/Security/ICurrentUser.cs b/Tiku.Application/Security/ICurrentUser.cs index 58741b0..3539862 100644 --- a/Tiku.Application/Security/ICurrentUser.cs +++ b/Tiku.Application/Security/ICurrentUser.cs @@ -8,7 +8,6 @@ public interface ICurrentUser Guid? SessionId { get; } string? Phone { get; } string? Email { get; } - string? TenantRole { get; } bool IsAuthenticated { get; } void Load(ClaimsPrincipal principal); } diff --git a/Tiku.Application/Security/IJwtKeyRing.cs b/Tiku.Application/Security/IJwtKeyRing.cs new file mode 100644 index 0000000..e6021b5 --- /dev/null +++ b/Tiku.Application/Security/IJwtKeyRing.cs @@ -0,0 +1,9 @@ +using Microsoft.IdentityModel.Tokens; + +namespace Tiku.Application.Security; + +public interface IJwtKeyRing +{ + SigningCredentials SigningCredentials { get; } + IReadOnlyCollection ValidationKeys { get; } +} diff --git a/Tiku.Application/Security/JwtOptions.cs b/Tiku.Application/Security/JwtOptions.cs index ae46041..a0234e8 100644 --- a/Tiku.Application/Security/JwtOptions.cs +++ b/Tiku.Application/Security/JwtOptions.cs @@ -9,14 +9,73 @@ public sealed class JwtOptions public string Audience { get; set; } = "tiku-api"; [System.ComponentModel.DataAnnotations.Required] - [System.ComponentModel.DataAnnotations.MinLength(32)] - public string SigningKey { get; set; } = string.Empty; + public string KeyId { get; set; } = "development-ephemeral"; + + public string PrivateKeyPem { get; set; } = string.Empty; + + public Dictionary PublicKeys { get; set; } = new(StringComparer.Ordinal); [System.ComponentModel.DataAnnotations.Range(1, 1440)] - public int AccessTokenMinutes { get; set; } = 30; + public int AccessTokenMinutes { get; set; } = 15; [System.ComponentModel.DataAnnotations.Range(1, 365)] public int RefreshTokenDays { get; set; } = 30; - public bool ValidateSessions { get; set; } = true; + public static bool BeValid(JwtOptions options, bool isProduction) + { + if (string.IsNullOrWhiteSpace(options.Issuer) || + string.IsNullOrWhiteSpace(options.Audience) || + string.IsNullOrWhiteSpace(options.KeyId) || + options.AccessTokenMinutes != 15 || + (isProduction && string.Equals( + options.KeyId, + "development-ephemeral", + StringComparison.Ordinal))) + { + return false; + } + + if (string.IsNullOrWhiteSpace(options.PrivateKeyPem)) + { + if (isProduction) + { + return false; + } + } + else if (!IsValidRsaPem(options.PrivateKeyPem, requirePrivateKey: true)) + { + return false; + } + + return options.PublicKeys.All(pair => + !string.IsNullOrWhiteSpace(pair.Key) && + !string.Equals(pair.Key, options.KeyId, StringComparison.Ordinal) && + IsValidRsaPem(pair.Value, requirePrivateKey: false)); + } + + private static bool IsValidRsaPem(string pem, bool requirePrivateKey) + { + try + { + using var rsa = System.Security.Cryptography.RSA.Create(); + rsa.ImportFromPem(pem); + if (rsa.KeySize < 2048) + { + return false; + } + + if (requirePrivateKey) + { + _ = rsa.ExportParameters(includePrivateParameters: true); + } + + return true; + } + catch (Exception exception) when ( + exception is ArgumentException or + System.Security.Cryptography.CryptographicException) + { + return false; + } + } } diff --git a/Tiku.Application/Security/TikuClaimTypes.cs b/Tiku.Application/Security/TikuClaimTypes.cs index 59df2b6..6027619 100644 --- a/Tiku.Application/Security/TikuClaimTypes.cs +++ b/Tiku.Application/Security/TikuClaimTypes.cs @@ -4,10 +4,11 @@ namespace Tiku.Application.Security; public static class TikuClaimTypes { - public const string UserId = "tiku:user_id"; - public const string TenantId = "tiku:tenant_id"; - public const string SessionId = "tiku:session_id"; - public const string TenantRole = "tiku:tenant_role"; + public const string UserId = "sub"; + public const string TenantId = "tid"; + public const string SessionId = "sid"; + public const string Realm = "scope"; + public const string Mfa = "amr"; public const string Phone = ClaimTypes.MobilePhone; public const string Email = ClaimTypes.Email; } diff --git a/Tiku.Application/Security/TikuPolicies.cs b/Tiku.Application/Security/TikuPolicies.cs index 2ff5019..99bdd19 100644 --- a/Tiku.Application/Security/TikuPolicies.cs +++ b/Tiku.Application/Security/TikuPolicies.cs @@ -4,5 +4,23 @@ public static class TikuPolicies { public const string AuthenticatedUser = "authenticated_user"; public const string CurrentTenantMember = "current_tenant_member"; + public const string TenantBackofficeBootstrap = "tenant_backoffice_bootstrap"; + public const string PlatformBackofficeBootstrap = "platform_backoffice_bootstrap"; + public const string Mfa = "mfa"; + public const string TenantContentManageAllScope = "tenant:content:manage:all_scope"; + public const string TenantCommerceOperateAllScope = "tenant:commerce:operate:all_scope"; + public const string TenantAdmin = "tenant_admin"; + + public static string TenantPermission(string permissionCode) + { + BackendPermissions.EnsureTenant(permissionCode); + return permissionCode; + } + + public static string PlatformPermission(string permissionCode) + { + BackendPermissions.EnsurePlatform(permissionCode); + return permissionCode; + } } diff --git a/Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs b/Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs index dae6b19..b975dcf 100644 --- a/Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs +++ b/Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs @@ -90,25 +90,6 @@ public interface ITenantAdminDirectService TenantAdminAuditLogFilter filter, CancellationToken cancellationToken = default); - Task GetPermissionMatrixAsync( - TenantAdminActor actor, - CancellationToken cancellationToken = default); - - Task> GetRoleTemplatesAsync( - TenantAdminActor actor, - TenantAdminRoleTemplateFilter filter, - CancellationToken cancellationToken = default); - - Task> UpsertRoleTemplateAsync( - TenantAdminActor actor, - UpsertTenantAdminRoleTemplateCommand command, - CancellationToken cancellationToken = default); - - Task> DisableRoleTemplateAsync( - TenantAdminActor actor, - Guid roleTemplateId, - CancellationToken cancellationToken = default); - Task> UpsertBrandingAsync( TenantAdminActor actor, UpsertTenantBrandingCommand command, diff --git a/Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs b/Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs index 033d88e..862c15f 100644 --- a/Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs +++ b/Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs @@ -6,7 +6,18 @@ using Tiku.Domain.Tenancy; namespace Tiku.Application.TenantAdmin; -public sealed record TenantAdminActor(Guid TenantId, Guid UserId, TenantRole Role = TenantRole.TenantAdmin); +public sealed record TenantAdminActor(Guid TenantId, Guid UserId) +{ + public static TenantAdminActor FromResolvedIdentity(Guid? tenantId, Guid? userId) + { + if (tenantId is null || userId is null) + { + throw new InvalidOperationException("Tenant admin actor was not resolved."); + } + + return new(tenantId.Value, userId.Value); + } +} public sealed record TenantAdminClassFilter( Guid? RegionId = null, @@ -44,10 +55,6 @@ public sealed record TenantAdminAuditLogFilter( Guid? ActorUserId = null, int? Limit = null); -public sealed record TenantAdminRoleTemplateFilter( - string? Status = null, - int? Limit = null); - public sealed record TenantAdminBadgeFilter( string? Category = null, bool IncludeInactive = false, @@ -140,24 +147,8 @@ public sealed record UpsertTenantAdminMemberCommand( UserLookupCommand User, string? Role, string? Status, - Guid? RoleTemplateId, - JsonElement Permissions, string? PrimaryRole); -public sealed record UpsertTenantAdminRoleTemplateCommand( - Guid? Id, - string? Code, - string Name, - string? Description, - string? BaseRole, - string? Status, - JsonElement Permissions, - JsonElement MenuPermissions, - JsonElement ModulePermissions, - JsonElement FieldPermissions, - JsonElement DataScope, - int? Order); - public sealed record UpsertTenantBrandingCommand( string BrandName, string? ShortName, @@ -357,10 +348,6 @@ public sealed record TenantAdminMemberItem( Guid UserId, TenantRole Role, MembershipStatus Status, - JsonElement Permissions, - Guid? RoleTemplateId, - string? RoleTemplateCode, - string? RoleTemplateName, string? LegacyRole, TenantAdminUserSummary User, DateTimeOffset CreatedAt, @@ -379,37 +366,6 @@ public sealed record TenantAdminAuditLogItem( string? ActorPhone, DateTimeOffset CreatedAt); -public sealed record TenantAdminPermissionMatrix( - TenantAdminCurrentPermission Current, - IReadOnlyCollection Permissions, - IReadOnlyDictionary> RoleDefaults); - -public sealed record TenantAdminCurrentPermission( - Guid UserId, - Guid TenantId, - TenantRole Role); - -public sealed record TenantAdminPermissionCatalogItem(string Key, string Label); - -public sealed record TenantAdminRoleTemplateItem( - Guid Id, - string Code, - string Name, - string? Description, - TenantRole BaseRole, - TenantRoleTemplateStatus Status, - JsonElement Permissions, - JsonElement MenuPermissions, - JsonElement ModulePermissions, - JsonElement FieldPermissions, - JsonElement DataScope, - bool IsSystem, - int Order, - Guid? CreatedBy, - Guid? UpdatedBy, - DateTimeOffset CreatedAt, - DateTimeOffset UpdatedAt); - public sealed record TenantBrandingItem( Guid TenantId, string BrandName, diff --git a/Tiku.Application/Tiku.Application.csproj b/Tiku.Application/Tiku.Application.csproj index 0d76892..c418dd3 100644 --- a/Tiku.Application/Tiku.Application.csproj +++ b/Tiku.Application/Tiku.Application.csproj @@ -6,6 +6,7 @@ + diff --git a/Tiku.DbMigrator/Program.cs b/Tiku.DbMigrator/Program.cs index b179761..127a3dd 100644 --- a/Tiku.DbMigrator/Program.cs +++ b/Tiku.DbMigrator/Program.cs @@ -1,12 +1,24 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Tiku.Infrastructure; using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Bootstrap; using Tiku.Application; var builder = Host.CreateApplicationBuilder(args); +var bootstrapPlatformAdmin = args.Contains("--bootstrap-platform-admin", StringComparer.Ordinal); +PlatformAdminBootstrapOptions? bootstrapOptions = null; +if (bootstrapPlatformAdmin) +{ + bootstrapOptions = new PlatformAdminBootstrapOptions( + RequiredBootstrapSetting(builder.Configuration, "TIKU_BOOTSTRAP_PLATFORM_ADMIN_EMAIL"), + RequiredBootstrapSetting(builder.Configuration, "TIKU_BOOTSTRAP_PLATFORM_ADMIN_PASSWORD"), + builder.Configuration["TIKU_BOOTSTRAP_PLATFORM_ADMIN_NAME"]); +} + var connectionString = builder.Configuration.GetConnectionString("Database") ?? Environment.GetEnvironmentVariable("DATABASE_URL") ?? @@ -15,8 +27,26 @@ var connectionString = builder.Services.AddApplication(); builder.Services.AddInfrastructure(connectionString); +// Resolving UserManager also activates Identity's default token providers. +// Bootstrap never issues a reset token, so the migrator uses a process-local provider; +// the API remains the sole owner of the persisted, certificate-protected key ring. +builder.Services.AddDataProtection().UseEphemeralDataProtectionProvider(); using var host = builder.Build(); await using var scope = host.Services.CreateAsyncScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); await dbContext.Database.MigrateAsync(); + +if (bootstrapOptions is not null) +{ + var bootstrapper = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + var result = await bootstrapper.BootstrapAsync(bootstrapOptions); + Console.WriteLine($"Platform administrator '{result.Email}' was created and must change the temporary password and enroll MFA at first sign-in."); +} + +static string RequiredBootstrapSetting(IConfiguration configuration, string key) +{ + return configuration[key] is { } value && !string.IsNullOrWhiteSpace(value) + ? value + : throw new InvalidOperationException($"{key} is required with --bootstrap-platform-admin."); +} diff --git a/Tiku.Domain/Identity/User.cs b/Tiku.Domain/Identity/User.cs index 69c98e0..c1c1b9e 100644 --- a/Tiku.Domain/Identity/User.cs +++ b/Tiku.Domain/Identity/User.cs @@ -1,20 +1,35 @@ using System.Text.Json; +using Microsoft.AspNetCore.Identity; using Tiku.Domain.Common; namespace Tiku.Domain.Identity; -public sealed class User : AuditableEntity +public sealed class User : IdentityUser, IHasTimestamps { + public User() + { + Id = Guid.NewGuid(); + SecurityStamp = Guid.NewGuid().ToString("N"); + ConcurrencyStamp = Guid.NewGuid().ToString("N"); + } + public string? LegacyId { get; set; } - public string? Username { get; set; } - public string? Email { get; set; } public string? Phone { get; set; } public string? Name { get; set; } public string? AvatarUrl { get; set; } public string PrimaryRole { get; set; } = "student"; public int Score { get; set; } public DateTimeOffset? LastSeenAt { get; set; } - public string? LegacyPasswordHash { get; set; } - public bool PasswordMigrationRequired { get; set; } + public UserStatus Status { get; set; } = UserStatus.Active; + public bool ForcePasswordChange { get; set; } public JsonElement RawProfile { get; set; } = JsonDefaults.Object(); + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public enum UserStatus +{ + Active, + Disabled, + Archived } diff --git a/Tiku.Domain/Identity/UserIdentity.cs b/Tiku.Domain/Identity/UserIdentity.cs index e268546..f587a25 100644 --- a/Tiku.Domain/Identity/UserIdentity.cs +++ b/Tiku.Domain/Identity/UserIdentity.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using Tiku.Domain.Common; namespace Tiku.Domain.Identity; @@ -12,5 +11,4 @@ public sealed class UserIdentity : AuditableEntity public string? OpenId { get; set; } public string? Phone { get; set; } public string? Email { get; set; } - public JsonElement SecretPayload { get; set; } = JsonDefaults.Object(); } diff --git a/Tiku.Domain/Tenancy/TenantMembership.cs b/Tiku.Domain/Tenancy/TenantMembership.cs index a1d472c..63e4904 100644 --- a/Tiku.Domain/Tenancy/TenantMembership.cs +++ b/Tiku.Domain/Tenancy/TenantMembership.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using Tiku.Domain.Common; namespace Tiku.Domain.Tenancy; @@ -6,16 +5,13 @@ namespace Tiku.Domain.Tenancy; public sealed class TenantMembership : AuditableTenantEntity { public Guid UserId { get; set; } - public Guid? RoleTemplateId { get; set; } public TenantRole Role { get; set; } = TenantRole.Student; public MembershipStatus Status { get; set; } = MembershipStatus.Active; - public JsonElement Permissions { get; set; } = JsonDefaults.Object(); public string? LegacyRole { get; set; } } public enum TenantRole { - PlatformAdmin, TenantOwner, TenantAdmin, TenantOperator, diff --git a/Tiku.Domain/Tenancy/TenantOperationsEntities.cs b/Tiku.Domain/Tenancy/TenantOperationsEntities.cs index 4f956da..ee2778a 100644 --- a/Tiku.Domain/Tenancy/TenantOperationsEntities.cs +++ b/Tiku.Domain/Tenancy/TenantOperationsEntities.cs @@ -47,9 +47,9 @@ public sealed class SmsVerificationCode : Entity, ITenantOwned public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; } -public sealed class AuthLoginEvent : Entity, ITenantOwned +public sealed class AuthLoginEvent : Entity { - public Guid TenantId { get; set; } + public Guid? TenantId { get; set; } public Guid? UserId { get; set; } public string Provider { get; set; } = string.Empty; public string? Identifier { get; set; } @@ -61,18 +61,46 @@ public sealed class AuthLoginEvent : Entity, ITenantOwned public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; } -public sealed class AuthSession : AuditableTenantEntity +public sealed class AuthSession : AuditableEntity { + public AuthRealm Realm { get; set; } = AuthRealm.Tenant; + public Guid? TenantId { get; set; } public Guid UserId { get; set; } + public Guid TokenFamilyId { get; set; } + public Guid? ParentSessionId { get; set; } + public Guid? ReplacedBySessionId { get; set; } public string TokenHash { get; set; } = string.Empty; + public string SecurityStamp { get; set; } = string.Empty; + public bool MfaSatisfied { get; set; } public string Provider { get; set; } = string.Empty; public DateTimeOffset ExpiresAt { get; set; } public DateTimeOffset? RevokedAt { get; set; } + public string? RevokedReason { get; set; } public string? IpAddress { get; set; } public string? UserAgent { get; set; } public JsonElement Metadata { get; set; } = JsonDefaults.Object(); } +public enum AuthRealm { Tenant, Platform } + +public sealed class AuthChallenge : Entity +{ + public Guid UserId { get; set; } + public AuthRealm Realm { get; set; } + public Guid? TenantId { get; set; } + public AuthChallengePurpose Purpose { get; set; } + public string TokenHash { get; set; } = string.Empty; + public string SecurityStamp { get; set; } = string.Empty; + public string Provider { get; set; } = string.Empty; + public DateTimeOffset ExpiresAt { get; set; } + public DateTimeOffset? ConsumedAt { get; set; } + public string? IpAddress { get; set; } + public string? UserAgent { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public enum AuthChallengePurpose { MfaEnrollment, MfaVerification, PasswordChange } + public sealed class SmsSendRateLimit : ITenantOwned { public Guid TenantId { get; set; } @@ -83,24 +111,6 @@ public sealed class SmsSendRateLimit : ITenantOwned public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; } -public sealed class TenantRoleTemplate : AuditableTenantEntity -{ - public string Code { get; set; } = string.Empty; - public string Name { get; set; } = string.Empty; - public string? Description { get; set; } - public TenantRole BaseRole { get; set; } = TenantRole.TenantOperator; - public TenantRoleTemplateStatus Status { get; set; } = TenantRoleTemplateStatus.Active; - public JsonElement Permissions { get; set; } = JsonDefaults.Object(); - public JsonElement MenuPermissions { get; set; } = JsonDefaults.Object(); - public JsonElement ModulePermissions { get; set; } = JsonDefaults.Object(); - public JsonElement FieldPermissions { get; set; } = JsonDefaults.Object(); - public JsonElement DataScope { get; set; } = JsonDefaults.Object(); - public bool IsSystem { get; set; } - public int SortOrder { get; set; } = 100; - public Guid? CreatedBy { get; set; } - public Guid? UpdatedBy { get; set; } -} - public sealed class TenantClass : AuditableTenantEntity { public Guid? RegionId { get; set; } @@ -165,7 +175,6 @@ public enum SmsPurpose { Login, BindPhone, ResetPassword } public enum SmsVerificationStatus { Pending, Sent, Verified, Expired, Blocked, Failed } public enum AuthLoginResult { Sent, Success, Failed, Blocked } public enum SmsRateLimitDimension { Tenant, Phone, Ip, Device } -public enum TenantRoleTemplateStatus { Active, Disabled, Archived } public enum TenantRecordStatus { Active, Disabled, Archived } public enum TenantClassMemberType { Student, Teacher, Assistant, HeadTeacher } public enum TenantClassMemberStatus { Active, Disabled, Removed } diff --git a/Tiku.Domain/Tiku.Domain.csproj b/Tiku.Domain/Tiku.Domain.csproj index b760144..c4a0ded 100644 --- a/Tiku.Domain/Tiku.Domain.csproj +++ b/Tiku.Domain/Tiku.Domain.csproj @@ -6,4 +6,8 @@ enable + + + + diff --git a/Tiku.Infrastructure/Auth/AuthService.cs b/Tiku.Infrastructure/Auth/AuthService.cs index 214678f..5c2c577 100644 --- a/Tiku.Infrastructure/Auth/AuthService.cs +++ b/Tiku.Infrastructure/Auth/AuthService.cs @@ -1,5 +1,9 @@ using System.Text.Json; +using System.Security.Cryptography; +using System.Text; using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity; +using Microsoft.IdentityModel.Tokens; using Tiku.Application.Auth; using Tiku.Application.Tenancy; using Tiku.Domain.Identity; @@ -10,9 +14,10 @@ namespace Tiku.Infrastructure.Auth; public sealed class AuthService( TikuDbContext dbContext, - IPasswordHasher passwordHasher, + SignInManager signInManager, + UserManager userManager, ISmsVerificationService smsVerificationService, - ISessionService sessionService, + IAuthSessionStore sessionStore, IWechatOAuthClient wechatOAuthClient, ITenantExternalProviderConfigService providerConfigService) : IAuthService { @@ -24,35 +29,38 @@ public sealed class AuthService( private static readonly string[] WechatMiniAppProviderAliases = ["wechat-miniapp", "wechat_miniapp", "wechat-mini", "wechatMiniapp"]; private static readonly string[] WechatIdentityProviders = ["wechat_web", "wechat-web", "wechat", "wechat-miniapp", "wechat_miniapp", "wechat-mini", "wechatMiniapp"]; - public async Task LoginWithPasswordAsync( + public async Task LoginWithPasswordAsync( PasswordLoginRequest request, CancellationToken cancellationToken = default) { - var phone = SmsCodeHashing.NormalizePhone(request.Phone); + var identifier = request.Phone.Trim(); + var normalizedEmail = userManager.NormalizeEmail(identifier); + var normalizedUserName = userManager.NormalizeName(identifier); var user = await dbContext.Users - .SingleOrDefaultAsync(entity => entity.Phone == phone, cancellationToken); - var identity = user is null - ? null - : await dbContext.UserIdentities - .SingleOrDefaultAsync( - entity => - entity.UserId == user.Id && - entity.Provider == PasswordProvider && - entity.ProviderSubject == phone, - cancellationToken); + .SingleOrDefaultAsync(entity => + entity.Phone == identifier || + entity.NormalizedEmail == normalizedEmail || + entity.NormalizedUserName == normalizedUserName, + cancellationToken); + var passwordResult = user is null || user.Status != UserStatus.Active + ? SignInResult.Failed + : await signInManager.CheckPasswordSignInAsync(user, request.Password, lockoutOnFailure: true); - if (user is null || - identity is null || - !TryGetPasswordHash(identity.SecretPayload, out var passwordHash) || - !passwordHasher.Verify(request.Password, passwordHash)) + if (!passwordResult.Succeeded) { + var loginResult = passwordResult.IsLockedOut + ? AuthLoginResult.Blocked + : AuthLoginResult.Failed; + var failureCode = passwordResult.IsLockedOut + ? "account_locked" + : "invalid_credentials"; await AddLoginEventAsync( request.TenantId, user?.Id, PasswordProvider, - phone, - AuthLoginResult.Failed, - "invalid_credentials", + identifier, + loginResult, + failureCode, request.IpAddress, request.UserAgent, cancellationToken); @@ -60,16 +68,17 @@ public sealed class AuthService( } return await CompleteSuccessfulLoginAsync( + request.Realm, request.TenantId, - user, + user!, PasswordProvider, - phone, + identifier, request.IpAddress, request.UserAgent, cancellationToken); } - public async Task LoginWithSmsAsync( + public async Task LoginWithSmsAsync( SmsLoginRequest request, CancellationToken cancellationToken = default) { @@ -79,8 +88,12 @@ public sealed class AuthService( try { + if (!request.TenantId.HasValue) + { + throw new InvalidCredentialsException("tenant_required_for_sms"); + } await smsVerificationService.VerifyCodeAsync( - request.TenantId, + request.TenantId.Value, phone, SmsPurpose.Login, request.Code, @@ -117,6 +130,7 @@ public sealed class AuthService( } return await CompleteSuccessfulLoginAsync( + request.Realm, request.TenantId, user, SmsProvider, @@ -126,7 +140,7 @@ public sealed class AuthService( cancellationToken); } - public Task LoginWithWechatWebAsync( + public Task LoginWithWechatWebAsync( WechatLoginRequest request, CancellationToken cancellationToken = default) { @@ -138,7 +152,7 @@ public sealed class AuthService( cancellationToken); } - public Task LoginWithWechatMiniAppAsync( + public Task LoginWithWechatMiniAppAsync( WechatLoginRequest request, CancellationToken cancellationToken = default) { @@ -154,90 +168,218 @@ public sealed class AuthService( RefreshSessionRequest request, CancellationToken cancellationToken = default) { - if (!sessionService.TryParseRefreshToken(request.RefreshToken, out var locator)) - { - throw new SessionRevokedException(); - } - - var tokenHash = sessionService.HashRefreshToken(request.RefreshToken); - var now = DateTimeOffset.UtcNow; - var session = await dbContext.AuthSessions - .SingleOrDefaultAsync(entity => - entity.Id == locator.SessionId && - entity.TenantId == locator.TenantId && - entity.TokenHash == tokenHash, - cancellationToken); - - if (session is null || session.RevokedAt is not null || session.ExpiresAt <= now) - { - throw new SessionRevokedException(); - } - - var user = await dbContext.Users.FindAsync([session.UserId], cancellationToken) - ?? throw new SessionRevokedException(); - var membership = await FindActiveMembershipAsync(session.TenantId, session.UserId, cancellationToken) - ?? throw new TenantAccessDeniedException(); - - session.RevokedAt = now; - await AddLoginEventAsync( - session.TenantId, - session.UserId, - "refresh", - user.Phone ?? user.Email, - AuthLoginResult.Success, - null, - request.IpAddress, - request.UserAgent, - cancellationToken); - - return await sessionService.IssueAsync( - user.Id, - user.Phone, - user.Email, - membership, - "refresh", - request.IpAddress, - request.UserAgent, - cancellationToken); + return await sessionStore.RotateAsync( + request.RefreshToken, request.IpAddress, request.UserAgent, cancellationToken); } public async Task LogoutAsync( LogoutSessionRequest request, CancellationToken cancellationToken = default) { - if (!sessionService.TryParseRefreshToken(request.RefreshToken, out var locator)) - { - return; - } - - var tokenHash = sessionService.HashRefreshToken(request.RefreshToken); - var session = await dbContext.AuthSessions - .SingleOrDefaultAsync(entity => - entity.Id == locator.SessionId && - entity.TenantId == locator.TenantId && - entity.TokenHash == tokenHash, - cancellationToken); - - if (session is null || session.RevokedAt is not null) - { - return; - } - - session.RevokedAt = DateTimeOffset.UtcNow; - await AddLoginEventAsync( - session.TenantId, - session.UserId, - "logout", - null, - AuthLoginResult.Success, - null, - null, - null, - cancellationToken); + await sessionStore.RevokeFamilyAsync(request.RefreshToken, "logout", cancellationToken); } - private async Task CompleteSuccessfulLoginAsync( - Guid tenantId, + public async Task LogoutAllAsync(Guid userId, CancellationToken cancellationToken = default) + { + var user = await userManager.FindByIdAsync(userId.ToString()) + ?? throw new InvalidCredentialsException(); + var stampResult = await userManager.UpdateSecurityStampAsync(user); + if (!stampResult.Succeeded) + { + throw new InvalidOperationException("Unable to update the user's security stamp."); + } + + await sessionStore.RevokeAllAsync(userId, "logout_all", cancellationToken); + } + + public async Task SetupTotpAsync( + MfaChallengeRequest request, + CancellationToken cancellationToken = default) + { + var challenge = await FindChallengeAsync( + request.ChallengeToken, AuthChallengePurpose.MfaEnrollment, cancellationToken); + var user = await userManager.FindByIdAsync(challenge.UserId.ToString()) + ?? throw new InvalidAuthChallengeException(); + var reset = await userManager.ResetAuthenticatorKeyAsync(user); + if (!reset.Succeeded) + { + throw new InvalidOperationException("Unable to initialize the authenticator key."); + } + + var key = await userManager.GetAuthenticatorKeyAsync(user) + ?? throw new InvalidOperationException("Authenticator key was not generated."); + challenge.SecurityStamp = user.SecurityStamp ?? string.Empty; + var account = user.Email ?? user.Phone ?? user.Id.ToString(); + var uri = $"otpauth://totp/{Uri.EscapeDataString("TIKU:" + account)}" + + $"?secret={Uri.EscapeDataString(key)}&issuer={Uri.EscapeDataString("TIKU")}&digits=6"; + await AddSecurityAuditAsync( + user.Id, challenge.TenantId, "auth.mfa.enrollment_setup", null, + request.IpAddress, request.UserAgent, cancellationToken); + return new MfaSetupResult(key, uri); + } + + public async Task ConfirmTotpAsync( + MfaChallengeRequest request, + CancellationToken cancellationToken = default) + { + var challenge = await FindChallengeAsync( + request.ChallengeToken, AuthChallengePurpose.MfaEnrollment, cancellationToken); + var user = await userManager.FindByIdAsync(challenge.UserId.ToString()) + ?? throw new InvalidAuthChallengeException(); + if (string.IsNullOrWhiteSpace(request.Code) || + !await userManager.VerifyTwoFactorTokenAsync( + user, TokenOptions.DefaultAuthenticatorProvider, NormalizeTotp(request.Code))) + { + await AddSecurityAuditAsync( + user.Id, challenge.TenantId, "auth.mfa.enrollment_denied", "invalid_code", + request.IpAddress, request.UserAgent, cancellationToken); + throw new InvalidCredentialsException("invalid_mfa_code"); + } + + var enabled = await userManager.SetTwoFactorEnabledAsync(user, true); + if (!enabled.Succeeded) + { + throw new InvalidOperationException("Unable to enable two-factor authentication."); + } + + await ConsumeChallengeAsync(challenge, cancellationToken); + await AddSecurityAuditAsync( + user.Id, challenge.TenantId, "auth.mfa.enrollment_confirmed", null, + request.IpAddress, request.UserAgent, cancellationToken); + var recoveryCodes = (await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10))?.ToArray() ?? []; + var authentication = await IssueFromChallengeAsync( + challenge, user, request.IpAddress, request.UserAgent, cancellationToken); + return new MfaConfirmResult(authentication, recoveryCodes); + } + + public async Task VerifyTotpAsync( + MfaChallengeRequest request, + CancellationToken cancellationToken = default) + { + var challenge = await FindChallengeAsync( + request.ChallengeToken, AuthChallengePurpose.MfaVerification, cancellationToken); + var user = await userManager.FindByIdAsync(challenge.UserId.ToString()) + ?? throw new InvalidAuthChallengeException(); + var recoveryCode = request.Code?.Trim(); + var totpCode = NormalizeTotp(request.Code); + var verifiedByTotp = !string.IsNullOrWhiteSpace(totpCode) && + await userManager.VerifyTwoFactorTokenAsync( + user, TokenOptions.DefaultAuthenticatorProvider, totpCode); + var verifiedByRecoveryCode = !verifiedByTotp && + !string.IsNullOrWhiteSpace(recoveryCode) && + (await userManager.RedeemTwoFactorRecoveryCodeAsync(user, recoveryCode)).Succeeded; + if (!verifiedByTotp && !verifiedByRecoveryCode) + { + await AddSecurityAuditAsync( + user.Id, challenge.TenantId, "auth.mfa.verification_denied", "invalid_code", + request.IpAddress, request.UserAgent, cancellationToken); + throw new InvalidCredentialsException("invalid_mfa_code"); + } + + await ConsumeChallengeAsync(challenge, cancellationToken); + await AddSecurityAuditAsync( + user.Id, challenge.TenantId, "auth.mfa.verified", + verifiedByRecoveryCode ? "recovery_code" : "totp", + request.IpAddress, request.UserAgent, cancellationToken); + return await IssueFromChallengeAsync( + challenge, user, request.IpAddress, request.UserAgent, cancellationToken); + } + + public async Task ChangeRequiredPasswordAsync( + PasswordChangeChallengeRequest request, + CancellationToken cancellationToken = default) + { + var challenge = await FindChallengeAsync( + request.ChallengeToken, AuthChallengePurpose.PasswordChange, cancellationToken); + var user = await userManager.FindByIdAsync(challenge.UserId.ToString()) + ?? throw new InvalidAuthChallengeException(); + var resetToken = await userManager.GeneratePasswordResetTokenAsync(user); + var reset = await userManager.ResetPasswordAsync(user, resetToken, request.NewPassword); + if (!reset.Succeeded) + { + throw new InvalidCredentialsException("invalid_new_password"); + } + + user.ForcePasswordChange = false; + var updated = await userManager.UpdateAsync(user); + if (!updated.Succeeded) + { + throw new InvalidOperationException("Unable to clear the password-change requirement."); + } + + await sessionStore.RevokeAllAsync(user.Id, "password_changed", cancellationToken); + await ConsumeChallengeAsync(challenge, cancellationToken); + await AddSecurityAuditAsync( + user.Id, challenge.TenantId, "auth.password.changed", null, + request.IpAddress, request.UserAgent, cancellationToken); + return await CompleteSuccessfulLoginAsync( + challenge.Realm, challenge.TenantId, user, challenge.Provider, user.Email ?? user.Phone ?? user.Id.ToString(), + request.IpAddress, request.UserAgent, cancellationToken); + } + + private async Task IssueFromChallengeAsync( + AuthChallenge challenge, + User user, + string? ipAddress, + string? userAgent, + CancellationToken cancellationToken) + { + if (!await HasBackendPermissionsAsync( + challenge.Realm, challenge.TenantId, user.Id, cancellationToken)) + { + throw new InvalidAuthChallengeException("backend_access_revoked"); + } + + Tenant? tenant = null; + TenantMembership? membership = null; + if (challenge.Realm == AuthRealm.Tenant && challenge.TenantId.HasValue) + { + tenant = await dbContext.Tenants.SingleOrDefaultAsync( + item => item.Id == challenge.TenantId.Value && item.Status == TenantStatus.Active, cancellationToken); + membership = await FindActiveMembershipAsync(challenge.TenantId.Value, user.Id, cancellationToken); + if (tenant is null || membership is null) + { + throw new TenantAccessDeniedException(); + } + } + + return await IssueAuthenticatedResultAsync( + user, challenge.Realm, tenant, membership, challenge.Provider, + mfaSatisfied: true, null, ipAddress, userAgent, cancellationToken); + } + + private async Task FindChallengeAsync( + string token, + AuthChallengePurpose purpose, + CancellationToken cancellationToken) + { + var tokenHash = HashChallengeToken(token); + var now = DateTimeOffset.UtcNow; + return await dbContext.AuthChallenges.SingleOrDefaultAsync( + item => item.TokenHash == tokenHash && item.Purpose == purpose && + item.ConsumedAt == null && item.ExpiresAt > now && + dbContext.Users.Any(user => + user.Id == item.UserId && user.Status == UserStatus.Active && + user.SecurityStamp == item.SecurityStamp), + cancellationToken) + ?? throw new InvalidAuthChallengeException(); + } + + private async Task ConsumeChallengeAsync(AuthChallenge challenge, CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + var consumed = await dbContext.AuthChallenges + .Where(item => item.Id == challenge.Id && item.ConsumedAt == null && item.ExpiresAt > now) + .ExecuteUpdateAsync(setters => setters.SetProperty(item => item.ConsumedAt, now), cancellationToken); + if (consumed != 1) + { + throw new InvalidAuthChallengeException(); + } + } + + private async Task CompleteSuccessfulLoginAsync( + AuthRealm realm, + Guid? tenantId, User user, string provider, string identifier, @@ -245,36 +387,96 @@ public sealed class AuthService( string? userAgent, CancellationToken cancellationToken) { - var membership = await FindActiveMembershipAsync(tenantId, user.Id, cancellationToken); - if (membership is null) + if (user.Status != UserStatus.Active) { await AddLoginEventAsync( - tenantId, - user.Id, - provider, - identifier, - AuthLoginResult.Failed, - "tenant_access_denied", - ipAddress, - userAgent, - cancellationToken); + tenantId, user.Id, provider, identifier, AuthLoginResult.Failed, + "user_disabled", ipAddress, userAgent, cancellationToken); + throw new InvalidCredentialsException(); + } + + TenantMembership? membership = null; + Tenant? tenant = null; + if (realm == AuthRealm.Tenant && tenantId.HasValue) + { + membership = await FindActiveMembershipAsync(tenantId.Value, user.Id, cancellationToken); + tenant = await dbContext.Tenants.SingleOrDefaultAsync( + item => item.Id == tenantId.Value && item.Status == TenantStatus.Active, cancellationToken); + if (membership is null || tenant is null) + { + await AddLoginEventAsync(tenantId, user.Id, provider, identifier, AuthLoginResult.Failed, + "tenant_access_denied", ipAddress, userAgent, cancellationToken); + throw new TenantAccessDeniedException(); + } + } + else if (realm == AuthRealm.Platform) + { + if (!await HasBackendPermissionsAsync(realm, tenantId, user.Id, cancellationToken)) + { + await AddLoginEventAsync( + null, user.Id, provider, identifier, AuthLoginResult.Failed, + "platform_access_denied", ipAddress, userAgent, cancellationToken); + throw new TenantAccessDeniedException(); + } + } + else + { throw new TenantAccessDeniedException(); } - var tenant = await dbContext.Tenants.FindAsync([tenantId], cancellationToken) - ?? throw new TenantAccessDeniedException(); - var tokens = await sessionService.IssueAsync( - user.Id, - user.Phone, - user.Email, - membership, - provider, - ipAddress, - userAgent, + if (user.ForcePasswordChange) + { + return await CreateChallengeResultAsync( + user, realm, tenantId, AuthChallengePurpose.PasswordChange, provider, + AuthenticationStatus.PasswordChangeRequired, ipAddress, userAgent, cancellationToken); + } + + var requiresMfa = await HasBackendPermissionsAsync(realm, tenantId, user.Id, cancellationToken); + if (requiresMfa) + { + var hasAuthenticator = user.TwoFactorEnabled && + !string.IsNullOrWhiteSpace(await userManager.GetAuthenticatorKeyAsync(user)); + return await CreateChallengeResultAsync( + user, realm, tenantId, + hasAuthenticator ? AuthChallengePurpose.MfaVerification : AuthChallengePurpose.MfaEnrollment, + provider, + hasAuthenticator ? AuthenticationStatus.MfaRequired : AuthenticationStatus.MfaEnrollmentRequired, + ipAddress, userAgent, cancellationToken); + } + + return await IssueAuthenticatedResultAsync( + user, realm, tenant, membership, provider, mfaSatisfied: false, + identifier, ipAddress, userAgent, cancellationToken); + } + + private async Task IssueAuthenticatedResultAsync( + User user, + AuthRealm realm, + Tenant? tenant, + TenantMembership? membership, + string provider, + bool mfaSatisfied, + string? identifier, + string? ipAddress, + string? userAgent, + CancellationToken cancellationToken) + { + var tokens = await sessionStore.IssueAsync( + new AuthSessionIssueRequest( + user.Id, + user.Phone, + user.Email, + user.SecurityStamp ?? string.Empty, + realm, + tenant?.Id, + provider, + mfaSatisfied, + ipAddress, + userAgent), cancellationToken); await AddLoginEventAsync( - tenantId, + tenant?.Id, user.Id, provider, identifier, @@ -284,28 +486,28 @@ public sealed class AuthService( userAgent, cancellationToken); - return new AuthenticatedUser( - user.Id, - user.Phone, - user.Email, - user.Name, - new TenantMembershipSummary( - tenant.Id, - tenant.Name, - membership.Role, - membership.Status), - tokens); + var tenantSummary = tenant is not null && membership is not null + ? new TenantMembershipSummary(tenant.Id, tenant.Name, membership.Role, membership.Status) + : null; + return new AuthenticationResult( + AuthenticationStatus.Authenticated, + new AuthenticatedUser(user.Id, user.Phone, user.Email, user.Name, realm, tenantSummary, tokens)); } - private async Task LoginWithWechatAsync( + private async Task LoginWithWechatAsync( WechatLoginRequest request, string provider, IReadOnlyList providerAliases, Func> exchangeCodeAsync, CancellationToken cancellationToken) { + if (request.Realm != AuthRealm.Tenant || !request.TenantId.HasValue) + { + throw new InvalidCredentialsException("tenant_realm_required_for_wechat"); + } + var config = await LoadWechatProviderOptionsAsync( - request.TenantId, + request.TenantId.Value, provider, providerAliases, cancellationToken); @@ -338,11 +540,12 @@ public sealed class AuthService( identity, cancellationToken); await EnsureTenantMembershipAsync( - request.TenantId, + request.TenantId.Value, user.Id, cancellationToken); return await CompleteSuccessfulLoginAsync( + request.Realm, request.TenantId, user, provider, @@ -438,7 +641,6 @@ public sealed class AuthService( existingIdentity.UserId = user.Id; existingIdentity.OpenId = wechatIdentity.OpenId; existingIdentity.UnionId = wechatIdentity.UnionId; - existingIdentity.SecretPayload = CreateWechatSecretPayload(appId, wechatIdentity); await dbContext.SaveChangesAsync(cancellationToken); return user; @@ -521,8 +723,111 @@ public sealed class AuthService( .FirstOrDefaultAsync(cancellationToken); } + private async Task CreateChallengeResultAsync( + User user, + AuthRealm realm, + Guid? tenantId, + AuthChallengePurpose purpose, + string provider, + AuthenticationStatus status, + string? ipAddress, + string? userAgent, + CancellationToken cancellationToken) + { + var realmCode = realm == AuthRealm.Tenant ? "t" : "p"; + var tenantCode = tenantId?.ToString("N") ?? "-"; + var rawToken = $"c1.{realmCode}.{tenantCode}.{Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(48))}"; + var expiresAt = DateTimeOffset.UtcNow.AddMinutes(5); + dbContext.AuthChallenges.Add(new AuthChallenge + { + UserId = user.Id, + Realm = realm, + TenantId = tenantId, + Purpose = purpose, + TokenHash = HashChallengeToken(rawToken), + SecurityStamp = user.SecurityStamp ?? string.Empty, + Provider = provider, + ExpiresAt = expiresAt, + IpAddress = ipAddress, + UserAgent = userAgent + }); + await dbContext.SaveChangesAsync(cancellationToken); + await AddSecurityAuditAsync( + user.Id, tenantId, "auth.challenge.issued", status.ToString(), + ipAddress, userAgent, cancellationToken); + return new AuthenticationResult(status, ChallengeToken: rawToken, ChallengeExpiresAt: expiresAt); + } + + private async Task HasBackendPermissionsAsync( + AuthRealm realm, + Guid? tenantId, + Guid userId, + CancellationToken cancellationToken) + { + if (realm == AuthRealm.Platform) + { + return await ( + from userRole in dbContext.PlatformBackendUserRoles + join role in dbContext.PlatformBackendRoles on userRole.RoleId equals role.Id + join binding in dbContext.PlatformBackendRolePermissions on role.Id equals binding.RoleId + join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code + where userRole.UserId == userId && + role.Status == Tiku.Domain.Operations.BackendRoleStatus.Active && + (permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Platform || + permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Both) + select permission.Id).AnyAsync(cancellationToken); + } + + if (!tenantId.HasValue) + { + return false; + } + + return await ( + from userRole in dbContext.TenantBackendUserRoles + join role in dbContext.TenantBackendRoles on userRole.RoleId equals role.Id + join binding in dbContext.TenantBackendRolePermissions on role.Id equals binding.RoleId + join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code + where userRole.TenantId == tenantId.Value && userRole.UserId == userId && + binding.TenantId == tenantId.Value && + role.Status == Tiku.Domain.Operations.BackendRoleStatus.Active && + (permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Tenant || + permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Both) + select permission.Id).AnyAsync(cancellationToken); + } + + private static string HashChallengeToken(string token) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token ?? string.Empty))).ToLowerInvariant(); + + private static string NormalizeTotp(string? code) => + (code ?? string.Empty).Replace(" ", string.Empty, StringComparison.Ordinal) + .Replace("-", string.Empty, StringComparison.Ordinal); + + private async Task AddSecurityAuditAsync( + Guid userId, + Guid? tenantId, + string action, + string? reason, + string? ipAddress, + string? userAgent, + CancellationToken cancellationToken) + { + dbContext.AuditLogs.Add(new Tiku.Domain.Operations.AuditLog + { + TenantId = tenantId, + ActorUserId = userId, + Action = action, + TargetType = "user", + TargetId = userId.ToString(), + Details = JsonSerializer.SerializeToElement(new { reason }), + IpAddress = ipAddress, + UserAgent = userAgent + }); + await dbContext.SaveChangesAsync(cancellationToken); + } + private async Task AddLoginEventAsync( - Guid tenantId, + Guid? tenantId, Guid? userId, string provider, string? identifier, @@ -547,15 +852,6 @@ public sealed class AuthService( await dbContext.SaveChangesAsync(cancellationToken); } - private static bool TryGetPasswordHash(JsonElement secretPayload, out string passwordHash) - { - passwordHash = string.Empty; - return secretPayload.ValueKind == JsonValueKind.Object && - secretPayload.TryGetProperty("passwordHash", out var property) && - property.ValueKind == JsonValueKind.String && - !string.IsNullOrWhiteSpace(passwordHash = property.GetString() ?? string.Empty); - } - private static string? GetJsonString(JsonElement element, params string[] names) { if (element.ValueKind != JsonValueKind.Object) @@ -578,19 +874,13 @@ public sealed class AuthService( private static JsonElement CreateWechatRawProfile(WechatIdentity identity) { - using var document = JsonDocument.Parse(identity.RawJson); - return document.RootElement.Clone(); + return JsonSerializer.SerializeToElement(new + { + openId = identity.OpenId, + unionId = identity.UnionId, + nickname = identity.Nickname, + avatarUrl = identity.AvatarUrl + }); } - private static JsonElement CreateWechatSecretPayload(string appId, WechatIdentity identity) - { - var payload = new - { - appId, - sessionKey = identity.SessionKey, - raw = JsonSerializer.Deserialize(identity.RawJson), - updatedAt = DateTimeOffset.UtcNow - }; - return JsonSerializer.SerializeToElement(payload); - } } diff --git a/Tiku.Infrastructure/Auth/AuthSessionStore.cs b/Tiku.Infrastructure/Auth/AuthSessionStore.cs new file mode 100644 index 0000000..611f763 --- /dev/null +++ b/Tiku.Infrastructure/Auth/AuthSessionStore.cs @@ -0,0 +1,353 @@ +using System.Security.Cryptography; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using Tiku.Application.Auth; +using Tiku.Application.Security; +using Tiku.Domain.Identity; +using Tiku.Domain.Operations; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Auth; + +public sealed class AuthSessionStore( + TikuDbContext dbContext, + ITokenService tokenService, + IOptions options) : IAuthSessionStore +{ + private readonly JwtOptions options = options.Value; + + public string GenerateRefreshToken(AuthRealm realm, Guid? tenantId, Guid sessionId) + { + var realmCode = realm == AuthRealm.Tenant ? "t" : "p"; + var tenant = tenantId?.ToString("N") ?? "-"; + return $"v2.{realmCode}.{tenant}.{sessionId:N}.{Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(64))}"; + } + + public bool TryParseRefreshToken(string refreshToken, out RefreshTokenLocator locator) + { + locator = default; + var parts = refreshToken?.Split('.', 5, StringSplitOptions.None) ?? []; + if (parts.Length != 5 || parts[0] != "v2" || parts[4].Length < 64 || + !Guid.TryParseExact(parts[3], "N", out var sessionId)) + { + return false; + } + + if (parts[1] == "p" && parts[2] == "-") + { + locator = new RefreshTokenLocator(AuthRealm.Platform, null, sessionId); + return true; + } + + if (parts[1] == "t" && Guid.TryParseExact(parts[2], "N", out var tenantId)) + { + locator = new RefreshTokenLocator(AuthRealm.Tenant, tenantId, sessionId); + return true; + } + + return false; + } + + public string HashRefreshToken(string refreshToken) => + Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(refreshToken))).ToLowerInvariant(); + + public async Task IssueAsync( + AuthSessionIssueRequest request, + CancellationToken cancellationToken = default) + { + ValidateRealm(request.Realm, request.TenantId); + var session = CreateSession(request, Guid.NewGuid()); + var refreshToken = GenerateRefreshToken(session.Realm, session.TenantId, session.Id); + session.TokenHash = HashRefreshToken(refreshToken); + dbContext.AuthSessions.Add(session); + await dbContext.SaveChangesAsync(cancellationToken); + return CreatePair(request, session, refreshToken); + } + + public async Task RotateAsync( + string refreshToken, + string? ipAddress, + string? userAgent, + CancellationToken cancellationToken = default) + { + if (!TryParseRefreshToken(refreshToken, out var locator)) + { + throw new SessionRevokedException(); + } + + var tokenHash = HashRefreshToken(refreshToken); + var now = DateTimeOffset.UtcNow; + await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + var current = await dbContext.AuthSessions.SingleOrDefaultAsync( + item => item.Id == locator.SessionId && item.Realm == locator.Realm && + item.TenantId == locator.TenantId && item.TokenHash == tokenHash, + cancellationToken); + if (current is null) + { + throw new SessionRevokedException(); + } + + if (current.RevokedAt.HasValue || current.ReplacedBySessionId.HasValue || current.ExpiresAt <= now) + { + await RevokeFamilyCoreAsync(current.TokenFamilyId, "refresh_token_reuse", now, cancellationToken); + await transaction.CommitAsync(cancellationToken); + throw new SessionRevokedException(); + } + + var user = await dbContext.Users.SingleOrDefaultAsync(item => item.Id == current.UserId, cancellationToken); + if (user is null || user.Status != UserStatus.Active || + !string.Equals(user.SecurityStamp, current.SecurityStamp, StringComparison.Ordinal)) + { + await RevokeFamilyCoreAsync(current.TokenFamilyId, "identity_state_changed", now, cancellationToken); + await transaction.CommitAsync(cancellationToken); + throw new SessionRevokedException(); + } + + try + { + await AssertRealmAccessAsync( + current.Realm, current.TenantId, current.UserId, current.MfaSatisfied, cancellationToken); + } + catch (TenantAccessDeniedException) + { + await RevokeFamilyCoreAsync(current.TokenFamilyId, "realm_access_revoked", now, cancellationToken); + await transaction.CommitAsync(cancellationToken); + throw new SessionRevokedException(); + } + var nextId = Guid.NewGuid(); + var updated = await dbContext.AuthSessions + .Where(item => item.Id == current.Id && item.RevokedAt == null && item.ReplacedBySessionId == null) + .ExecuteUpdateAsync(setters => setters + .SetProperty(item => item.RevokedAt, now) + .SetProperty(item => item.RevokedReason, "rotated") + .SetProperty(item => item.ReplacedBySessionId, nextId), cancellationToken); + if (updated != 1) + { + await RevokeFamilyCoreAsync(current.TokenFamilyId, "refresh_token_reuse", now, cancellationToken); + await transaction.CommitAsync(cancellationToken); + throw new SessionRevokedException(); + } + + var request = new AuthSessionIssueRequest( + user.Id, user.Phone, user.Email, user.SecurityStamp ?? string.Empty, + current.Realm, current.TenantId, "refresh", current.MfaSatisfied, + ipAddress, userAgent, current.TokenFamilyId, current.Id); + var next = CreateSession(request, nextId); + var nextToken = GenerateRefreshToken(next.Realm, next.TenantId, next.Id); + next.TokenHash = HashRefreshToken(nextToken); + dbContext.AuthSessions.Add(next); + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return CreatePair(request, next, nextToken); + } + + public async Task ValidateAccessSessionAsync( + Guid sessionId, + Guid userId, + AuthRealm realm, + Guid? tenantId, + CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + var session = await dbContext.AuthSessions.AsNoTracking().SingleOrDefaultAsync( + item => item.Id == sessionId && item.UserId == userId && item.Realm == realm && + item.TenantId == tenantId && item.RevokedAt == null && item.ExpiresAt > now, + cancellationToken); + if (session is null) + { + return null; + } + + var user = await dbContext.Users.AsNoTracking().SingleOrDefaultAsync(item => item.Id == userId, cancellationToken); + if (user is null || user.Status != UserStatus.Active || + !string.Equals(user.SecurityStamp, session.SecurityStamp, StringComparison.Ordinal)) + { + return null; + } + + try + { + await AssertRealmAccessAsync( + realm, tenantId, userId, session.MfaSatisfied, cancellationToken); + } + catch (TenantAccessDeniedException) + { + return null; + } + + return new AuthSessionValidationResult(userId, realm, tenantId, session.MfaSatisfied); + } + + public async Task RevokeFamilyAsync(string refreshToken, string reason, CancellationToken cancellationToken = default) + { + if (!TryParseRefreshToken(refreshToken, out var locator)) + { + return; + } + + var hash = HashRefreshToken(refreshToken); + var session = await dbContext.AuthSessions.AsNoTracking().SingleOrDefaultAsync( + item => item.Id == locator.SessionId && item.TokenHash == hash, cancellationToken); + if (session is not null) + { + await RevokeFamilyCoreAsync(session.TokenFamilyId, reason, DateTimeOffset.UtcNow, cancellationToken); + } + } + + public async Task RevokeAllAsync(Guid userId, string reason, CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + var count = await dbContext.AuthSessions.Where(item => item.UserId == userId && item.RevokedAt == null) + .ExecuteUpdateAsync(setters => setters + .SetProperty(item => item.RevokedAt, DateTimeOffset.UtcNow) + .SetProperty(item => item.RevokedReason, reason), cancellationToken); + if (count > 0) + { + dbContext.AuditLogs.Add(new AuditLog + { + ActorUserId = userId, + Action = "auth.sessions.revoked_all", + TargetType = "user", + TargetId = userId.ToString(), + Details = System.Text.Json.JsonSerializer.SerializeToElement(new { reason, count, revokedAt = now }) + }); + await dbContext.SaveChangesAsync(cancellationToken); + } + } + + public async Task RevokeRealmAsync( + Guid userId, + AuthRealm realm, + Guid? tenantId, + string reason, + CancellationToken cancellationToken = default) + { + ValidateRealm(realm, tenantId); + var now = DateTimeOffset.UtcNow; + var count = await dbContext.AuthSessions + .Where(item => item.UserId == userId && item.Realm == realm && item.TenantId == tenantId && item.RevokedAt == null) + .ExecuteUpdateAsync(setters => setters + .SetProperty(item => item.RevokedAt, now) + .SetProperty(item => item.RevokedReason, reason), cancellationToken); + if (count > 0) + { + dbContext.AuditLogs.Add(new AuditLog + { + TenantId = tenantId, + ActorUserId = userId, + Action = "auth.sessions.realm_revoked", + TargetType = "user", + TargetId = userId.ToString(), + Details = System.Text.Json.JsonSerializer.SerializeToElement(new { realm, reason, count, revokedAt = now }) + }); + await dbContext.SaveChangesAsync(cancellationToken); + } + } + + private AuthSession CreateSession(AuthSessionIssueRequest request, Guid sessionId) => new() + { + Id = sessionId, + Realm = request.Realm, + TenantId = request.TenantId, + UserId = request.UserId, + TokenFamilyId = request.TokenFamilyId ?? sessionId, + ParentSessionId = request.ParentSessionId, + SecurityStamp = request.SecurityStamp, + MfaSatisfied = request.MfaSatisfied, + Provider = request.Provider, + ExpiresAt = DateTimeOffset.UtcNow.AddDays(options.RefreshTokenDays), + IpAddress = request.IpAddress, + UserAgent = request.UserAgent + }; + + private AuthTokenPair CreatePair(AuthSessionIssueRequest request, AuthSession session, string refreshToken) + { + var access = tokenService.CreateAccessToken( + request.UserId, session.Id, request.Phone, request.Email, + request.Realm, request.TenantId, request.MfaSatisfied); + return new AuthTokenPair(access.Token, refreshToken, access.ExpiresAt, session.ExpiresAt); + } + + private async Task AssertRealmAccessAsync( + AuthRealm realm, + Guid? tenantId, + Guid userId, + bool mfaSatisfied, + CancellationToken cancellationToken) + { + if (realm == AuthRealm.Tenant && tenantId.HasValue) + { + var active = await dbContext.Tenants.AnyAsync(item => item.Id == tenantId && item.Status == TenantStatus.Active, cancellationToken) && + await dbContext.TenantMemberships.AnyAsync(item => item.TenantId == tenantId && item.UserId == userId && item.Status == MembershipStatus.Active, cancellationToken); + if (active && (!mfaSatisfied || await HasTenantBackendPermissionAsync(tenantId.Value, userId, cancellationToken))) + { + return; + } + } + else if (realm == AuthRealm.Platform) + { + var active = await ( + from userRole in dbContext.PlatformBackendUserRoles + join role in dbContext.PlatformBackendRoles on userRole.RoleId equals role.Id + join binding in dbContext.PlatformBackendRolePermissions on role.Id equals binding.RoleId + join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code + where userRole.UserId == userId && role.Status == BackendRoleStatus.Active && + (permission.Area == BackendPermissionArea.Platform || permission.Area == BackendPermissionArea.Both) + select permission.Id).AnyAsync(cancellationToken); + if (active) return; + } + + throw new TenantAccessDeniedException(); + } + + private Task HasTenantBackendPermissionAsync( + Guid tenantId, + Guid userId, + CancellationToken cancellationToken) => + (from userRole in dbContext.TenantBackendUserRoles + join role in dbContext.TenantBackendRoles on userRole.RoleId equals role.Id + join binding in dbContext.TenantBackendRolePermissions on role.Id equals binding.RoleId + join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code + where userRole.TenantId == tenantId && userRole.UserId == userId && + binding.TenantId == tenantId && role.Status == BackendRoleStatus.Active && + (permission.Area == BackendPermissionArea.Tenant || permission.Area == BackendPermissionArea.Both) + select permission.Id).AnyAsync(cancellationToken); + + private async Task RevokeFamilyCoreAsync(Guid familyId, string reason, DateTimeOffset now, CancellationToken cancellationToken) + { + var owner = await dbContext.AuthSessions.AsNoTracking() + .Where(item => item.TokenFamilyId == familyId) + .Select(item => new { item.UserId, item.TenantId }) + .FirstOrDefaultAsync(cancellationToken); + var count = await dbContext.AuthSessions.Where(item => item.TokenFamilyId == familyId && item.RevokedAt == null) + .ExecuteUpdateAsync(setters => setters + .SetProperty(item => item.RevokedAt, now) + .SetProperty(item => item.RevokedReason, reason), cancellationToken); + if (count > 0 && owner is not null) + { + dbContext.AuditLogs.Add(new AuditLog + { + TenantId = owner.TenantId, + ActorUserId = owner.UserId, + Action = "auth.session_family.revoked", + TargetType = "auth_session_family", + TargetId = familyId.ToString(), + Details = System.Text.Json.JsonSerializer.SerializeToElement(new { reason, count, revokedAt = now }) + }); + await dbContext.SaveChangesAsync(cancellationToken); + } + + return count; + } + + private static void ValidateRealm(AuthRealm realm, Guid? tenantId) + { + if ((realm == AuthRealm.Tenant) != tenantId.HasValue) + { + throw new ArgumentException("Tenant sessions require a tenant and platform sessions must not have one."); + } + } +} diff --git a/Tiku.Infrastructure/Auth/JwtKeyRing.cs b/Tiku.Infrastructure/Auth/JwtKeyRing.cs new file mode 100644 index 0000000..6d7d293 --- /dev/null +++ b/Tiku.Infrastructure/Auth/JwtKeyRing.cs @@ -0,0 +1,59 @@ +using System.Security.Cryptography; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using Tiku.Application.Security; + +namespace Tiku.Infrastructure.Auth; + +internal sealed class JwtKeyRing : IJwtKeyRing, IDisposable +{ + private readonly List keys = []; + + public JwtKeyRing(IOptions options) + { + var value = options.Value; + var signingRsa = RSA.Create(3072); + keys.Add(signingRsa); + if (!string.IsNullOrWhiteSpace(value.PrivateKeyPem)) + { + signingRsa.ImportFromPem(value.PrivateKeyPem); + } + + var signingKey = CreateKey(signingRsa, value.KeyId); + SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256); + + var validationKeys = new List { signingKey }; + foreach (var pair in value.PublicKeys.Where(pair => pair.Key != value.KeyId)) + { + var rsa = RSA.Create(); + rsa.ImportFromPem(pair.Value); + keys.Add(rsa); + validationKeys.Add(CreateKey(rsa, pair.Key)); + } + + ValidationKeys = validationKeys; + } + + public SigningCredentials SigningCredentials { get; } + public IReadOnlyCollection ValidationKeys { get; } + + private static RsaSecurityKey CreateKey(RSA rsa, string keyId) => new(rsa) + { + KeyId = keyId, + // IdentityModel caches signature providers globally by key identity. A key ring owns + // and disposes its RSA instances, so a provider retained by another in-process host + // could otherwise reference an RSA instance that has already been disposed. + CryptoProviderFactory = new CryptoProviderFactory + { + CacheSignatureProviders = false + } + }; + + public void Dispose() + { + foreach (var key in keys) + { + key.Dispose(); + } + } +} diff --git a/Tiku.Infrastructure/Auth/PasswordHasher.cs b/Tiku.Infrastructure/Auth/PasswordHasher.cs deleted file mode 100644 index 1723733..0000000 --- a/Tiku.Infrastructure/Auth/PasswordHasher.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Security.Cryptography; -using Tiku.Application.Auth; - -namespace Tiku.Infrastructure.Auth; - -public sealed class PasswordHasher : IPasswordHasher -{ - private const int SaltSize = 16; - private const int HashSize = 32; - private const int Iterations = 210_000; - private const string Prefix = "pbkdf2-sha256"; - - public string Hash(string password) - { - ArgumentException.ThrowIfNullOrWhiteSpace(password); - - var salt = RandomNumberGenerator.GetBytes(SaltSize); - var hash = Rfc2898DeriveBytes.Pbkdf2( - password, - salt, - Iterations, - HashAlgorithmName.SHA256, - HashSize); - - return string.Join( - '$', - Prefix, - Iterations.ToString(System.Globalization.CultureInfo.InvariantCulture), - Convert.ToBase64String(salt), - Convert.ToBase64String(hash)); - } - - public bool Verify(string password, string passwordHash) - { - if (string.IsNullOrWhiteSpace(password) || string.IsNullOrWhiteSpace(passwordHash)) - { - return false; - } - - var parts = passwordHash.Split('$'); - if (parts.Length != 4 || - !string.Equals(parts[0], Prefix, StringComparison.Ordinal) || - !int.TryParse(parts[1], out var iterations)) - { - return false; - } - - var salt = Convert.FromBase64String(parts[2]); - var expected = Convert.FromBase64String(parts[3]); - var actual = Rfc2898DeriveBytes.Pbkdf2( - password, - salt, - iterations, - HashAlgorithmName.SHA256, - expected.Length); - - return CryptographicOperations.FixedTimeEquals(actual, expected); - } -} diff --git a/Tiku.Infrastructure/Auth/SelfHostedIdentityProvider.cs b/Tiku.Infrastructure/Auth/SelfHostedIdentityProvider.cs index df0fe56..0c0b668 100644 --- a/Tiku.Infrastructure/Auth/SelfHostedIdentityProvider.cs +++ b/Tiku.Infrastructure/Auth/SelfHostedIdentityProvider.cs @@ -1,4 +1,5 @@ using Tiku.Application.Auth; +using Tiku.Domain.Tenancy; namespace Tiku.Infrastructure.Auth; @@ -13,6 +14,7 @@ internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IId { "password" => await authService.LoginWithPasswordAsync( new PasswordLoginRequest( + AuthRealm.Tenant, request.TenantId, request.Identifier, request.Secret, @@ -21,6 +23,7 @@ internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IId cancellationToken), "sms" => await authService.LoginWithSmsAsync( new SmsLoginRequest( + AuthRealm.Tenant, request.TenantId, request.Identifier, request.Secret, @@ -29,6 +32,7 @@ internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IId cancellationToken), "wechat_web" => await authService.LoginWithWechatWebAsync( new WechatLoginRequest( + AuthRealm.Tenant, request.TenantId, request.Secret, request.IpAddress, @@ -36,6 +40,7 @@ internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IId cancellationToken), "wechat_miniapp" => await authService.LoginWithWechatMiniAppAsync( new WechatLoginRequest( + AuthRealm.Tenant, request.TenantId, request.Secret, request.IpAddress, @@ -44,11 +49,13 @@ internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IId _ => throw new AuthProviderNotConfiguredException(provider) }; + var user = authenticated.User ?? throw new InvalidAuthChallengeException("interactive_authentication_required"); + return new IdentityProviderResult( provider, - authenticated.UserId.ToString("N"), - authenticated.Phone, - authenticated.Email, - authenticated.Name); + user.UserId.ToString("N"), + user.Phone, + user.Email, + user.Name); } } diff --git a/Tiku.Infrastructure/Auth/SessionService.cs b/Tiku.Infrastructure/Auth/SessionService.cs deleted file mode 100644 index 4ca895c..0000000 --- a/Tiku.Infrastructure/Auth/SessionService.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System.Security.Cryptography; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Tokens; -using Tiku.Application.Auth; -using Tiku.Application.Security; -using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; - -namespace Tiku.Infrastructure.Auth; - -public sealed class SessionService( - TikuDbContext dbContext, - ITokenService tokenService, - IOptions options) : ISessionService -{ - private readonly JwtOptions options = options.Value; - - public string GenerateRefreshToken(Guid tenantId, Guid sessionId) - { - return $"v1.{tenantId:N}.{sessionId:N}.{Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(64))}"; - } - - public bool TryParseRefreshToken(string refreshToken, out RefreshTokenLocator locator) - { - locator = default; - if (string.IsNullOrWhiteSpace(refreshToken)) - { - return false; - } - - var parts = refreshToken.Split('.', 4, StringSplitOptions.None); - if (parts.Length != 4 || parts[0] != "v1" || parts[3].Length < 32 || - !Guid.TryParseExact(parts[1], "N", out var tenantId) || - !Guid.TryParseExact(parts[2], "N", out var sessionId)) - { - return false; - } - - locator = new RefreshTokenLocator(tenantId, sessionId); - return true; - } - - public string HashRefreshToken(string refreshToken) - { - var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(refreshToken)); - return Convert.ToHexString(hash).ToLowerInvariant(); - } - - public async Task IssueAsync( - Guid userId, - string? phone, - string? email, - TenantMembership membership, - string provider, - string? ipAddress, - string? userAgent, - CancellationToken cancellationToken = default) - { - var session = new AuthSession - { - Id = Guid.NewGuid(), - TenantId = membership.TenantId, - UserId = userId, - TokenHash = string.Empty, - Provider = provider, - ExpiresAt = DateTimeOffset.UtcNow.AddDays(options.RefreshTokenDays), - IpAddress = ipAddress, - UserAgent = userAgent - }; - var refreshToken = GenerateRefreshToken(session.TenantId, session.Id); - session.TokenHash = HashRefreshToken(refreshToken); - - dbContext.AuthSessions.Add(session); - await dbContext.SaveChangesAsync(cancellationToken); - - var accessToken = tokenService.CreateAccessToken( - userId, - session.Id, - phone, - email, - membership); - - return new AuthTokenPair( - accessToken.Token, - refreshToken, - accessToken.ExpiresAt, - session.ExpiresAt); - } -} diff --git a/Tiku.Infrastructure/Auth/SmsCodeHashing.cs b/Tiku.Infrastructure/Auth/SmsCodeHashing.cs index c50964e..f903d20 100644 --- a/Tiku.Infrastructure/Auth/SmsCodeHashing.cs +++ b/Tiku.Infrastructure/Auth/SmsCodeHashing.cs @@ -6,10 +6,29 @@ namespace Tiku.Infrastructure.Auth; public static class SmsCodeHashing { - public static string Hash(Guid tenantId, string phone, SmsPurpose purpose, string code) + public static string Hash( + Guid tenantId, + string phone, + SmsPurpose purpose, + string code, + string pepper) { + ArgumentException.ThrowIfNullOrWhiteSpace(pepper); + var normalized = $"{tenantId:N}:{NormalizePhone(phone)}:{purpose}:{code.Trim()}"; - var hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalized)); + var hash = HMACSHA256.HashData( + Encoding.UTF8.GetBytes(pepper), + Encoding.UTF8.GetBytes(normalized)); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + public static string HashScope(string value, string pepper) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pepper); + + var hash = HMACSHA256.HashData( + Encoding.UTF8.GetBytes(pepper), + Encoding.UTF8.GetBytes(value.Trim().ToLowerInvariant())); return Convert.ToHexString(hash).ToLowerInvariant(); } diff --git a/Tiku.Infrastructure/Auth/SmsVerificationService.cs b/Tiku.Infrastructure/Auth/SmsVerificationService.cs index 2d92438..96b69bf 100644 --- a/Tiku.Infrastructure/Auth/SmsVerificationService.cs +++ b/Tiku.Infrastructure/Auth/SmsVerificationService.cs @@ -1,6 +1,10 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; using Microsoft.EntityFrameworkCore; -using Tiku.Domain.Common; +using Microsoft.Extensions.Options; using Tiku.Application.Auth; +using Tiku.Domain.Common; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; @@ -8,44 +12,33 @@ namespace Tiku.Infrastructure.Auth; public sealed class SmsVerificationService( TikuDbContext dbContext, - ISmsProvider smsProvider) : ISmsVerificationService + ISmsProvider smsProvider, + IOptions securityOptions) : ISmsVerificationService { - private const int MaxPhoneRequestsPerHour = 5; private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(10); + private static readonly SemaphoreSlim InMemoryRateLimitLock = new(1, 1); + private readonly SmsSecurityOptions options = securityOptions.Value; public async Task CreateCodeAsync( SendSmsCodeRequest request, CancellationToken cancellationToken = default) { + EnsureValidOptions(); + var phone = SmsCodeHashing.NormalizePhone(request.Phone); - var bucketStart = TruncateToHour(DateTimeOffset.UtcNow); - var scopeHash = SmsCodeHashing.Hash(request.TenantId, phone, request.Purpose, "phone-bucket"); - var rateLimit = await dbContext.SmsSendRateLimits.FindAsync( - [request.TenantId, SmsRateLimitDimension.Phone, scopeHash, bucketStart], - cancellationToken); + var now = DateTimeOffset.UtcNow; + await ConsumeRateLimitsAsync(request, phone, now, cancellationToken); - if (rateLimit is null) - { - rateLimit = new SmsSendRateLimit - { - TenantId = request.TenantId, - Dimension = SmsRateLimitDimension.Phone, - ScopeHash = scopeHash, - BucketStart = bucketStart - }; - dbContext.SmsSendRateLimits.Add(rateLimit); - } + var code = RandomNumberGenerator + .GetInt32(100000, 1000000) + .ToString(CultureInfo.InvariantCulture); + var codeHash = SmsCodeHashing.Hash( + request.TenantId, + phone, + request.Purpose, + code, + options.CodePepper); - if (rateLimit.RequestCount >= MaxPhoneRequestsPerHour) - { - throw new SmsRateLimitedException(); - } - - rateLimit.RequestCount++; - rateLimit.UpdatedAt = DateTimeOffset.UtcNow; - - var code = Random.Shared.Next(100000, 999999).ToString(System.Globalization.CultureInfo.InvariantCulture); - var codeHash = SmsCodeHashing.Hash(request.TenantId, phone, request.Purpose, code); SmsProviderSendResult sendResult; try { @@ -69,18 +62,38 @@ public sealed class SmsVerificationService( CodeHash = codeHash, Provider = "failed", Status = SmsVerificationStatus.Failed, - ExpiresAt = DateTimeOffset.UtcNow, + ExpiresAt = now, IpAddress = request.IpAddress, UserAgent = request.UserAgent, Metadata = JsonDefaults.Object() }); + dbContext.AuthLoginEvents.Add(new AuthLoginEvent + { + TenantId = request.TenantId, + Provider = "sms", + Identifier = phone, + Result = AuthLoginResult.Failed, + FailureCode = "sms_provider_send_failed", + IpAddress = request.IpAddress, + UserAgent = request.UserAgent + }); await dbContext.SaveChangesAsync(cancellationToken); throw exception is SmsProviderException ? exception - : new SmsProviderException("SMS provider failed to send the verification code.", "sms_provider_send_failed", exception); + : new SmsProviderException( + "SMS provider failed to send the verification code.", + "sms_provider_send_failed", + exception); } + await ExpirePreviousCodesAsync( + request.TenantId, + phone, + request.Purpose, + now, + cancellationToken); + var verification = new SmsVerificationCode { TenantId = request.TenantId, @@ -89,12 +102,29 @@ public sealed class SmsVerificationService( CodeHash = codeHash, Provider = sendResult.Provider, Status = SmsVerificationStatus.Sent, - ExpiresAt = DateTimeOffset.UtcNow.Add(CodeLifetime), + ExpiresAt = now.Add(CodeLifetime), IpAddress = request.IpAddress, UserAgent = request.UserAgent }; dbContext.SmsVerificationCodes.Add(verification); + dbContext.AuthLoginEvents.Add(new AuthLoginEvent + { + TenantId = request.TenantId, + Provider = "sms", + Identifier = phone, + Result = AuthLoginResult.Sent, + IpAddress = request.IpAddress, + UserAgent = request.UserAgent, + Metadata = JsonSerializer.SerializeToElement(new + { + verificationId = verification.Id, + sendResult.Provider, + sendResult.Status, + sendResult.MessageId, + request.DeviceId + }) + }); await dbContext.SaveChangesAsync(cancellationToken); return new SmsSendResult(verification.Id, verification.ExpiresAt); @@ -107,35 +137,346 @@ public sealed class SmsVerificationService( string code, CancellationToken cancellationToken = default) { + EnsureValidOptions(); + var normalizedPhone = SmsCodeHashing.NormalizePhone(phone); var now = DateTimeOffset.UtcNow; - var codeHash = SmsCodeHashing.Hash(tenantId, normalizedPhone, purpose, code); + var codeHash = SmsCodeHashing.Hash( + tenantId, + normalizedPhone, + purpose, + code, + options.CodePepper); var verification = await dbContext.SmsVerificationCodes + .AsNoTracking() .Where(entity => entity.TenantId == tenantId && entity.Phone == normalizedPhone && entity.Purpose == purpose && - entity.ConsumedAt == null) + entity.ConsumedAt == null && + entity.Status == SmsVerificationStatus.Sent) .OrderByDescending(entity => entity.CreatedAt) .FirstOrDefaultAsync(cancellationToken); - if (verification is null || - verification.ExpiresAt <= now || - verification.Status != SmsVerificationStatus.Sent) + if (verification is null) { throw new InvalidCredentialsException("invalid_sms_code"); } - verification.Attempts++; - if (!string.Equals(verification.CodeHash, codeHash, StringComparison.Ordinal)) + if (verification.ExpiresAt <= now) { - await dbContext.SaveChangesAsync(cancellationToken); + await MarkExpiredAsync(verification.Id, now, cancellationToken); throw new InvalidCredentialsException("invalid_sms_code"); } + if (HashesMatch(verification.CodeHash, codeHash)) + { + var consumed = await TryConsumeAsync(verification.Id, now, cancellationToken); + if (consumed) + { + return; + } + + throw new InvalidCredentialsException("invalid_sms_code"); + } + + await RecordFailedAttemptAsync(verification.Id, now, cancellationToken); + throw new InvalidCredentialsException("invalid_sms_code"); + } + + private async Task ConsumeRateLimitsAsync( + SendSmsCodeRequest request, + string phone, + DateTimeOffset now, + CancellationToken cancellationToken) + { + var limits = BuildRateLimits(request, phone); + var bucketStart = TruncateToHour(now); + + if (!dbContext.Database.IsRelational()) + { + await ConsumeInMemoryRateLimitsAsync(limits, request.TenantId, bucketStart, now, cancellationToken); + return; + } + + await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + foreach (var limit in limits) + { + var dimension = ToSnakeCase(limit.Dimension); + var affected = await dbContext.Database.ExecuteSqlInterpolatedAsync($$""" + INSERT INTO sms_send_rate_limits + (tenant_id, dimension, scope_hash, bucket_start, request_count, updated_at) + VALUES + ({{request.TenantId}}, {{dimension}}, {{limit.ScopeHash}}, {{bucketStart}}, 1, {{now}}) + ON CONFLICT (tenant_id, dimension, scope_hash, bucket_start) + DO UPDATE SET + request_count = sms_send_rate_limits.request_count + 1, + updated_at = EXCLUDED.updated_at + WHERE sms_send_rate_limits.request_count < {{limit.Maximum}} + """, cancellationToken); + + if (affected == 0) + { + await transaction.RollbackAsync(cancellationToken); + throw new SmsRateLimitedException(); + } + } + + await transaction.CommitAsync(cancellationToken); + } + + private async Task ConsumeInMemoryRateLimitsAsync( + IReadOnlyCollection limits, + Guid tenantId, + DateTimeOffset bucketStart, + DateTimeOffset now, + CancellationToken cancellationToken) + { + await InMemoryRateLimitLock.WaitAsync(cancellationToken); + try + { + var counters = new List<(RateLimitSpec Limit, SmsSendRateLimit? Counter)>(); + foreach (var limit in limits) + { + var counter = await dbContext.SmsSendRateLimits.FindAsync( + [tenantId, limit.Dimension, limit.ScopeHash, bucketStart], + cancellationToken); + if (counter?.RequestCount >= limit.Maximum) + { + throw new SmsRateLimitedException(); + } + + counters.Add((limit, counter)); + } + + foreach (var (limit, existingCounter) in counters) + { + var counter = existingCounter; + if (counter is null) + { + counter = new SmsSendRateLimit + { + TenantId = tenantId, + Dimension = limit.Dimension, + ScopeHash = limit.ScopeHash, + BucketStart = bucketStart + }; + dbContext.SmsSendRateLimits.Add(counter); + } + + counter.RequestCount++; + counter.UpdatedAt = now; + } + + await dbContext.SaveChangesAsync(cancellationToken); + } + finally + { + InMemoryRateLimitLock.Release(); + } + } + + private IReadOnlyList BuildRateLimits(SendSmsCodeRequest request, string phone) + { + var limits = new List + { + CreateLimit(SmsRateLimitDimension.Tenant, $"tenant:{request.TenantId:N}", options.TenantRequestsPerHour), + CreateLimit(SmsRateLimitDimension.Phone, $"phone:{request.TenantId:N}:{phone}", options.PhoneRequestsPerHour) + }; + + if (!string.IsNullOrWhiteSpace(request.IpAddress)) + { + limits.Add(CreateLimit( + SmsRateLimitDimension.Ip, + $"ip:{request.IpAddress.Trim()}", + options.IpRequestsPerHour)); + } + + var deviceKey = string.IsNullOrWhiteSpace(request.DeviceId) + ? request.UserAgent + : request.DeviceId; + if (!string.IsNullOrWhiteSpace(deviceKey)) + { + limits.Add(CreateLimit( + SmsRateLimitDimension.Device, + $"device:{deviceKey.Trim()}", + options.DeviceRequestsPerHour)); + } + + return limits; + } + + private RateLimitSpec CreateLimit(SmsRateLimitDimension dimension, string scope, int maximum) + { + return new RateLimitSpec( + dimension, + SmsCodeHashing.HashScope(scope, options.CodePepper), + maximum); + } + + private async Task ExpirePreviousCodesAsync( + Guid tenantId, + string phone, + SmsPurpose purpose, + DateTimeOffset now, + CancellationToken cancellationToken) + { + var query = dbContext.SmsVerificationCodes.Where(entity => + entity.TenantId == tenantId && + entity.Phone == phone && + entity.Purpose == purpose && + entity.ConsumedAt == null && + (entity.Status == SmsVerificationStatus.Pending || + entity.Status == SmsVerificationStatus.Sent)); + + if (dbContext.Database.IsRelational()) + { + await query.ExecuteUpdateAsync( + setters => setters + .SetProperty(entity => entity.Status, SmsVerificationStatus.Expired) + .SetProperty(entity => entity.ExpiresAt, now), + cancellationToken); + return; + } + + foreach (var verification in await query.ToListAsync(cancellationToken)) + { + verification.Status = SmsVerificationStatus.Expired; + verification.ExpiresAt = now; + } + } + + private async Task MarkExpiredAsync(Guid id, DateTimeOffset now, CancellationToken cancellationToken) + { + if (dbContext.Database.IsRelational()) + { + await dbContext.SmsVerificationCodes + .Where(entity => + entity.Id == id && + entity.Status == SmsVerificationStatus.Sent && + entity.ConsumedAt == null && + entity.ExpiresAt <= now) + .ExecuteUpdateAsync( + setters => setters.SetProperty(entity => entity.Status, SmsVerificationStatus.Expired), + cancellationToken); + return; + } + + var verification = await dbContext.SmsVerificationCodes.FindAsync([id], cancellationToken); + if (verification is not null && + verification.Status == SmsVerificationStatus.Sent && + verification.ConsumedAt is null && + verification.ExpiresAt <= now) + { + verification.Status = SmsVerificationStatus.Expired; + await dbContext.SaveChangesAsync(cancellationToken); + } + } + + private async Task TryConsumeAsync(Guid id, DateTimeOffset now, CancellationToken cancellationToken) + { + if (dbContext.Database.IsRelational()) + { + var affected = await dbContext.SmsVerificationCodes + .Where(entity => + entity.Id == id && + entity.Status == SmsVerificationStatus.Sent && + entity.ConsumedAt == null && + entity.ExpiresAt > now && + entity.Attempts < options.MaxVerificationAttempts) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(entity => entity.Status, SmsVerificationStatus.Verified) + .SetProperty(entity => entity.ConsumedAt, now), + cancellationToken); + return affected == 1; + } + + var verification = await dbContext.SmsVerificationCodes.FindAsync([id], cancellationToken); + if (verification is null || + verification.Status != SmsVerificationStatus.Sent || + verification.ConsumedAt is not null || + verification.ExpiresAt <= now || + verification.Attempts >= options.MaxVerificationAttempts) + { + return false; + } + verification.Status = SmsVerificationStatus.Verified; verification.ConsumedAt = now; await dbContext.SaveChangesAsync(cancellationToken); + return true; + } + + private async Task RecordFailedAttemptAsync(Guid id, DateTimeOffset now, CancellationToken cancellationToken) + { + if (dbContext.Database.IsRelational()) + { + await dbContext.SmsVerificationCodes + .Where(entity => + entity.Id == id && + entity.Status == SmsVerificationStatus.Sent && + entity.ConsumedAt == null && + entity.ExpiresAt > now && + entity.Attempts < options.MaxVerificationAttempts) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(entity => entity.Attempts, entity => entity.Attempts + 1) + .SetProperty( + entity => entity.Status, + entity => entity.Attempts + 1 >= options.MaxVerificationAttempts + ? SmsVerificationStatus.Blocked + : SmsVerificationStatus.Sent), + cancellationToken); + return; + } + + var verification = await dbContext.SmsVerificationCodes.FindAsync([id], cancellationToken); + if (verification is null || + verification.Status != SmsVerificationStatus.Sent || + verification.ConsumedAt is not null || + verification.ExpiresAt <= now || + verification.Attempts >= options.MaxVerificationAttempts) + { + return; + } + + verification.Attempts++; + if (verification.Attempts >= options.MaxVerificationAttempts) + { + verification.Status = SmsVerificationStatus.Blocked; + } + + await dbContext.SaveChangesAsync(cancellationToken); + } + + private void EnsureValidOptions() + { + if (!SmsSecurityOptions.BeValid(options)) + { + throw new InvalidOperationException( + $"{SmsSecurityOptions.SectionName} must contain a pepper of at least 32 characters, " + + "exactly five verification attempts, and positive rate limits."); + } + } + + private static bool HashesMatch(string expected, string actual) + { + try + { + return CryptographicOperations.FixedTimeEquals( + Convert.FromHexString(expected), + Convert.FromHexString(actual)); + } + catch (FormatException) + { + return false; + } + } + + private static string ToSnakeCase(SmsRateLimitDimension dimension) + { + return dimension.ToString().ToLowerInvariant(); } private static DateTimeOffset TruncateToHour(DateTimeOffset value) @@ -149,4 +490,9 @@ public sealed class SmsVerificationService( 0, value.Offset); } + + private sealed record RateLimitSpec( + SmsRateLimitDimension Dimension, + string ScopeHash, + int Maximum); } diff --git a/Tiku.Infrastructure/Auth/TokenService.cs b/Tiku.Infrastructure/Auth/TokenService.cs index 417fb47..dfa229b 100644 --- a/Tiku.Infrastructure/Auth/TokenService.cs +++ b/Tiku.Infrastructure/Auth/TokenService.cs @@ -1,15 +1,13 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; -using System.Text; using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Tokens; using Tiku.Application.Auth; using Tiku.Application.Security; using Tiku.Domain.Tenancy; namespace Tiku.Infrastructure.Auth; -public sealed class TokenService(IOptions options) : ITokenService +public sealed class TokenService(IOptions options, IJwtKeyRing keyRing) : ITokenService { private readonly JwtOptions options = options.Value; @@ -18,17 +16,30 @@ public sealed class TokenService(IOptions options) : ITokenService Guid sessionId, string? phone, string? email, - TenantMembership membership) + AuthRealm realm, + Guid? tenantId, + bool mfaSatisfied) { var expiresAt = DateTimeOffset.UtcNow.AddMinutes(options.AccessTokenMinutes); var claims = new List { - new(TikuClaimTypes.UserId, userId.ToString()), + new(JwtRegisteredClaimNames.Sub, userId.ToString()), new(TikuClaimTypes.SessionId, sessionId.ToString()), - new(TikuClaimTypes.TenantId, membership.TenantId.ToString()), - new(TikuClaimTypes.TenantRole, membership.Role.ToString()) + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")), + new(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64), + new(TikuClaimTypes.Realm, realm.ToString().ToLowerInvariant()) }; + if (tenantId.HasValue) + { + claims.Add(new Claim(TikuClaimTypes.TenantId, tenantId.Value.ToString())); + } + + if (mfaSatisfied) + { + claims.Add(new Claim(TikuClaimTypes.Mfa, "mfa")); + } + if (!string.IsNullOrWhiteSpace(phone)) { claims.Add(new Claim(TikuClaimTypes.Phone, phone)); @@ -39,15 +50,12 @@ public sealed class TokenService(IOptions options) : ITokenService claims.Add(new Claim(TikuClaimTypes.Email, email)); } - var credentials = new SigningCredentials( - new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.SigningKey)), - SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( options.Issuer, options.Audience, claims, expires: expiresAt.UtcDateTime, - signingCredentials: credentials); + signingCredentials: keyRing.SigningCredentials); return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt); } diff --git a/Tiku.Infrastructure/Backoffice/BackofficeService.cs b/Tiku.Infrastructure/Backoffice/BackofficeService.cs index aaf3a44..c2a501f 100644 --- a/Tiku.Infrastructure/Backoffice/BackofficeService.cs +++ b/Tiku.Infrastructure/Backoffice/BackofficeService.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Backoffice; +using Tiku.Application.Security; using Tiku.Domain.Common; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; @@ -14,19 +15,23 @@ internal sealed class BackofficeService( { private static readonly BuiltinPermission[] BuiltinPermissions = [ - new("tenant:dashboard:view", "租户总览", BackendPermissionArea.Tenant, "tenant_dashboard"), - new("tenant:staff:manage", "租户员工管理", BackendPermissionArea.Tenant, "tenant_staff"), - new("tenant:role:manage", "租户角色权限管理", BackendPermissionArea.Tenant, "tenant_staff"), - new("tenant:student:manage", "学生与班级管理", BackendPermissionArea.Tenant, "tenant_student"), - new("tenant:content:manage", "租户内容管理", BackendPermissionArea.Tenant, "tenant_content"), - new("tenant:provider:manage", "租户外部服务配置", BackendPermissionArea.Tenant, "tenant_provider"), - new("tenant:commerce:operate", "租户交易运营", BackendPermissionArea.Tenant, "tenant_commerce"), - new("platform:dashboard:view", "平台总览", BackendPermissionArea.Platform, "platform_dashboard"), - new("platform:tenant:manage", "平台租户管理", BackendPermissionArea.Platform, "platform_tenant"), - new("platform:staff:manage", "平台员工管理", BackendPermissionArea.Platform, "platform_staff"), - new("platform:role:manage", "平台角色权限管理", BackendPermissionArea.Platform, "platform_staff"), - new("platform:question-bank:manage", "平台公共题库运营", BackendPermissionArea.Platform, "platform_content"), - new("platform:audit:view", "平台审计查询", BackendPermissionArea.Platform, "platform_audit"), + new(BackendPermissions.TenantDashboardView, "租户总览", BackendPermissionArea.Tenant, "tenant_dashboard"), + new(BackendPermissions.TenantStaffManage, "租户员工管理", BackendPermissionArea.Tenant, "tenant_staff"), + new(BackendPermissions.TenantRoleManage, "租户角色权限管理", BackendPermissionArea.Tenant, "tenant_staff"), + new(BackendPermissions.TenantStudentManage, "学生与班级管理", BackendPermissionArea.Tenant, "tenant_student"), + new(BackendPermissions.TenantContentManage, "租户内容管理", BackendPermissionArea.Tenant, "tenant_content"), + new(BackendPermissions.TenantSettingsManage, "租户设置管理", BackendPermissionArea.Tenant, "tenant_settings"), + new(BackendPermissions.TenantProviderManage, "租户外部服务配置", BackendPermissionArea.Tenant, "tenant_provider"), + new(BackendPermissions.TenantCommerceOperate, "租户交易运营", BackendPermissionArea.Tenant, "tenant_commerce"), + new(BackendPermissions.TenantCrmManage, "租户客户管理", BackendPermissionArea.Tenant, "tenant_crm"), + new(BackendPermissions.TenantCommissionManage, "租户佣金管理", BackendPermissionArea.Tenant, "tenant_commission"), + new(BackendPermissions.TenantJobManage, "租户任务管理", BackendPermissionArea.Tenant, "tenant_job"), + new(BackendPermissions.PlatformDashboardView, "平台总览", BackendPermissionArea.Platform, "platform_dashboard"), + new(BackendPermissions.PlatformTenantManage, "平台租户管理", BackendPermissionArea.Platform, "platform_tenant"), + new(BackendPermissions.PlatformStaffManage, "平台员工管理", BackendPermissionArea.Platform, "platform_staff"), + new(BackendPermissions.PlatformRoleManage, "平台角色权限管理", BackendPermissionArea.Platform, "platform_staff"), + new(BackendPermissions.PlatformQuestionBankManage, "平台公共题库运营", BackendPermissionArea.Platform, "platform_content"), + new(BackendPermissions.PlatformAuditView, "平台审计查询", BackendPermissionArea.Platform, "platform_audit"), new("commerce:refund:approve", "退款审核", BackendPermissionArea.Both, "commerce"), new("commerce:reconciliation:manage", "对账管理", BackendPermissionArea.Both, "commerce"), new("commerce:adjustment:manage", "调账管理", BackendPermissionArea.Both, "commerce") @@ -47,6 +52,43 @@ internal sealed class BackofficeService( new("platform.audit", null, "平台审计", BackendPermissionArea.Platform, "/platform/audit", "platform:audit:view", 50) ]; + public async Task GetTenantUiBootstrapAsync( + CurrentAccessSnapshot access, + CancellationToken cancellationToken = default) + { + if (!access.IsUserActive || !access.IsCurrentTenantMember || + access.UserId is null || access.TenantId is null) + { + throw new BackofficeException("Tenant backoffice access is denied.", "tenant_access_denied"); + } + + await EnsureCatalogAsync(cancellationToken); + var permissionCodes = access.TenantPermissions.Order(StringComparer.Ordinal).ToArray(); + var menus = await LoadEffectiveMenusAsync( + BackendPermissionArea.Tenant, + permissionCodes, + cancellationToken); + return new BackofficeUiBootstrap(permissionCodes, menus); + } + + public async Task GetPlatformUiBootstrapAsync( + CurrentAccessSnapshot access, + CancellationToken cancellationToken = default) + { + if (!access.IsUserActive || access.UserId is null || access.PlatformPermissions.Count == 0) + { + throw new BackofficeException("Platform backoffice access is denied.", "platform_access_denied"); + } + + await EnsureCatalogAsync(cancellationToken); + var permissionCodes = access.PlatformPermissions.Order(StringComparer.Ordinal).ToArray(); + var menus = await LoadEffectiveMenusAsync( + BackendPermissionArea.Platform, + permissionCodes, + cancellationToken); + return new BackofficeUiBootstrap(permissionCodes, menus); + } + public async Task GetTenantBootstrapAsync( BackofficeActor actor, CancellationToken cancellationToken = default) @@ -193,6 +235,21 @@ internal sealed class BackofficeService( { var tenantId = RequireTenantAdmin(actor); var roleIds = command.RoleIds.Distinct().ToArray(); + var ownerRoleId = await dbContext.TenantBackendRoles + .Where(item => item.TenantId == tenantId && item.Code == "tenant_owner" && item.IsSystem) + .Select(item => (Guid?)item.Id) + .SingleOrDefaultAsync(cancellationToken); + var isActiveOwner = await dbContext.TenantMemberships.AnyAsync( + item => item.TenantId == tenantId && + item.UserId == command.UserId && + item.Role == TenantRole.TenantOwner && + item.Status == MembershipStatus.Active, + cancellationToken); + if (isActiveOwner && ownerRoleId.HasValue && !roleIds.Contains(ownerRoleId.Value)) + { + throw new BackofficeException("Tenant owner system role cannot be removed.", "system_role_locked"); + } + var count = await dbContext.TenantBackendRoles.CountAsync( item => item.TenantId == tenantId && roleIds.Contains(item.Id) && item.Status == BackendRoleStatus.Active, cancellationToken); @@ -329,6 +386,21 @@ internal sealed class BackofficeService( } } + private async Task LoadEffectiveMenusAsync( + BackendPermissionArea area, + IReadOnlyCollection permissionCodes, + CancellationToken cancellationToken) + { + var codes = permissionCodes.ToArray(); + var menus = await dbContext.BackendMenus.AsNoTracking() + .Where(item => item.IsActive && item.Area == area && + (item.PermissionCode == null || codes.Contains(item.PermissionCode))) + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Code) + .ToArrayAsync(cancellationToken); + return menus.Select(ToMenuItem).ToArray(); + } + private async Task ValidateMenuCodesAsync(string[] codes, BackendPermissionArea area, CancellationToken cancellationToken) { var count = await dbContext.BackendMenus.CountAsync( diff --git a/Tiku.Infrastructure/Bootstrap/PlatformAdminBootstrapper.cs b/Tiku.Infrastructure/Bootstrap/PlatformAdminBootstrapper.cs new file mode 100644 index 0000000..5f5d04c --- /dev/null +++ b/Tiku.Infrastructure/Bootstrap/PlatformAdminBootstrapper.cs @@ -0,0 +1,161 @@ +using System.Data; +using System.Text.Json; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using Tiku.Application.Security; +using Tiku.Domain.Identity; +using Tiku.Domain.Operations; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Bootstrap; + +public sealed record PlatformAdminBootstrapOptions( + string Email, + string TemporaryPassword, + string? DisplayName = null); + +public sealed record PlatformAdminBootstrapResult(Guid UserId, Guid RoleId, string Email); + +public sealed class PlatformAdminBootstrapper( + TikuDbContext dbContext, + UserManager userManager) +{ + public const string SuperAdminRoleCode = "platform_super_admin"; + + public async Task BootstrapAsync( + PlatformAdminBootstrapOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + var email = options.Email.Trim(); + if (email.Length == 0) + { + throw new ArgumentException("Platform administrator email is required.", nameof(options)); + } + + if (string.IsNullOrWhiteSpace(options.TemporaryPassword)) + { + throw new ArgumentException("Platform administrator temporary password is required.", nameof(options)); + } + + IDbContextTransaction? transaction = null; + if (dbContext.Database.IsRelational()) + { + transaction = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); + } + + await using (transaction) + { + var existingAdministrator = await ( + from binding in dbContext.PlatformBackendUserRoles.AsNoTracking() + join boundRole in dbContext.PlatformBackendRoles.AsNoTracking() on binding.RoleId equals boundRole.Id + join boundUser in dbContext.Users.AsNoTracking() on binding.UserId equals boundUser.Id + where boundRole.Status == BackendRoleStatus.Active && boundUser.Status == UserStatus.Active + select boundUser.Id) + .AnyAsync(cancellationToken); + if (existingAdministrator) + { + throw new PlatformAdminBootstrapException( + "A platform administrator already exists. Bootstrap is a one-time operation.", + "platform_admin_already_exists"); + } + + var normalizedEmail = userManager.NormalizeEmail(email); + if (await dbContext.Users.AsNoTracking().AnyAsync( + user => user.NormalizedEmail == normalizedEmail || user.NormalizedUserName == normalizedEmail, + cancellationToken)) + { + throw new PlatformAdminBootstrapException( + "The bootstrap email is already assigned to a user.", + "bootstrap_user_already_exists"); + } + + var user = new User + { + Email = email, + UserName = email, + Name = string.IsNullOrWhiteSpace(options.DisplayName) ? "Platform Administrator" : options.DisplayName.Trim(), + EmailConfirmed = true, + Status = UserStatus.Active, + ForcePasswordChange = true, + TwoFactorEnabled = false + }; + var createResult = await userManager.CreateAsync(user, options.TemporaryPassword); + if (!createResult.Succeeded) + { + var errors = string.Join(", ", createResult.Errors.Select(error => $"{error.Code}: {error.Description}")); + throw new PlatformAdminBootstrapException( + $"Platform administrator could not be created: {errors}", + "bootstrap_user_invalid"); + } + + var role = new PlatformBackendRole + { + Code = SuperAdminRoleCode, + Name = "Platform Super Administrator", + Description = "Built-in role with all platform permissions. Created by the one-time bootstrap command.", + Status = BackendRoleStatus.Active, + IsSystem = true + }; + dbContext.PlatformBackendRoles.Add(role); + + var platformPermissionCodes = BackendPermissions.Platform.ToArray(); + var existingPermissionCodes = await dbContext.BackendPermissions + .Where(permission => platformPermissionCodes.Contains(permission.Code)) + .Select(permission => permission.Code) + .ToHashSetAsync(StringComparer.Ordinal, cancellationToken); + foreach (var permissionCode in platformPermissionCodes.Where(code => !existingPermissionCodes.Contains(code))) + { + dbContext.BackendPermissions.Add(new BackendPermission + { + Code = permissionCode, + Name = permissionCode, + Area = BackendPermissionArea.Platform, + Module = "platform", + Description = "Built-in platform permission.", + IsSystem = true + }); + } + + dbContext.PlatformBackendRolePermissions.AddRange( + platformPermissionCodes.Select(permissionCode => new PlatformBackendRolePermission + { + RoleId = role.Id, + PermissionCode = permissionCode + })); + dbContext.PlatformBackendUserRoles.Add(new PlatformBackendUserRole + { + UserId = user.Id, + RoleId = role.Id + }); + dbContext.AuditLogs.Add(new AuditLog + { + ActorUserId = user.Id, + Action = "platform.bootstrap_admin.created", + TargetType = "users", + TargetId = user.Id.ToString(), + Details = JsonSerializer.SerializeToElement(new + { + user.Email, + RoleCode = SuperAdminRoleCode, + ForcePasswordChange = true, + MfaEnrollmentRequired = true + }) + }); + + await dbContext.SaveChangesAsync(cancellationToken); + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + + return new PlatformAdminBootstrapResult(user.Id, role.Id, email); + } + } +} + +public sealed class PlatformAdminBootstrapException(string message, string code) : InvalidOperationException(message) +{ + public string Code { get; } = code; +} diff --git a/Tiku.Infrastructure/Commerce/CommerceAdminService.cs b/Tiku.Infrastructure/Commerce/CommerceAdminService.cs index 51d593a..a55b82c 100644 --- a/Tiku.Infrastructure/Commerce/CommerceAdminService.cs +++ b/Tiku.Infrastructure/Commerce/CommerceAdminService.cs @@ -3,18 +3,21 @@ using System.Security.Cryptography; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; +using Tiku.Application.Security; using Tiku.Application.Tenancy; using Tiku.Domain.Catalog; using Tiku.Domain.Commerce; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Commerce; internal sealed class CommerceAdminService( TikuDbContext dbContext, ITenantSecretProtector tenantSecretProtector, - ITenantExternalProviderConfigService providerConfigService) : ICommerceAdminService + ITenantExternalProviderConfigService providerConfigService, + ICurrentAccessContext currentAccessContext) : ICommerceAdminService { public async Task> GetPaymentAccountsAsync( CommerceAdminActor actor, @@ -105,8 +108,14 @@ internal sealed class CommerceAdminService( CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); var orders = dbContext.Orders.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId); + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + item => item.UserId == actor.UserId, + item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); if (!string.IsNullOrWhiteSpace(query.Status)) { orders = orders.Where(item => item.Status == ParseOrderStatus(query.Status)); @@ -125,8 +134,16 @@ internal sealed class CommerceAdminService( CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var scopedOrders = dbContext.Orders.AsNoTracking() + .Where(order => order.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + order => order.UserId == actor.UserId, + order => order.RegionId.HasValue && regionIds.Contains(order.RegionId.Value)); var payments = from payment in dbContext.Payments.AsNoTracking() - join order in dbContext.Orders.AsNoTracking() + join order in scopedOrders on new { payment.TenantId, payment.OrderId } equals new { order.TenantId, OrderId = order.Id } where payment.TenantId == actor.TenantId select new { payment, order.OrderNo }; @@ -568,8 +585,19 @@ internal sealed class CommerceAdminService( CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); var refunds = dbContext.CommerceRefundRequests.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId); + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order => + order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId), + item => dbContext.Orders.Any(order => + order.TenantId == actor.TenantId && + order.Id == item.OrderId && + order.RegionId.HasValue && + regionIds.Contains(order.RegionId.Value))); if (!string.IsNullOrWhiteSpace(query.Status)) { refunds = refunds.Where(item => item.Status == ParseRefundStatus(query.Status)); @@ -588,9 +616,16 @@ internal sealed class CommerceAdminService( CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var order = await dbContext.Orders.SingleOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.Id == command.OrderId, - cancellationToken) ?? throw new CommerceException("Order was not found.", "order_not_found"); + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var order = await dbContext.Orders + .Where(item => item.TenantId == actor.TenantId && item.Id == command.OrderId) + .ApplyDataScope( + scope, + item => item.UserId == actor.UserId, + item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)) + .SingleOrDefaultAsync(cancellationToken) + ?? throw new CommerceException("Order was not found.", "order_not_found"); if (order.Status is not (OrderStatus.Paid or OrderStatus.PartiallyRefunded)) { throw new CommerceException("Only paid orders can be refunded.", "order_not_refundable"); @@ -639,9 +674,21 @@ internal sealed class CommerceAdminService( CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var refund = await dbContext.CommerceRefundRequests.SingleOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.Id == command.RefundRequestId, - cancellationToken) ?? throw new CommerceException("Refund request was not found.", "refund_not_found"); + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var refund = await dbContext.CommerceRefundRequests + .Where(item => item.TenantId == actor.TenantId && item.Id == command.RefundRequestId) + .ApplyDataScope( + scope, + item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order => + order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId), + item => dbContext.Orders.Any(order => + order.TenantId == actor.TenantId && + order.Id == item.OrderId && + order.RegionId.HasValue && + regionIds.Contains(order.RegionId.Value))) + .SingleOrDefaultAsync(cancellationToken) + ?? throw new CommerceException("Refund request was not found.", "refund_not_found"); var fromStatus = refund.Status; if (!IsAllowedRefundTransition(fromStatus, command.Status)) { @@ -687,6 +734,25 @@ internal sealed class CommerceAdminService( CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var refundExists = await dbContext.CommerceRefundRequests + .Where(item => item.TenantId == actor.TenantId && item.Id == refundRequestId) + .ApplyDataScope( + scope, + item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order => + order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId), + item => dbContext.Orders.Any(order => + order.TenantId == actor.TenantId && + order.Id == item.OrderId && + order.RegionId.HasValue && + regionIds.Contains(order.RegionId.Value))) + .AnyAsync(cancellationToken); + if (!refundExists) + { + throw new CommerceException("Refund request was not found.", "refund_not_found"); + } + var items = await dbContext.CommerceRefundEvents.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.RefundRequestId == refundRequestId) .OrderBy(item => item.CreatedAt) @@ -807,20 +873,24 @@ internal sealed class CommerceAdminService( private async Task AssertAdminAsync(CommerceAdminActor actor, CancellationToken cancellationToken) { - var isAdmin = await dbContext.TenantMemberships.AnyAsync(item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.Status == MembershipStatus.Active && - (item.Role == TenantRole.PlatformAdmin || - item.Role == TenantRole.TenantOwner || - item.Role == TenantRole.TenantAdmin), - cancellationToken); - if (!isAdmin) + var access = await currentAccessContext.GetAsync(cancellationToken); + if (!access.IsCurrentTenantMember || + access.UserId != actor.UserId || + access.TenantId != actor.TenantId || + !access.HasTenantPermission(BackendPermissions.TenantCommerceOperate)) { throw new CommerceException("Tenant admin access is required.", "tenant_admin_access_denied"); } } + private async Task RequireDataScopeAsync( + CommerceAdminActor actor, + CancellationToken cancellationToken) + { + await AssertAdminAsync(actor, cancellationToken); + return (await currentAccessContext.GetAsync(cancellationToken)).DataScope; + } + private static TenantPaymentProviderItem ToPaymentAccountItem(TenantExternalProviderItem item) => new( item.Id, diff --git a/Tiku.Infrastructure/Content/ContentManagementService.cs b/Tiku.Infrastructure/Content/ContentManagementService.cs index 198091a..b28c5b7 100644 --- a/Tiku.Infrastructure/Content/ContentManagementService.cs +++ b/Tiku.Infrastructure/Content/ContentManagementService.cs @@ -4,17 +4,20 @@ using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; using Tiku.Application.Content; using Tiku.Application.QuestionBanks; +using Tiku.Application.Security; using Tiku.Domain.Catalog; using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.QuestionBanks; using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; public sealed class ContentManagementService( TikuDbContext dbContext, - IQuestionReferenceService questionReferenceService) : IContentManagementService + IQuestionReferenceService questionReferenceService, + ICurrentAccessContext currentAccessContext) : IContentManagementService { private const int DefaultLimit = 100; private const int MaxLimit = 1000; @@ -24,9 +27,15 @@ public sealed class ContentManagementService( ContentManagementFilter filter, CancellationToken cancellationToken = default) { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); var query = dbContext.ContentEntries .AsNoTracking() - .Where(entry => entry.TenantId == actor.TenantId); + .Where(entry => entry.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + entry => entry.CreatedBy == actor.UserId, + entry => entry.RegionId.HasValue && regionIds.Contains(entry.RegionId.Value)); if (!filter.IncludeInactive) { @@ -67,6 +76,7 @@ public sealed class ContentManagementService( UpsertContentEntryCommand command, CancellationToken cancellationToken = default) { + var scope = await RequireDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken); @@ -81,6 +91,21 @@ public sealed class ContentManagementService( cancellationToken); var isNew = entry is null; + if (command.Id.HasValue && (entry is null || entry.Id != command.Id.Value)) + { + throw new ContentManagementException("Content entry was not found.", "entry_not_found"); + } + + if (entry is not null && !scope.AllowsResource(actor.UserId, entry.CreatedBy, entry.RegionId)) + { + throw new ContentManagementException("Content entry was not found.", "entry_not_found"); + } + + if (entry is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId)) + { + throw new ContentManagementException("Content entry was not found.", "entry_not_found"); + } + entry ??= new ContentEntry { Id = command.Id ?? Guid.NewGuid(), @@ -117,14 +142,21 @@ public sealed class ContentManagementService( ContentManagementFilter filter, CancellationToken cancellationToken = default) { + var scope = await RequireDataScopeAsync(actor, cancellationToken); if (!filter.EntryId.HasValue) { throw new ContentManagementException("entryId is required.", "entry_id_required"); } + await AssertEntryAsync(actor, scope, filter.EntryId, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); var query = dbContext.ContentNodes .AsNoTracking() - .Where(node => node.TenantId == actor.TenantId && node.EntryId == filter.EntryId.Value); + .Where(node => node.TenantId == actor.TenantId && node.EntryId == filter.EntryId.Value) + .ApplyDataScope( + scope, + node => node.CreatedBy == actor.UserId, + node => node.RegionId.HasValue && regionIds.Contains(node.RegionId.Value)); if (!filter.IncludeInactive) { @@ -178,9 +210,11 @@ public sealed class ContentManagementService( UpsertContentNodeCommand command, CancellationToken cancellationToken = default) { + var scope = await RequireDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); - await AssertEntryAsync(actor.TenantId, command.EntryId, cancellationToken); + await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken); await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken); + await AssertNodeAsync(actor, scope, command.ParentId, cancellationToken); var nodeKey = Normalize(command.NodeKey) ?? Normalize(command.Id?.ToString("N")) ?? @@ -193,6 +227,21 @@ public sealed class ContentManagementService( cancellationToken); var isNew = node is null; + if (command.Id.HasValue && (node is null || node.Id != command.Id.Value)) + { + throw new ContentManagementException("Content node was not found.", "node_not_found"); + } + + if (node is not null && !scope.AllowsResource(actor.UserId, node.CreatedBy, node.RegionId)) + { + throw new ContentManagementException("Content node was not found.", "node_not_found"); + } + + if (node is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId)) + { + throw new ContentManagementException("Content node was not found.", "node_not_found"); + } + node ??= new ContentNode { Id = command.Id ?? Guid.NewGuid(), @@ -246,9 +295,15 @@ public sealed class ContentManagementService( ContentManagementFilter filter, CancellationToken cancellationToken = default) { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); var query = dbContext.QuestionCollections .AsNoTracking() - .Where(collection => collection.TenantId == actor.TenantId); + .Where(collection => collection.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + collection => collection.CreatedBy == actor.UserId, + collection => collection.RegionId.HasValue && regionIds.Contains(collection.RegionId.Value)); if (!filter.IncludeInactive) { @@ -296,10 +351,11 @@ public sealed class ContentManagementService( UpsertQuestionCollectionCommand command, CancellationToken cancellationToken = default) { + var scope = await RequireDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken); - await AssertEntryAsync(actor.TenantId, command.EntryId, cancellationToken); - await AssertNodeAsync(actor.TenantId, command.NodeId, cancellationToken); + await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken); + await AssertNodeAsync(actor, scope, command.NodeId, cancellationToken); await AssertReferenceAsync(actor.TenantId, command.SubjectId, "subject_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.CategoryId, "category_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.QuestionBankId, "question_bank_not_found", cancellationToken); @@ -312,6 +368,21 @@ public sealed class ContentManagementService( cancellationToken); var isNew = collection is null; + if (command.Id.HasValue && (collection is null || collection.Id != command.Id.Value)) + { + throw new ContentManagementException("Collection was not found.", "collection_not_found"); + } + + if (collection is not null && !scope.AllowsResource(actor.UserId, collection.CreatedBy, collection.RegionId)) + { + throw new ContentManagementException("Collection was not found.", "collection_not_found"); + } + + if (collection is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId)) + { + throw new ContentManagementException("Collection was not found.", "collection_not_found"); + } + collection ??= new QuestionCollection { Id = command.Id ?? Guid.NewGuid(), @@ -352,9 +423,15 @@ public sealed class ContentManagementService( ReplaceCollectionItemsCommand command, CancellationToken cancellationToken = default) { - var collection = await dbContext.QuestionCollections.SingleOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.Id == command.CollectionId, - cancellationToken); + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var collection = await dbContext.QuestionCollections + .Where(item => item.TenantId == actor.TenantId && item.Id == command.CollectionId) + .ApplyDataScope( + scope, + item => item.CreatedBy == actor.UserId, + item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)) + .SingleOrDefaultAsync(cancellationToken); if (collection is null) { @@ -409,9 +486,15 @@ public sealed class ContentManagementService( ContentManagementFilter filter, CancellationToken cancellationToken = default) { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); var query = dbContext.PracticeBlueprints .AsNoTracking() - .Where(blueprint => blueprint.TenantId == actor.TenantId); + .Where(blueprint => blueprint.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + blueprint => blueprint.CreatedBy == actor.UserId, + blueprint => blueprint.RegionId.HasValue && regionIds.Contains(blueprint.RegionId.Value)); if (!filter.IncludeInactive) { @@ -464,10 +547,11 @@ public sealed class ContentManagementService( UpsertPracticeBlueprintCommand command, CancellationToken cancellationToken = default) { + var scope = await RequireDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken); - await AssertEntryAsync(actor.TenantId, command.EntryId, cancellationToken); - await AssertNodeAsync(actor.TenantId, command.NodeId, cancellationToken); + await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken); + await AssertNodeAsync(actor, scope, command.NodeId, cancellationToken); await AssertReferenceAsync(actor.TenantId, command.CollectionId, "collection_not_found", cancellationToken); var blueprint = await ResolveEntityByIdOrLegacyAsync( @@ -478,6 +562,21 @@ public sealed class ContentManagementService( cancellationToken); var isNew = blueprint is null; + if (command.Id.HasValue && (blueprint is null || blueprint.Id != command.Id.Value)) + { + throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found"); + } + + if (blueprint is not null && !scope.AllowsResource(actor.UserId, blueprint.CreatedBy, blueprint.RegionId)) + { + throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found"); + } + + if (blueprint is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId)) + { + throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found"); + } + blueprint ??= new PracticeBlueprint { Id = command.Id ?? Guid.NewGuid(), @@ -584,11 +683,74 @@ public sealed class ContentManagementService( await AssertReferenceAsync(tenantId, entryId, "entry_not_found", cancellationToken); } + private async Task AssertEntryAsync( + ContentManagementActor actor, + CurrentDataScope scope, + Guid? entryId, + CancellationToken cancellationToken) + { + if (!entryId.HasValue) + { + return; + } + + var regionIds = scope.RegionIds.ToArray(); + var exists = await dbContext.ContentEntries + .Where(entry => entry.TenantId == actor.TenantId && entry.Id == entryId.Value) + .ApplyDataScope( + scope, + entry => entry.CreatedBy == actor.UserId, + entry => entry.RegionId.HasValue && regionIds.Contains(entry.RegionId.Value)) + .AnyAsync(cancellationToken); + if (!exists) + { + throw new ContentManagementException("Content entry was not found.", "entry_not_found"); + } + } + private async Task AssertNodeAsync(Guid tenantId, Guid? nodeId, CancellationToken cancellationToken) { await AssertReferenceAsync(tenantId, nodeId, "node_not_found", cancellationToken); } + private async Task AssertNodeAsync( + ContentManagementActor actor, + CurrentDataScope scope, + Guid? nodeId, + CancellationToken cancellationToken) + { + if (!nodeId.HasValue) + { + return; + } + + var regionIds = scope.RegionIds.ToArray(); + var exists = await dbContext.ContentNodes + .Where(node => node.TenantId == actor.TenantId && node.Id == nodeId.Value) + .ApplyDataScope( + scope, + node => node.CreatedBy == actor.UserId, + node => node.RegionId.HasValue && regionIds.Contains(node.RegionId.Value)) + .AnyAsync(cancellationToken); + if (!exists) + { + throw new ContentManagementException("Content node was not found.", "node_not_found"); + } + } + + private async Task RequireDataScopeAsync( + ContentManagementActor actor, + CancellationToken cancellationToken) + { + var access = await currentAccessContext.GetAsync(cancellationToken); + if (!access.IsCurrentTenantMember || access.UserId != actor.UserId || access.TenantId != actor.TenantId) + { + throw new ContentManagementException("Content resource was not found.", "content_not_found"); + } + + return access.DataScope; + } + private async Task AssertReferenceAsync( Guid tenantId, Guid? id, diff --git a/Tiku.Infrastructure/Content/DirectContentService.cs b/Tiku.Infrastructure/Content/DirectContentService.cs index c7476eb..6befec1 100644 --- a/Tiku.Infrastructure/Content/DirectContentService.cs +++ b/Tiku.Infrastructure/Content/DirectContentService.cs @@ -5,6 +5,7 @@ using Tiku.Application.Assets; using Tiku.Application.Catalog; using Tiku.Application.Content; using Tiku.Application.QuestionBanks; +using Tiku.Application.Security; using Tiku.Domain.Catalog; using Tiku.Domain.Common; using Tiku.Domain.Content; @@ -12,12 +13,14 @@ using Tiku.Domain.Learning; using Tiku.Domain.Operations; using Tiku.Domain.QuestionBanks; using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; public sealed class DirectContentService( TikuDbContext dbContext, - IQuestionReferenceService questionReferenceService) : IDirectContentService + IQuestionReferenceService questionReferenceService, + ICurrentAccessContext currentAccessContext) : IDirectContentService { private const int DefaultLimit = 100; private const int MaxLimit = 1000; @@ -120,7 +123,11 @@ public sealed class DirectContentService( AdminLimitFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.VocabularyUnits.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var query = dbContext.VocabularyUnits.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); if (filter.RegionId.HasValue) { query = query.Where(item => item.RegionId == filter.RegionId.Value); @@ -159,6 +166,7 @@ public sealed class DirectContentService( VocabularyUnitCommand command, CancellationToken cancellationToken = default) { + var scope = await RequireDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken); @@ -166,6 +174,7 @@ public sealed class DirectContentService( var item = await ResolveByIdOrLegacyAsync(dbContext.VocabularyUnits, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; + EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "vocabulary_unit_not_found"); item ??= new VocabularyUnit { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; item.RegionId = command.RegionId; item.EntryId = command.EntryId; @@ -272,7 +281,11 @@ public sealed class DirectContentService( AdminLimitFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.HandbookSubjects.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var query = dbContext.HandbookSubjects.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); if (filter.RegionId.HasValue) { query = query.Where(item => item.RegionId == filter.RegionId.Value); @@ -321,6 +334,7 @@ public sealed class DirectContentService( HandbookSubjectCommand command, CancellationToken cancellationToken = default) { + var scope = await RequireDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken); @@ -330,6 +344,7 @@ public sealed class DirectContentService( var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookSubjects, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; + EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "handbook_subject_not_found"); item ??= new HandbookSubject { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; item.RegionId = command.RegionId; item.SchoolId = command.SchoolId; @@ -513,7 +528,11 @@ public sealed class DirectContentService( AdminLimitFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.Schools.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var query = dbContext.Schools.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); if (filter.RegionId.HasValue) { query = query.Where(item => item.RegionId == filter.RegionId.Value); @@ -536,10 +555,12 @@ public sealed class DirectContentService( SchoolCommand command, CancellationToken cancellationToken = default) { + var scope = await RequireDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); var item = await ResolveByIdOrLegacyAsync(dbContext.Schools, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; + EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "school_not_found"); item ??= new School { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; item.RegionId = command.RegionId; item.LegacyId = Normalize(command.LegacyId); @@ -560,7 +581,11 @@ public sealed class DirectContentService( AdminLimitFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.Majors.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var query = dbContext.Majors.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); if (filter.RegionId.HasValue) { query = query.Where(item => item.RegionId == filter.RegionId.Value); @@ -594,11 +619,13 @@ public sealed class DirectContentService( MajorCommand command, CancellationToken cancellationToken = default) { + var scope = await RequireDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken); var item = await ResolveByIdOrLegacyAsync(dbContext.Majors, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; + EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "major_not_found"); item ??= new Major { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; item.RegionId = command.RegionId; item.SchoolId = command.SchoolId; @@ -622,7 +649,11 @@ public sealed class DirectContentService( AdminLimitFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.ScorelineFields.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var query = dbContext.ScorelineFields.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); if (filter.RegionId.HasValue) { query = query.Where(item => item.RegionId == filter.RegionId.Value || item.RegionId == null); @@ -646,6 +677,7 @@ public sealed class DirectContentService( ScorelineFieldCommand command, CancellationToken cancellationToken = default) { + var scope = await RequireDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.FieldKey); ArgumentException.ThrowIfNullOrWhiteSpace(command.FieldName); if (!ScorelineFieldKeyRegex.IsMatch(command.FieldKey.Trim())) @@ -656,6 +688,7 @@ public sealed class DirectContentService( await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); var item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineFields, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; + EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "scoreline_field_not_found"); item ??= new ScorelineField { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; item.RegionId = command.RegionId; item.LegacyId = Normalize(command.LegacyId); @@ -685,7 +718,11 @@ public sealed class DirectContentService( AdminLimitFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.ScorelineRecords.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var query = dbContext.ScorelineRecords.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); if (filter.RegionId.HasValue) { query = query.Where(item => item.RegionId == filter.RegionId.Value); @@ -727,6 +764,7 @@ public sealed class DirectContentService( ScorelineRecordCommand command, CancellationToken cancellationToken = default) { + var scope = await RequireDataScopeAsync(actor, cancellationToken); if (command.Year is < 1900 or > 3000) { throw new ContentManagementException("Scoreline record year is invalid.", "scoreline_year_invalid"); @@ -737,6 +775,7 @@ public sealed class DirectContentService( await AssertReferenceAsync(actor.TenantId, command.MajorId, "major_not_found", cancellationToken); var item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineRecords, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; + EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "scoreline_record_not_found"); item ??= new ScorelineRecord { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; item.RegionId = command.RegionId; item.SchoolId = command.SchoolId; @@ -760,8 +799,11 @@ public sealed class DirectContentService( AdminLimitFilter filter, CancellationToken cancellationToken = default) { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); var query = dbContext.ScorelineRecords.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId); + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); if (filter.RegionId.HasValue) { query = query.Where(item => item.RegionId == filter.RegionId.Value); @@ -1720,6 +1762,38 @@ public sealed class DirectContentService( item.IssuesCount); } + private async Task RequireDataScopeAsync( + DirectContentActor actor, + CancellationToken cancellationToken) + { + var access = await currentAccessContext.GetAsync(cancellationToken); + if (!access.IsCurrentTenantMember || + access.UserId != actor.UserId || + access.TenantId != actor.TenantId || + !access.HasTenantPermission(BackendPermissions.TenantContentManage)) + { + throw new ContentManagementException("Tenant content access was denied.", "content_access_denied"); + } + + return access.DataScope; + } + + private static void EnsureRegionWriteAllowed( + CurrentDataScope scope, + DirectContentActor actor, + Guid? currentRegionId, + Guid? targetRegionId, + bool isNew, + string notFoundCode) + { + var canAccessCurrent = isNew || scope.AllowsResource(actor.UserId, regionId: currentRegionId); + var canAccessTarget = scope.AllowsResource(actor.UserId, regionId: targetRegionId); + if (!canAccessCurrent || !canAccessTarget) + { + throw new ContentManagementException("Content resource was not found.", notFoundCode); + } + } + private async Task ResolveByIdOrLegacyAsync( DbSet set, Guid tenantId, diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs index fcba657..f5319fd 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Npgsql; using Tiku.Application.Assets; @@ -35,10 +36,12 @@ using Tiku.Infrastructure.Profile; using Tiku.Infrastructure.Points; using Tiku.Infrastructure.QuestionBanks; using Tiku.Infrastructure.Scoreline; +using Tiku.Infrastructure.Security; using Tiku.Infrastructure.Storage; using Tiku.Infrastructure.StudyContent; using Tiku.Infrastructure.TenantAdmin; using Tiku.Infrastructure.Tenancy; +using Tiku.Domain.Identity; namespace Tiku.Infrastructure; @@ -59,6 +62,21 @@ public static class DependencyInjection npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName)); options.AddInterceptors(serviceProvider.GetRequiredService()); }); + services.AddIdentityCore(options => + { + options.Password.RequiredLength = 10; + options.Password.RequireDigit = true; + options.Password.RequireLowercase = true; + options.Password.RequireUppercase = false; + options.Password.RequireNonAlphanumeric = false; + options.Lockout.MaxFailedAccessAttempts = 5; + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); + options.User.RequireUniqueEmail = false; + }) + .AddEntityFrameworkStores() + .AddSignInManager() + .AddDefaultTokenProviders(); + services.Configure(options => options.IterationCount = 210_000); services.AddScoped(); services.AddMemoryCache(); services.AddScoped(); @@ -69,9 +87,10 @@ public static class DependencyInjection services.AddScoped(); services.AddOptions(); services.AddSingleton(); - services.AddScoped(); + services.AddSingleton(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService()); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -94,6 +113,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Tiku.Infrastructure/Growth/CommissionService.cs b/Tiku.Infrastructure/Growth/CommissionService.cs index 1e001c2..bb63145 100644 --- a/Tiku.Infrastructure/Growth/CommissionService.cs +++ b/Tiku.Infrastructure/Growth/CommissionService.cs @@ -4,6 +4,7 @@ using System.Text; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Growth; +using Tiku.Application.Security; using Tiku.Domain.Commerce; using Tiku.Domain.Common; using Tiku.Domain.Growth; @@ -13,7 +14,9 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Growth; -public sealed class CommissionService(TikuDbContext dbContext) : ICommissionService +public sealed class CommissionService( + TikuDbContext dbContext, + ICurrentAccessContext currentAccessContext) : ICommissionService { public async Task GetSettingsAsync(CommissionAdminActor actor, CancellationToken cancellationToken = default) { @@ -40,11 +43,20 @@ public sealed class CommissionService(TikuDbContext dbContext) : ICommissionServ var member = await dbContext.TenantMemberships .FirstOrDefaultAsync(item => item.TenantId == actor.TenantId && item.UserId == command.UserId, cancellationToken) ?? throw new CommissionException("Commission member was not found.", "commission_member_not_found"); - member.Permissions = JsonSerializer.SerializeToElement(new + var settings = await GetSettingsCoreAsync(actor.TenantId, cancellationToken); + var config = settings.Config.ValueKind == JsonValueKind.Object + ? JsonSerializer.Deserialize>(settings.Config.GetRawText()) ?? [] + : []; + var memberRates = config.TryGetValue("memberRates", out var existingRates) && existingRates.ValueKind == JsonValueKind.Object + ? JsonSerializer.Deserialize>(existingRates.GetRawText()) ?? [] + : []; + memberRates[member.UserId.ToString("N")] = JsonSerializer.SerializeToElement(new { commissionRate = command.CommissionRate, commissionConfig = command.CommissionConfig }); + config["memberRates"] = JsonSerializer.SerializeToElement(memberRates); + settings.Config = JsonSerializer.SerializeToElement(config); await dbContext.SaveChangesAsync(cancellationToken); return new { member.UserId, commissionRate = command.CommissionRate, commissionConfig = command.CommissionConfig }; } @@ -265,7 +277,7 @@ public sealed class CommissionService(TikuDbContext dbContext) : ICommissionServ foreach (var row in orders) { if (query.ReferrerUserId.HasValue && row.lead.ReferrerUserId != query.ReferrerUserId) continue; - var rate = GetMemberRate(await GetMembershipPermissionsAsync(tenantId, row.lead.ReferrerUserId!.Value, cancellationToken)) ?? settings.DefaultRate; + var rate = GetMemberRate(settings.Config, row.lead.ReferrerUserId!.Value) ?? settings.DefaultRate; var settled = existing.FirstOrDefault(item => item.SourceType == CommissionSourceType.Order && item.SourceId == row.order.Id)?.SettlementId; result.Add(new SourceCandidate(CommissionSourceType.Order, row.order.Id, row.order.OrderNo, row.lead.ReferrerUserId.Value, row.order.UserId, row.order.AmountCents, rate, (int)Math.Round(row.order.AmountCents * rate), CommissionRateSource.Member, settled, row.order.PaidAt, "protected_lead")); } @@ -282,7 +294,7 @@ public sealed class CommissionService(TikuDbContext dbContext) : ICommissionServ if (!row.code.AgentUserId.HasValue) continue; var agentUserId = row.code.AgentUserId.Value; if (query.ReferrerUserId.HasValue && agentUserId != query.ReferrerUserId) continue; - var rate = row.batch?.CommissionRate ?? GetMemberRate(await GetMembershipPermissionsAsync(tenantId, agentUserId, cancellationToken)) ?? settings.DefaultRate; + var rate = row.batch?.CommissionRate ?? GetMemberRate(settings.Config, agentUserId) ?? settings.DefaultRate; var sourceAmount = row.code.UnitPriceCents ?? row.batch?.DefaultUnitPriceCents ?? 0; var settled = existing.FirstOrDefault(item => item.SourceType == CommissionSourceType.ActivationCode && item.SourceId == row.code.Id)?.SettlementId; result.Add(new SourceCandidate(CommissionSourceType.ActivationCode, row.code.Id, row.code.Code, agentUserId, row.code.UsedBy, sourceAmount, rate, (int)Math.Round(sourceAmount * rate), row.batch?.CommissionRate is null ? CommissionRateSource.Member : CommissionRateSource.Batch, settled, row.code.UsedAt, "activation_code_agent")); @@ -290,11 +302,22 @@ public sealed class CommissionService(TikuDbContext dbContext) : ICommissionServ return result.OrderBy(item => item.SourcePaidAt).ToArray(); } - private async Task GetMembershipPermissionsAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken) => - await dbContext.TenantMemberships.AsNoTracking().Where(item => item.TenantId == tenantId && item.UserId == userId).Select(item => item.Permissions).FirstOrDefaultAsync(cancellationToken); + private static decimal? GetMemberRate(JsonElement config, Guid userId) + { + if (config.ValueKind != JsonValueKind.Object || + !config.TryGetProperty("memberRates", out var memberRates) || + memberRates.ValueKind != JsonValueKind.Object || + !memberRates.TryGetProperty(userId.ToString("N"), out var memberRate) || + memberRate.ValueKind != JsonValueKind.Object || + !memberRate.TryGetProperty("commissionRate", out var value) || + value.ValueKind != JsonValueKind.Number || + !value.TryGetDecimal(out var rate)) + { + return null; + } - private static decimal? GetMemberRate(JsonElement permissions) => - permissions.ValueKind == JsonValueKind.Object && permissions.TryGetProperty("commissionRate", out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetDecimal(out var rate) ? rate : null; + return rate; + } private async Task GetSettingsCoreAsync(Guid tenantId, CancellationToken cancellationToken) { @@ -311,8 +334,14 @@ public sealed class CommissionService(TikuDbContext dbContext) : ICommissionServ private async Task AssertAdminAsync(CommissionAdminActor actor, CancellationToken cancellationToken) { - var ok = await dbContext.TenantMemberships.AnyAsync(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.Status == MembershipStatus.Active && (item.Role == TenantRole.PlatformAdmin || item.Role == TenantRole.TenantOwner || item.Role == TenantRole.TenantAdmin), cancellationToken); - if (!ok) throw new CommissionException("Commission admin access was denied.", "commission_access_denied"); + var access = await currentAccessContext.GetAsync(cancellationToken); + if (!access.IsCurrentTenantMember || + access.TenantId != actor.TenantId || + access.UserId != actor.UserId || + !access.HasTenantPermission(BackendPermissions.TenantCommissionManage)) + { + throw new CommissionException("Commission admin access was denied.", "commission_access_denied"); + } } private async Task AddAuditAsync(CommissionAdminActor actor, string action, string targetType, Guid targetId, object details, CancellationToken cancellationToken) diff --git a/Tiku.Infrastructure/Growth/CrmService.cs b/Tiku.Infrastructure/Growth/CrmService.cs index 3f7a005..1b43517 100644 --- a/Tiku.Infrastructure/Growth/CrmService.cs +++ b/Tiku.Infrastructure/Growth/CrmService.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Growth; +using Tiku.Application.Security; using Tiku.Domain.Common; using Tiku.Domain.Growth; using Tiku.Domain.Tenancy; @@ -11,7 +12,8 @@ namespace Tiku.Infrastructure.Growth; internal sealed class CrmService( TikuDbContext dbContext, - ITenantSecretProtector tenantSecretProtector) : ICrmService + ITenantSecretProtector tenantSecretProtector, + ICurrentAccessContext currentAccessContext) : ICrmService { private static readonly HashSet SensitiveKeys = new(StringComparer.OrdinalIgnoreCase) { @@ -270,16 +272,12 @@ internal sealed class CrmService( private async Task AssertAdminAsync(CrmAdminActor actor, CancellationToken cancellationToken) { - var isAdmin = await dbContext.TenantMemberships.AnyAsync( - item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.Status == MembershipStatus.Active && - (item.Role == TenantRole.PlatformAdmin || - item.Role == TenantRole.TenantOwner || - item.Role == TenantRole.TenantAdmin), - cancellationToken); - if (!isAdmin) + var access = await currentAccessContext.GetAsync(cancellationToken); + if (!access.IsCurrentTenantMember || + access.UserId != actor.UserId || + access.TenantId != actor.TenantId || + !access.HasTenantPermission(BackendPermissions.TenantCrmManage) || + access.DataScope.Mode != DataScopeMode.All) { throw new CrmException("CRM admin access was denied.", "crm_access_denied"); } diff --git a/Tiku.Infrastructure/Growth/ReferralService.cs b/Tiku.Infrastructure/Growth/ReferralService.cs index 061d03b..10d0a2a 100644 --- a/Tiku.Infrastructure/Growth/ReferralService.cs +++ b/Tiku.Infrastructure/Growth/ReferralService.cs @@ -3,6 +3,7 @@ using System.Security.Cryptography; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Growth; +using Tiku.Application.Security; using Tiku.Domain.Commerce; using Tiku.Domain.Common; using Tiku.Domain.Growth; @@ -14,7 +15,8 @@ namespace Tiku.Infrastructure.Growth; public sealed class ReferralService( TikuDbContext dbContext, - IReferralQrcodeGenerator qrcodeGenerator) : IReferralService + IReferralQrcodeGenerator qrcodeGenerator, + ICurrentAccessContext currentAccessContext) : IReferralService { private static readonly HashSet AllowedEventTypes = new(StringComparer.OrdinalIgnoreCase) { @@ -109,7 +111,7 @@ public sealed class ReferralService( referralCode.UserId, membership.Role, user.Name, - user.Username, + user.UserName, user.Phone }) .FirstOrDefaultAsync(cancellationToken); @@ -121,7 +123,7 @@ public sealed class ReferralService( row.UserId, row.Code, row.Role.ToString(), - FirstNonBlank(row.Name, row.Username, row.Phone)); + FirstNonBlank(row.Name, row.UserName, row.Phone)); } public async Task TrackEventAsync( @@ -655,16 +657,11 @@ public sealed class ReferralService( private async Task AssertAdminAsync(ReferralAdminActor actor, CancellationToken cancellationToken) { - var isAdmin = await dbContext.TenantMemberships.AnyAsync( - item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.Status == MembershipStatus.Active && - (item.Role == TenantRole.PlatformAdmin || - item.Role == TenantRole.TenantOwner || - item.Role == TenantRole.TenantAdmin), - cancellationToken); - if (!isAdmin) + var access = await currentAccessContext.GetAsync(cancellationToken); + if (!access.IsCurrentTenantMember || + access.TenantId != actor.TenantId || + access.UserId != actor.UserId || + !access.HasTenantPermission(BackendPermissions.TenantCrmManage)) { throw new ReferralException("Referral admin access was denied.", "referral_access_denied"); } @@ -677,7 +674,7 @@ public sealed class ReferralService( { var user = await dbContext.Users.AsNoTracking() .Where(item => item.Id == referrerUserId) - .Select(item => new { item.Name, item.Username, item.Phone }) + .Select(item => new { item.Name, item.UserName, item.Phone }) .FirstOrDefaultAsync(cancellationToken); var membership = await dbContext.TenantMemberships.AsNoTracking() .Where(item => item.TenantId == tenantId && item.UserId == referrerUserId) @@ -714,7 +711,7 @@ public sealed class ReferralService( return new ReferralStatsItem( referrerUserId, - FirstNonBlank(user?.Name, user?.Username, user?.Phone), + FirstNonBlank(user?.Name, user?.UserName, user?.Phone), membership.ToString(), inviteCode, leads.Length, diff --git a/Tiku.Infrastructure/Persistence/Configurations/IdentityConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/IdentityConfigurations.cs index 724148d..c40e59a 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/IdentityConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/IdentityConfigurations.cs @@ -9,22 +9,30 @@ internal sealed class UserConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { - builder.ConfigureEntity("users"); + builder.ToTable("users"); + builder.HasKey(entity => entity.Id); + builder.Property(entity => entity.Id).HasDefaultValueSql("gen_random_uuid()"); builder.ConfigureTimestamps(); builder.Property(entity => entity.LegacyId).HasMaxLength(64); - builder.Property(entity => entity.Username).HasMaxLength(100); + builder.Property(entity => entity.UserName).HasMaxLength(100); + builder.Property(entity => entity.NormalizedUserName).HasMaxLength(100); builder.Property(entity => entity.Email).HasColumnType("citext").HasMaxLength(320); + builder.Property(entity => entity.NormalizedEmail).HasMaxLength(320); builder.Property(entity => entity.Phone).HasMaxLength(32); + builder.Property(entity => entity.PhoneNumber).HasMaxLength(32); + builder.Property(entity => entity.PasswordHash).HasMaxLength(1024); + builder.Property(entity => entity.SecurityStamp).HasMaxLength(64); + builder.Property(entity => entity.ConcurrencyStamp).HasMaxLength(64).IsConcurrencyToken(); builder.Property(entity => entity.Name).HasMaxLength(200); builder.Property(entity => entity.AvatarUrl).HasMaxLength(2048); builder.Property(entity => entity.PrimaryRole).HasMaxLength(50); - builder.Property(entity => entity.LegacyPasswordHash).HasMaxLength(512); + builder.Property(entity => entity.Status).HasSnakeCaseEnum(); builder.Property(entity => entity.RawProfile).IsJson("{}"); builder.HasIndex(entity => entity.LegacyId).IsUnique(); - builder.HasIndex(entity => entity.Username).IsUnique(); - builder.HasIndex(entity => entity.Email).IsUnique(); + builder.HasIndex(entity => entity.NormalizedUserName).IsUnique(); + builder.HasIndex(entity => entity.NormalizedEmail); builder.HasIndex(entity => entity.Phone).IsUnique(); } } @@ -42,8 +50,6 @@ internal sealed class UserIdentityConfiguration : IEntityTypeConfiguration entity.OpenId).HasMaxLength(255); builder.Property(entity => entity.Phone).HasMaxLength(32); builder.Property(entity => entity.Email).HasColumnType("citext").HasMaxLength(320); - builder.Property(entity => entity.SecretPayload).IsJson("{}"); - builder.HasIndex(entity => new { entity.Provider, entity.ProviderSubject }).IsUnique(); builder.HasOne() .WithMany() diff --git a/Tiku.Infrastructure/Persistence/Configurations/TenancyConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/TenancyConfigurations.cs index c22763b..2fadca1 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/TenancyConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/TenancyConfigurations.cs @@ -43,7 +43,6 @@ internal sealed class TenantMembershipConfiguration : IEntityTypeConfiguration entity.Role).HasSnakeCaseEnum(); builder.Property(entity => entity.Status).HasSnakeCaseEnum(); - builder.Property(entity => entity.Permissions).IsJson("{}"); builder.Property(entity => entity.LegacyRole).HasMaxLength(50); builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.Role }).IsUnique(); @@ -52,11 +51,6 @@ internal sealed class TenantMembershipConfiguration : IEntityTypeConfiguration entity.UserId) .OnDelete(DeleteBehavior.Cascade); - builder.HasOne() - .WithMany() - .HasForeignKey(entity => new { entity.TenantId, entity.RoleTemplateId }) - .HasPrincipalKey(entity => new { entity.TenantId, entity.Id }) - .OnDelete(DeleteBehavior.Restrict); } } diff --git a/Tiku.Infrastructure/Persistence/Configurations/TenantOperationsConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/TenantOperationsConfigurations.cs index e5f0cd6..3762b33 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/TenantOperationsConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/TenantOperationsConfigurations.cs @@ -51,7 +51,6 @@ internal sealed class SmsVerificationCodeConfiguration : IEntityTypeConfiguratio public void Configure(EntityTypeBuilder builder) { builder.ConfigureEntity("sms_verification_codes"); - builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id }); builder.Property(entity => entity.Phone).HasMaxLength(32); builder.Property(entity => entity.Purpose).HasSnakeCaseEnum(); builder.Property(entity => entity.CodeHash).HasMaxLength(256); @@ -96,7 +95,7 @@ internal sealed class AuthLoginEventConfiguration : IEntityTypeConfiguration().WithMany() .HasForeignKey(entity => entity.TenantId) - .OnDelete(DeleteBehavior.Cascade); + .OnDelete(DeleteBehavior.SetNull); builder.HasOne().WithMany() .HasForeignKey(entity => entity.UserId) .OnDelete(DeleteBehavior.SetNull); @@ -107,18 +106,30 @@ internal sealed class AuthSessionConfiguration : IEntityTypeConfiguration builder) { - builder.ConfigureTenantEntity("auth_sessions"); + builder.ConfigureEntity("auth_sessions"); builder.ConfigureTimestamps(); + builder.Property(entity => entity.Realm).HasSnakeCaseEnum(); builder.Property(entity => entity.TokenHash).HasMaxLength(256); + builder.Property(entity => entity.SecurityStamp).HasMaxLength(128); builder.Property(entity => entity.Provider).HasMaxLength(50); + builder.Property(entity => entity.RevokedReason).HasMaxLength(100); builder.Property(entity => entity.IpAddress).HasMaxLength(64); builder.Property(entity => entity.UserAgent).HasMaxLength(1000); builder.Property(entity => entity.Metadata).IsJson("{}"); builder.HasIndex(entity => entity.TokenHash) .IsUnique() .HasAnnotation("Tiku:GlobalUnique", true); - builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.ExpiresAt }) + builder.HasIndex(entity => new { entity.Realm, entity.TenantId, entity.UserId, entity.ExpiresAt }) .HasFilter("revoked_at is null"); + builder.HasIndex(entity => new { entity.TokenFamilyId, entity.RevokedAt }); + + builder.ToTable(table => table.HasCheckConstraint( + "ck_auth_sessions_realm_tenant", + "(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)")); + + builder.HasOne().WithMany() + .HasForeignKey(entity => entity.TenantId) + .OnDelete(DeleteBehavior.Cascade); builder.HasOne().WithMany() .HasForeignKey(entity => entity.UserId) @@ -126,6 +137,29 @@ internal sealed class AuthSessionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ConfigureEntity("auth_challenges"); + builder.Property(entity => entity.Realm).HasSnakeCaseEnum(); + builder.Property(entity => entity.Purpose).HasSnakeCaseEnum(); + builder.Property(entity => entity.TokenHash).HasMaxLength(64); + builder.Property(entity => entity.SecurityStamp).HasMaxLength(128); + builder.Property(entity => entity.Provider).HasMaxLength(50); + builder.Property(entity => entity.IpAddress).HasMaxLength(100); + builder.Property(entity => entity.UserAgent).HasMaxLength(1024); + builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()"); + builder.HasIndex(entity => entity.TokenHash).IsUnique(); + builder.HasIndex(entity => new { entity.UserId, entity.Purpose, entity.ExpiresAt }); + builder.ToTable(table => table.HasCheckConstraint( + "ck_auth_challenges_realm_tenant", + "(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)")); + builder.HasOne().WithMany().HasForeignKey(entity => entity.UserId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade); + } +} + internal sealed class SmsSendRateLimitConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) @@ -154,32 +188,6 @@ internal sealed class SmsSendRateLimitConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ConfigureTenantEntity("tenant_role_templates"); - builder.ConfigureTimestamps(); - builder.Property(entity => entity.Code).HasMaxLength(100); - builder.Property(entity => entity.Name).HasMaxLength(200); - builder.Property(entity => entity.BaseRole).HasSnakeCaseEnum(); - builder.Property(entity => entity.Status).HasSnakeCaseEnum(); - builder.Property(entity => entity.Permissions).IsJson("{}"); - builder.Property(entity => entity.MenuPermissions).IsJson("{}"); - builder.Property(entity => entity.ModulePermissions).IsJson("{}"); - builder.Property(entity => entity.FieldPermissions).IsJson("{}"); - builder.Property(entity => entity.DataScope).IsJson("{}"); - builder.HasIndex(entity => new { entity.TenantId, entity.Code }).IsUnique(); - builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.SortOrder }); - builder.HasOne().WithMany() - .HasForeignKey(entity => entity.CreatedBy) - .OnDelete(DeleteBehavior.SetNull); - builder.HasOne().WithMany() - .HasForeignKey(entity => entity.UpdatedBy) - .OnDelete(DeleteBehavior.SetNull); - } -} - internal sealed class TenantClassConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) diff --git a/Tiku.Infrastructure/Persistence/Migrations/20260728014412_InitialSchema.Designer.cs b/Tiku.Infrastructure/Persistence/Migrations/20260728031410_InitialSchema.Designer.cs similarity index 98% rename from Tiku.Infrastructure/Persistence/Migrations/20260728014412_InitialSchema.Designer.cs rename to Tiku.Infrastructure/Persistence/Migrations/20260728031410_InitialSchema.Designer.cs index c9f4d2d..b8e0ed7 100644 --- a/Tiku.Infrastructure/Persistence/Migrations/20260728014412_InitialSchema.Designer.cs +++ b/Tiku.Infrastructure/Persistence/Migrations/20260728031410_InitialSchema.Designer.cs @@ -13,7 +13,7 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Persistence.Migrations { [DbContext(typeof(TikuDbContext))] - [Migration("20260728014412_InitialSchema")] + [Migration("20260728031410_InitialSchema")] partial class InitialSchema { /// @@ -28,6 +28,110 @@ namespace Tiku.Infrastructure.Persistence.Migrations NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree"); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text") + .HasColumnName("friendly_name"); + + b.Property("Xml") + .HasColumnType("text") + .HasColumnName("xml"); + + b.HasKey("Id") + .HasName("pk_data_protection_keys"); + + b.ToTable("data_protection_keys", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_claims_user_id"); + + b.ToTable("user_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("text") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_logins_user_id"); + + b.ToTable("user_logins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_user_tokens"); + + b.ToTable("user_tokens", (string)null); + }); + modelBuilder.Entity("Tiku.Domain.Catalog.Category", b => { b.Property("Id") @@ -7918,11 +8022,21 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasColumnName("id") .HasDefaultValueSql("gen_random_uuid()"); + b.Property("AccessFailedCount") + .HasColumnType("integer") + .HasColumnName("access_failed_count"); + b.Property("AvatarUrl") .HasMaxLength(2048) .HasColumnType("character varying(2048)") .HasColumnName("avatar_url"); + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("concurrency_stamp"); + b.Property("CreatedAt") .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone") @@ -7934,6 +8048,14 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasColumnType("citext") .HasColumnName("email"); + b.Property("EmailConfirmed") + .HasColumnType("boolean") + .HasColumnName("email_confirmed"); + + b.Property("ForcePasswordChange") + .HasColumnType("boolean") + .HasColumnName("force_password_change"); + b.Property("LastSeenAt") .HasColumnType("timestamp with time zone") .HasColumnName("last_seen_at"); @@ -7943,25 +8065,48 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasColumnType("character varying(64)") .HasColumnName("legacy_id"); - b.Property("LegacyPasswordHash") - .HasMaxLength(512) - .HasColumnType("character varying(512)") - .HasColumnName("legacy_password_hash"); + b.Property("LockoutEnabled") + .HasColumnType("boolean") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); b.Property("Name") .HasMaxLength(200) .HasColumnType("character varying(200)") .HasColumnName("name"); - b.Property("PasswordMigrationRequired") - .HasColumnType("boolean") - .HasColumnName("password_migration_required"); + b.Property("NormalizedEmail") + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("normalized_user_name"); + + b.Property("PasswordHash") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("password_hash"); b.Property("Phone") .HasMaxLength(32) .HasColumnType("character varying(32)") .HasColumnName("phone"); + b.Property("PhoneNumber") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean") + .HasColumnName("phone_number_confirmed"); + b.Property("PrimaryRole") .IsRequired() .HasMaxLength(50) @@ -7978,36 +8123,50 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasColumnType("integer") .HasColumnName("score"); + b.Property("SecurityStamp") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("security_stamp"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean") + .HasColumnName("two_factor_enabled"); + b.Property("UpdatedAt") .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone") .HasColumnName("updated_at") .HasDefaultValueSql("now()"); - b.Property("Username") + b.Property("UserName") .HasMaxLength(100) .HasColumnType("character varying(100)") - .HasColumnName("username"); + .HasColumnName("user_name"); b.HasKey("Id") .HasName("pk_users"); - b.HasIndex("Email") - .IsUnique() - .HasDatabaseName("ix_users_email"); - b.HasIndex("LegacyId") .IsUnique() .HasDatabaseName("ix_users_legacy_id"); + b.HasIndex("NormalizedEmail") + .HasDatabaseName("email_index"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("user_name_index"); + b.HasIndex("Phone") .IsUnique() .HasDatabaseName("ix_users_phone"); - b.HasIndex("Username") - .IsUnique() - .HasDatabaseName("ix_users_username"); - b.ToTable("users", (string)null); }); @@ -8052,12 +8211,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasColumnType("character varying(255)") .HasColumnName("provider_subject"); - b.Property("SecretPayload") - .ValueGeneratedOnAdd() - .HasColumnType("jsonb") - .HasColumnName("secret_payload") - .HasDefaultValueSql("'{}'::jsonb"); - b.Property("UnionId") .HasMaxLength(255) .HasColumnType("character varying(255)") @@ -12651,6 +12804,95 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.ToTable("question_versions", (string)null); }); + modelBuilder.Entity("Tiku.Domain.Tenancy.AuthChallenge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("consumed_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IpAddress") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("ip_address"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("provider"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("purpose"); + + b.Property("Realm") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("realm"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("security_stamp"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UserAgent") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_auth_challenges"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_auth_challenges_tenant_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_auth_challenges_token_hash"); + + b.HasIndex("UserId", "Purpose", "ExpiresAt") + .HasDatabaseName("ix_auth_challenges_user_id_purpose_expires_at"); + + b.ToTable("auth_challenges", null, t => + { + t.HasCheckConstraint("ck_auth_challenges_realm_tenant", "(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)"); + }); + }); + modelBuilder.Entity("Tiku.Domain.Tenancy.AuthLoginEvent", b => { b.Property("Id") @@ -12755,20 +12997,53 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasColumnName("metadata") .HasDefaultValueSql("'{}'::jsonb"); + b.Property("MfaSatisfied") + .HasColumnType("boolean") + .HasColumnName("mfa_satisfied"); + + b.Property("ParentSessionId") + .HasColumnType("uuid") + .HasColumnName("parent_session_id"); + b.Property("Provider") .IsRequired() .HasMaxLength(50) .HasColumnType("character varying(50)") .HasColumnName("provider"); + b.Property("Realm") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("realm"); + + b.Property("ReplacedBySessionId") + .HasColumnType("uuid") + .HasColumnName("replaced_by_session_id"); + b.Property("RevokedAt") .HasColumnType("timestamp with time zone") .HasColumnName("revoked_at"); - b.Property("TenantId") + b.Property("RevokedReason") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("revoked_reason"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("security_stamp"); + + b.Property("TenantId") .HasColumnType("uuid") .HasColumnName("tenant_id"); + b.Property("TokenFamilyId") + .HasColumnType("uuid") + .HasColumnName("token_family_id"); + b.Property("TokenHash") .IsRequired() .HasMaxLength(256) @@ -12793,8 +13068,8 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.HasKey("Id") .HasName("pk_auth_sessions"); - b.HasAlternateKey("TenantId", "Id") - .HasName("ak_auth_sessions_tenant_id_id"); + b.HasIndex("TenantId") + .HasDatabaseName("ix_auth_sessions_tenant_id"); b.HasIndex("TokenHash") .IsUnique() @@ -12804,11 +13079,17 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.HasIndex("UserId") .HasDatabaseName("ix_auth_sessions_user_id"); - b.HasIndex("TenantId", "UserId", "ExpiresAt") - .HasDatabaseName("ix_auth_sessions_tenant_id_user_id_expires_at") + b.HasIndex("TokenFamilyId", "RevokedAt") + .HasDatabaseName("ix_auth_sessions_token_family_id_revoked_at"); + + b.HasIndex("Realm", "TenantId", "UserId", "ExpiresAt") + .HasDatabaseName("ix_auth_sessions_realm_tenant_id_user_id_expires_at") .HasFilter("revoked_at is null"); - b.ToTable("auth_sessions", (string)null); + b.ToTable("auth_sessions", null, t => + { + t.HasCheckConstraint("ck_auth_sessions_realm_tenant", "(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)"); + }); }); modelBuilder.Entity("Tiku.Domain.Tenancy.SmsSendRateLimit", b => @@ -12934,9 +13215,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.HasKey("Id") .HasName("pk_sms_verification_codes"); - b.HasAlternateKey("TenantId", "Id") - .HasName("ak_sms_verification_codes_tenant_id_id"); - b.HasIndex("TenantId", "Phone", "Purpose") .IsUnique() .HasDatabaseName("ix_sms_verification_codes_tenant_id_phone_purpose") @@ -13626,22 +13904,12 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasColumnType("character varying(50)") .HasColumnName("legacy_role"); - b.Property("Permissions") - .ValueGeneratedOnAdd() - .HasColumnType("jsonb") - .HasColumnName("permissions") - .HasDefaultValueSql("'{}'::jsonb"); - b.Property("Role") .IsRequired() .HasMaxLength(32) .HasColumnType("character varying(32)") .HasColumnName("role"); - b.Property("RoleTemplateId") - .HasColumnType("uuid") - .HasColumnName("role_template_id"); - b.Property("Status") .IsRequired() .HasMaxLength(32) @@ -13671,9 +13939,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.HasIndex("UserId") .HasDatabaseName("ix_tenant_memberships_user_id"); - b.HasIndex("TenantId", "RoleTemplateId") - .HasDatabaseName("ix_tenant_memberships_tenant_id_role_template_id"); - b.HasIndex("TenantId", "UserId", "Role") .IsUnique() .HasDatabaseName("ix_tenant_memberships_tenant_id_user_id_role"); @@ -13681,126 +13946,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.ToTable("tenant_memberships", (string)null); }); - modelBuilder.Entity("Tiku.Domain.Tenancy.TenantRoleTemplate", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasColumnName("id") - .HasDefaultValueSql("gen_random_uuid()"); - - b.Property("BaseRole") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasColumnName("base_role"); - - b.Property("Code") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)") - .HasColumnName("code"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("now()"); - - b.Property("CreatedBy") - .HasColumnType("uuid") - .HasColumnName("created_by"); - - b.Property("DataScope") - .ValueGeneratedOnAdd() - .HasColumnType("jsonb") - .HasColumnName("data_scope") - .HasDefaultValueSql("'{}'::jsonb"); - - b.Property("Description") - .HasColumnType("text") - .HasColumnName("description"); - - b.Property("FieldPermissions") - .ValueGeneratedOnAdd() - .HasColumnType("jsonb") - .HasColumnName("field_permissions") - .HasDefaultValueSql("'{}'::jsonb"); - - b.Property("IsSystem") - .HasColumnType("boolean") - .HasColumnName("is_system"); - - b.Property("MenuPermissions") - .ValueGeneratedOnAdd() - .HasColumnType("jsonb") - .HasColumnName("menu_permissions") - .HasDefaultValueSql("'{}'::jsonb"); - - b.Property("ModulePermissions") - .ValueGeneratedOnAdd() - .HasColumnType("jsonb") - .HasColumnName("module_permissions") - .HasDefaultValueSql("'{}'::jsonb"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasColumnName("name"); - - b.Property("Permissions") - .ValueGeneratedOnAdd() - .HasColumnType("jsonb") - .HasColumnName("permissions") - .HasDefaultValueSql("'{}'::jsonb"); - - b.Property("SortOrder") - .HasColumnType("integer") - .HasColumnName("sort_order"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasColumnName("status"); - - b.Property("TenantId") - .HasColumnType("uuid") - .HasColumnName("tenant_id"); - - b.Property("UpdatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("updated_at") - .HasDefaultValueSql("now()"); - - b.Property("UpdatedBy") - .HasColumnType("uuid") - .HasColumnName("updated_by"); - - b.HasKey("Id") - .HasName("pk_tenant_role_templates"); - - b.HasAlternateKey("TenantId", "Id") - .HasName("ak_tenant_role_templates_tenant_id_id"); - - b.HasIndex("CreatedBy") - .HasDatabaseName("ix_tenant_role_templates_created_by"); - - b.HasIndex("UpdatedBy") - .HasDatabaseName("ix_tenant_role_templates_updated_by"); - - b.HasIndex("TenantId", "Code") - .IsUnique() - .HasDatabaseName("ix_tenant_role_templates_tenant_id_code"); - - b.HasIndex("TenantId", "Status", "SortOrder") - .HasDatabaseName("ix_tenant_role_templates_tenant_id_status_sort_order"); - - b.ToTable("tenant_role_templates", (string)null); - }); - modelBuilder.Entity("Tiku.Domain.Tenancy.TenantSecret", b => { b.Property("Id") @@ -14156,6 +14301,36 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.ToTable("tenant_student_notes", (string)null); }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_claims_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_logins_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_tokens_users_user_id"); + }); + modelBuilder.Entity("Tiku.Domain.Catalog.Category", b => { b.HasOne("Tiku.Domain.Tenancy.Tenant", null) @@ -17207,12 +17382,28 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasConstraintName("fk_question_versions_questions_tenant_id_question_id"); }); - modelBuilder.Entity("Tiku.Domain.Tenancy.AuthLoginEvent", b => + modelBuilder.Entity("Tiku.Domain.Tenancy.AuthChallenge", b => { b.HasOne("Tiku.Domain.Tenancy.Tenant", null) .WithMany() .HasForeignKey("TenantId") .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_auth_challenges_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_auth_challenges_users_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.AuthLoginEvent", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.SetNull) .IsRequired() .HasConstraintName("fk_auth_login_events_tenants_tenant_id"); @@ -17229,7 +17420,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations .WithMany() .HasForeignKey("TenantId") .OnDelete(DeleteBehavior.Cascade) - .IsRequired() .HasConstraintName("fk_auth_sessions_tenants_tenant_id"); b.HasOne("Tiku.Domain.Identity.User", null) @@ -17378,35 +17568,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired() .HasConstraintName("fk_tenant_memberships_users_user_id"); - - b.HasOne("Tiku.Domain.Tenancy.TenantRoleTemplate", null) - .WithMany() - .HasForeignKey("TenantId", "RoleTemplateId") - .HasPrincipalKey("TenantId", "Id") - .OnDelete(DeleteBehavior.Restrict) - .HasConstraintName("fk_tenant_memberships_tenant_role_templates_tenant_id_role_tem~"); - }); - - modelBuilder.Entity("Tiku.Domain.Tenancy.TenantRoleTemplate", b => - { - b.HasOne("Tiku.Domain.Identity.User", null) - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.SetNull) - .HasConstraintName("fk_tenant_role_templates_users_created_by"); - - b.HasOne("Tiku.Domain.Tenancy.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_tenant_role_templates_tenants_tenant_id"); - - b.HasOne("Tiku.Domain.Identity.User", null) - .WithMany() - .HasForeignKey("UpdatedBy") - .OnDelete(DeleteBehavior.SetNull) - .HasConstraintName("fk_tenant_role_templates_users_updated_by"); }); modelBuilder.Entity("Tiku.Domain.Tenancy.TenantSecret", b => diff --git a/Tiku.Infrastructure/Persistence/Migrations/20260728014412_InitialSchema.cs b/Tiku.Infrastructure/Persistence/Migrations/20260728031410_InitialSchema.cs similarity index 98% rename from Tiku.Infrastructure/Persistence/Migrations/20260728014412_InitialSchema.cs rename to Tiku.Infrastructure/Persistence/Migrations/20260728031410_InitialSchema.cs index e70ae85..c6eda3b 100644 --- a/Tiku.Infrastructure/Persistence/Migrations/20260728014412_InitialSchema.cs +++ b/Tiku.Infrastructure/Persistence/Migrations/20260728031410_InitialSchema.cs @@ -1,7 +1,7 @@ using System; using System.Text.Json; using Microsoft.EntityFrameworkCore.Migrations; -using Tiku.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable @@ -38,6 +38,20 @@ namespace Tiku.Infrastructure.Persistence.Migrations table.UniqueConstraint("ak_backend_permissions_code", x => x.code); }); + migrationBuilder.CreateTable( + name: "data_protection_keys", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + friendly_name = table.Column(type: "text", nullable: true), + xml = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_data_protection_keys", x => x.id); + }); + migrationBuilder.CreateTable( name: "platform_backend_roles", columns: table => new @@ -137,19 +151,31 @@ namespace Tiku.Infrastructure.Persistence.Migrations { id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), legacy_id = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), - username = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), - email = table.Column(type: "citext", maxLength: 320, nullable: true), phone = table.Column(type: "character varying(32)", maxLength: 32, nullable: true), name = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), avatar_url = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), primary_role = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), score = table.Column(type: "integer", nullable: false), last_seen_at = table.Column(type: "timestamp with time zone", nullable: true), - legacy_password_hash = table.Column(type: "character varying(512)", maxLength: 512, nullable: true), - password_migration_required = table.Column(type: "boolean", nullable: false), + status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + force_password_change = table.Column(type: "boolean", nullable: false), raw_profile = table.Column(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"), created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), - updated_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + updated_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + user_name = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + normalized_user_name = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + email = table.Column(type: "citext", maxLength: 320, nullable: true), + normalized_email = table.Column(type: "character varying(320)", maxLength: 320, nullable: true), + email_confirmed = table.Column(type: "boolean", nullable: false), + password_hash = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: true), + security_stamp = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + concurrency_stamp = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + phone_number = table.Column(type: "character varying(32)", maxLength: 32, nullable: true), + phone_number_confirmed = table.Column(type: "boolean", nullable: false), + two_factor_enabled = table.Column(type: "boolean", nullable: false), + lockout_end = table.Column(type: "timestamp with time zone", nullable: true), + lockout_enabled = table.Column(type: "boolean", nullable: false), + access_failed_count = table.Column(type: "integer", nullable: false) }, constraints: table => { @@ -265,6 +291,27 @@ namespace Tiku.Infrastructure.Persistence.Migrations onDelete: ReferentialAction.SetNull); }); + migrationBuilder.CreateTable( + name: "user_claims", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + user_id = table.Column(type: "uuid", nullable: false), + claim_type = table.Column(type: "text", nullable: true), + claim_value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_user_claims", x => x.id); + table.ForeignKey( + name: "fk_user_claims_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateTable( name: "user_identities", columns: table => new @@ -277,7 +324,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations open_id = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), phone = table.Column(type: "character varying(32)", maxLength: 32, nullable: true), email = table.Column(type: "citext", maxLength: 320, nullable: true), - secret_payload = table.Column(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"), created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), updated_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") }, @@ -292,6 +338,46 @@ namespace Tiku.Infrastructure.Persistence.Migrations onDelete: ReferentialAction.Cascade); }); + migrationBuilder.CreateTable( + name: "user_logins", + columns: table => new + { + login_provider = table.Column(type: "text", nullable: false), + provider_key = table.Column(type: "text", nullable: false), + provider_display_name = table.Column(type: "text", nullable: true), + user_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_user_logins", x => new { x.login_provider, x.provider_key }); + table.ForeignKey( + name: "fk_user_logins_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "user_tokens", + columns: table => new + { + user_id = table.Column(type: "uuid", nullable: false), + login_provider = table.Column(type: "text", nullable: false), + name = table.Column(type: "text", nullable: false), + value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_user_tokens", x => new { x.user_id, x.login_provider, x.name }); + table.ForeignKey( + name: "fk_user_tokens_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateTable( name: "platform_backend_role_menus", columns: table => new @@ -403,6 +489,42 @@ namespace Tiku.Infrastructure.Persistence.Migrations onDelete: ReferentialAction.SetNull); }); + migrationBuilder.CreateTable( + name: "auth_challenges", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + user_id = table.Column(type: "uuid", nullable: false), + realm = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + tenant_id = table.Column(type: "uuid", nullable: true), + purpose = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + token_hash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + security_stamp = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + provider = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + expires_at = table.Column(type: "timestamp with time zone", nullable: false), + consumed_at = table.Column(type: "timestamp with time zone", nullable: true), + ip_address = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + user_agent = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("pk_auth_challenges", x => x.id); + table.CheckConstraint("ck_auth_challenges_realm_tenant", "(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)"); + table.ForeignKey( + name: "fk_auth_challenges_tenants_tenant_id", + column: x => x.tenant_id, + principalTable: "tenants", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_auth_challenges_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateTable( name: "auth_login_events", columns: table => new @@ -428,7 +550,7 @@ namespace Tiku.Infrastructure.Persistence.Migrations column: x => x.tenant_id, principalTable: "tenants", principalColumn: "id", - onDelete: ReferentialAction.Cascade); + onDelete: ReferentialAction.SetNull); table.ForeignKey( name: "fk_auth_login_events_users_user_id", column: x => x.user_id, @@ -442,22 +564,29 @@ namespace Tiku.Infrastructure.Persistence.Migrations columns: table => new { id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + realm = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + tenant_id = table.Column(type: "uuid", nullable: true), user_id = table.Column(type: "uuid", nullable: false), + token_family_id = table.Column(type: "uuid", nullable: false), + parent_session_id = table.Column(type: "uuid", nullable: true), + replaced_by_session_id = table.Column(type: "uuid", nullable: true), token_hash = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + security_stamp = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + mfa_satisfied = table.Column(type: "boolean", nullable: false), provider = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), expires_at = table.Column(type: "timestamp with time zone", nullable: false), revoked_at = table.Column(type: "timestamp with time zone", nullable: true), + revoked_reason = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), ip_address = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), user_agent = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), metadata = table.Column(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"), - tenant_id = table.Column(type: "uuid", nullable: false), created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), updated_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") }, constraints: table => { table.PrimaryKey("pk_auth_sessions", x => x.id); - table.UniqueConstraint("ak_auth_sessions_tenant_id_id", x => new { x.tenant_id, x.id }); + table.CheckConstraint("ck_auth_sessions_realm_tenant", "(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)"); table.ForeignKey( name: "fk_auth_sessions_tenants_tenant_id", column: x => x.tenant_id, @@ -1186,7 +1315,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations constraints: table => { table.PrimaryKey("pk_sms_verification_codes", x => x.id); - table.UniqueConstraint("ak_sms_verification_codes_tenant_id_id", x => new { x.tenant_id, x.id }); table.CheckConstraint("ck_sms_verification_codes_attempts", "attempts >= 0"); table.ForeignKey( name: "fk_sms_verification_codes_tenants_tenant_id", @@ -1498,50 +1626,34 @@ namespace Tiku.Infrastructure.Persistence.Migrations }); migrationBuilder.CreateTable( - name: "tenant_role_templates", + name: "tenant_memberships", columns: table => new { id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), - code = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), - name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), - description = table.Column(type: "text", nullable: true), - base_role = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + role = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), - permissions = table.Column(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"), - menu_permissions = table.Column(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"), - module_permissions = table.Column(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"), - field_permissions = table.Column(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"), - data_scope = table.Column(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"), - is_system = table.Column(type: "boolean", nullable: false), - sort_order = table.Column(type: "integer", nullable: false), - created_by = table.Column(type: "uuid", nullable: true), - updated_by = table.Column(type: "uuid", nullable: true), + legacy_role = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), tenant_id = table.Column(type: "uuid", nullable: false), created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), updated_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") }, constraints: table => { - table.PrimaryKey("pk_tenant_role_templates", x => x.id); - table.UniqueConstraint("ak_tenant_role_templates_tenant_id_id", x => new { x.tenant_id, x.id }); + table.PrimaryKey("pk_tenant_memberships", x => x.id); + table.UniqueConstraint("ak_tenant_memberships_tenant_id_id", x => new { x.tenant_id, x.id }); table.ForeignKey( - name: "fk_tenant_role_templates_tenants_tenant_id", + name: "fk_tenant_memberships_tenants_tenant_id", column: x => x.tenant_id, principalTable: "tenants", principalColumn: "id", onDelete: ReferentialAction.Cascade); table.ForeignKey( - name: "fk_tenant_role_templates_users_created_by", - column: x => x.created_by, + name: "fk_tenant_memberships_users_user_id", + column: x => x.user_id, principalTable: "users", principalColumn: "id", - onDelete: ReferentialAction.SetNull); - table.ForeignKey( - name: "fk_tenant_role_templates_users_updated_by", - column: x => x.updated_by, - principalTable: "users", - principalColumn: "id", - onDelete: ReferentialAction.SetNull); + onDelete: ReferentialAction.Cascade); }); migrationBuilder.CreateTable( @@ -2885,45 +2997,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations onDelete: ReferentialAction.SetNull); }); - migrationBuilder.CreateTable( - name: "tenant_memberships", - columns: table => new - { - id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), - user_id = table.Column(type: "uuid", nullable: false), - role_template_id = table.Column(type: "uuid", nullable: true), - role = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), - status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), - permissions = table.Column(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"), - legacy_role = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), - tenant_id = table.Column(type: "uuid", nullable: false), - created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), - updated_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") - }, - constraints: table => - { - table.PrimaryKey("pk_tenant_memberships", x => x.id); - table.UniqueConstraint("ak_tenant_memberships_tenant_id_id", x => new { x.tenant_id, x.id }); - table.ForeignKey( - name: "fk_tenant_memberships_tenant_role_templates_tenant_id_role_tem~", - columns: x => new { x.tenant_id, x.role_template_id }, - principalTable: "tenant_role_templates", - principalColumns: new[] { "tenant_id", "id" }, - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "fk_tenant_memberships_tenants_tenant_id", - column: x => x.tenant_id, - principalTable: "tenants", - principalColumn: "id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "fk_tenant_memberships_users_user_id", - column: x => x.user_id, - principalTable: "users", - principalColumn: "id", - onDelete: ReferentialAction.Cascade); - }); - migrationBuilder.CreateTable( name: "content_nodes", columns: table => new @@ -6392,6 +6465,22 @@ namespace Tiku.Infrastructure.Persistence.Migrations table: "audit_logs", columns: new[] { "tenant_id", "target_type", "target_id", "created_at" }); + migrationBuilder.CreateIndex( + name: "ix_auth_challenges_tenant_id", + table: "auth_challenges", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "ix_auth_challenges_token_hash", + table: "auth_challenges", + column: "token_hash", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_auth_challenges_user_id_purpose_expires_at", + table: "auth_challenges", + columns: new[] { "user_id", "purpose", "expires_at" }); + migrationBuilder.CreateIndex( name: "ix_auth_login_events_tenant_id_user_id_created_at", table: "auth_login_events", @@ -6403,11 +6492,21 @@ namespace Tiku.Infrastructure.Persistence.Migrations column: "user_id"); migrationBuilder.CreateIndex( - name: "ix_auth_sessions_tenant_id_user_id_expires_at", + name: "ix_auth_sessions_realm_tenant_id_user_id_expires_at", table: "auth_sessions", - columns: new[] { "tenant_id", "user_id", "expires_at" }, + columns: new[] { "realm", "tenant_id", "user_id", "expires_at" }, filter: "revoked_at is null"); + migrationBuilder.CreateIndex( + name: "ix_auth_sessions_tenant_id", + table: "auth_sessions", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "ix_auth_sessions_token_family_id_revoked_at", + table: "auth_sessions", + columns: new[] { "token_family_id", "revoked_at" }); + migrationBuilder.CreateIndex( name: "ix_auth_sessions_token_hash", table: "auth_sessions", @@ -8609,11 +8708,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations table: "tenant_invoices", columns: new[] { "tenant_id", "status", "due_date" }); - migrationBuilder.CreateIndex( - name: "ix_tenant_memberships_tenant_id_role_template_id", - table: "tenant_memberships", - columns: new[] { "tenant_id", "role_template_id" }); - migrationBuilder.CreateIndex( name: "ix_tenant_memberships_tenant_id_user_id_role", table: "tenant_memberships", @@ -8656,27 +8750,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations table: "tenant_question_references", columns: new[] { "question_owner_tenant_id", "question_id" }); - migrationBuilder.CreateIndex( - name: "ix_tenant_role_templates_created_by", - table: "tenant_role_templates", - column: "created_by"); - - migrationBuilder.CreateIndex( - name: "ix_tenant_role_templates_tenant_id_code", - table: "tenant_role_templates", - columns: new[] { "tenant_id", "code" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "ix_tenant_role_templates_tenant_id_status_sort_order", - table: "tenant_role_templates", - columns: new[] { "tenant_id", "status", "sort_order" }); - - migrationBuilder.CreateIndex( - name: "ix_tenant_role_templates_updated_by", - table: "tenant_role_templates", - column: "updated_by"); - migrationBuilder.CreateIndex( name: "ix_tenant_secrets_tenant_id_purpose_provider_secret_key", table: "tenant_secrets", @@ -8850,6 +8923,11 @@ namespace Tiku.Infrastructure.Persistence.Migrations table: "user_badges", column: "user_id"); + migrationBuilder.CreateIndex( + name: "ix_user_claims_user_id", + table: "user_claims", + column: "user_id"); + migrationBuilder.CreateIndex( name: "ix_user_identities_provider_provider_subject", table: "user_identities", @@ -8861,6 +8939,11 @@ namespace Tiku.Infrastructure.Persistence.Migrations table: "user_identities", column: "user_id"); + migrationBuilder.CreateIndex( + name: "ix_user_logins_user_id", + table: "user_logins", + column: "user_id"); + migrationBuilder.CreateIndex( name: "ix_user_notifications_created_by", table: "user_notifications", @@ -8942,10 +9025,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations column: "user_id"); migrationBuilder.CreateIndex( - name: "ix_users_email", + name: "email_index", table: "users", - column: "email", - unique: true); + column: "normalized_email"); migrationBuilder.CreateIndex( name: "ix_users_legacy_id", @@ -8960,9 +9042,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations unique: true); migrationBuilder.CreateIndex( - name: "ix_users_username", + name: "user_name_index", table: "users", - column: "username", + column: "normalized_user_name", unique: true); migrationBuilder.CreateIndex( @@ -9347,6 +9429,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations migrationBuilder.DropTable( name: "app_assets"); + migrationBuilder.DropTable( + name: "auth_challenges"); + migrationBuilder.DropTable( name: "auth_login_events"); @@ -9395,6 +9480,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations migrationBuilder.DropTable( name: "dashboard_daily_stats"); + migrationBuilder.DropTable( + name: "data_protection_keys"); + migrationBuilder.DropTable( name: "entitlements"); @@ -9578,15 +9666,24 @@ namespace Tiku.Infrastructure.Persistence.Migrations migrationBuilder.DropTable( name: "user_badges"); + migrationBuilder.DropTable( + name: "user_claims"); + migrationBuilder.DropTable( name: "user_identities"); + migrationBuilder.DropTable( + name: "user_logins"); + migrationBuilder.DropTable( name: "user_notifications"); migrationBuilder.DropTable( name: "user_score_events"); + migrationBuilder.DropTable( + name: "user_tokens"); + migrationBuilder.DropTable( name: "user_word_favorites"); @@ -9662,9 +9759,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations migrationBuilder.DropTable( name: "tenant_backend_roles"); - migrationBuilder.DropTable( - name: "tenant_role_templates"); - migrationBuilder.DropTable( name: "tenant_classes"); diff --git a/Tiku.Infrastructure/Persistence/Migrations/TikuDbContextModelSnapshot.cs b/Tiku.Infrastructure/Persistence/Migrations/TikuDbContextModelSnapshot.cs index b01b256..0842a50 100644 --- a/Tiku.Infrastructure/Persistence/Migrations/TikuDbContextModelSnapshot.cs +++ b/Tiku.Infrastructure/Persistence/Migrations/TikuDbContextModelSnapshot.cs @@ -25,6 +25,110 @@ namespace Tiku.Infrastructure.Persistence.Migrations NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree"); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text") + .HasColumnName("friendly_name"); + + b.Property("Xml") + .HasColumnType("text") + .HasColumnName("xml"); + + b.HasKey("Id") + .HasName("pk_data_protection_keys"); + + b.ToTable("data_protection_keys", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_claims_user_id"); + + b.ToTable("user_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("text") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_logins_user_id"); + + b.ToTable("user_logins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_user_tokens"); + + b.ToTable("user_tokens", (string)null); + }); + modelBuilder.Entity("Tiku.Domain.Catalog.Category", b => { b.Property("Id") @@ -7915,11 +8019,21 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasColumnName("id") .HasDefaultValueSql("gen_random_uuid()"); + b.Property("AccessFailedCount") + .HasColumnType("integer") + .HasColumnName("access_failed_count"); + b.Property("AvatarUrl") .HasMaxLength(2048) .HasColumnType("character varying(2048)") .HasColumnName("avatar_url"); + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("concurrency_stamp"); + b.Property("CreatedAt") .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone") @@ -7931,6 +8045,14 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasColumnType("citext") .HasColumnName("email"); + b.Property("EmailConfirmed") + .HasColumnType("boolean") + .HasColumnName("email_confirmed"); + + b.Property("ForcePasswordChange") + .HasColumnType("boolean") + .HasColumnName("force_password_change"); + b.Property("LastSeenAt") .HasColumnType("timestamp with time zone") .HasColumnName("last_seen_at"); @@ -7940,25 +8062,48 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasColumnType("character varying(64)") .HasColumnName("legacy_id"); - b.Property("LegacyPasswordHash") - .HasMaxLength(512) - .HasColumnType("character varying(512)") - .HasColumnName("legacy_password_hash"); + b.Property("LockoutEnabled") + .HasColumnType("boolean") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); b.Property("Name") .HasMaxLength(200) .HasColumnType("character varying(200)") .HasColumnName("name"); - b.Property("PasswordMigrationRequired") - .HasColumnType("boolean") - .HasColumnName("password_migration_required"); + b.Property("NormalizedEmail") + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("normalized_user_name"); + + b.Property("PasswordHash") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("password_hash"); b.Property("Phone") .HasMaxLength(32) .HasColumnType("character varying(32)") .HasColumnName("phone"); + b.Property("PhoneNumber") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean") + .HasColumnName("phone_number_confirmed"); + b.Property("PrimaryRole") .IsRequired() .HasMaxLength(50) @@ -7975,36 +8120,50 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasColumnType("integer") .HasColumnName("score"); + b.Property("SecurityStamp") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("security_stamp"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean") + .HasColumnName("two_factor_enabled"); + b.Property("UpdatedAt") .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone") .HasColumnName("updated_at") .HasDefaultValueSql("now()"); - b.Property("Username") + b.Property("UserName") .HasMaxLength(100) .HasColumnType("character varying(100)") - .HasColumnName("username"); + .HasColumnName("user_name"); b.HasKey("Id") .HasName("pk_users"); - b.HasIndex("Email") - .IsUnique() - .HasDatabaseName("ix_users_email"); - b.HasIndex("LegacyId") .IsUnique() .HasDatabaseName("ix_users_legacy_id"); + b.HasIndex("NormalizedEmail") + .HasDatabaseName("email_index"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("user_name_index"); + b.HasIndex("Phone") .IsUnique() .HasDatabaseName("ix_users_phone"); - b.HasIndex("Username") - .IsUnique() - .HasDatabaseName("ix_users_username"); - b.ToTable("users", (string)null); }); @@ -8049,12 +8208,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasColumnType("character varying(255)") .HasColumnName("provider_subject"); - b.Property("SecretPayload") - .ValueGeneratedOnAdd() - .HasColumnType("jsonb") - .HasColumnName("secret_payload") - .HasDefaultValueSql("'{}'::jsonb"); - b.Property("UnionId") .HasMaxLength(255) .HasColumnType("character varying(255)") @@ -12648,6 +12801,95 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.ToTable("question_versions", (string)null); }); + modelBuilder.Entity("Tiku.Domain.Tenancy.AuthChallenge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("consumed_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IpAddress") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("ip_address"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("provider"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("purpose"); + + b.Property("Realm") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("realm"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("security_stamp"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UserAgent") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_auth_challenges"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_auth_challenges_tenant_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_auth_challenges_token_hash"); + + b.HasIndex("UserId", "Purpose", "ExpiresAt") + .HasDatabaseName("ix_auth_challenges_user_id_purpose_expires_at"); + + b.ToTable("auth_challenges", null, t => + { + t.HasCheckConstraint("ck_auth_challenges_realm_tenant", "(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)"); + }); + }); + modelBuilder.Entity("Tiku.Domain.Tenancy.AuthLoginEvent", b => { b.Property("Id") @@ -12752,20 +12994,53 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasColumnName("metadata") .HasDefaultValueSql("'{}'::jsonb"); + b.Property("MfaSatisfied") + .HasColumnType("boolean") + .HasColumnName("mfa_satisfied"); + + b.Property("ParentSessionId") + .HasColumnType("uuid") + .HasColumnName("parent_session_id"); + b.Property("Provider") .IsRequired() .HasMaxLength(50) .HasColumnType("character varying(50)") .HasColumnName("provider"); + b.Property("Realm") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("realm"); + + b.Property("ReplacedBySessionId") + .HasColumnType("uuid") + .HasColumnName("replaced_by_session_id"); + b.Property("RevokedAt") .HasColumnType("timestamp with time zone") .HasColumnName("revoked_at"); - b.Property("TenantId") + b.Property("RevokedReason") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("revoked_reason"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("security_stamp"); + + b.Property("TenantId") .HasColumnType("uuid") .HasColumnName("tenant_id"); + b.Property("TokenFamilyId") + .HasColumnType("uuid") + .HasColumnName("token_family_id"); + b.Property("TokenHash") .IsRequired() .HasMaxLength(256) @@ -12790,8 +13065,8 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.HasKey("Id") .HasName("pk_auth_sessions"); - b.HasAlternateKey("TenantId", "Id") - .HasName("ak_auth_sessions_tenant_id_id"); + b.HasIndex("TenantId") + .HasDatabaseName("ix_auth_sessions_tenant_id"); b.HasIndex("TokenHash") .IsUnique() @@ -12801,11 +13076,17 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.HasIndex("UserId") .HasDatabaseName("ix_auth_sessions_user_id"); - b.HasIndex("TenantId", "UserId", "ExpiresAt") - .HasDatabaseName("ix_auth_sessions_tenant_id_user_id_expires_at") + b.HasIndex("TokenFamilyId", "RevokedAt") + .HasDatabaseName("ix_auth_sessions_token_family_id_revoked_at"); + + b.HasIndex("Realm", "TenantId", "UserId", "ExpiresAt") + .HasDatabaseName("ix_auth_sessions_realm_tenant_id_user_id_expires_at") .HasFilter("revoked_at is null"); - b.ToTable("auth_sessions", (string)null); + b.ToTable("auth_sessions", null, t => + { + t.HasCheckConstraint("ck_auth_sessions_realm_tenant", "(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)"); + }); }); modelBuilder.Entity("Tiku.Domain.Tenancy.SmsSendRateLimit", b => @@ -12931,9 +13212,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.HasKey("Id") .HasName("pk_sms_verification_codes"); - b.HasAlternateKey("TenantId", "Id") - .HasName("ak_sms_verification_codes_tenant_id_id"); - b.HasIndex("TenantId", "Phone", "Purpose") .IsUnique() .HasDatabaseName("ix_sms_verification_codes_tenant_id_phone_purpose") @@ -13623,22 +13901,12 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasColumnType("character varying(50)") .HasColumnName("legacy_role"); - b.Property("Permissions") - .ValueGeneratedOnAdd() - .HasColumnType("jsonb") - .HasColumnName("permissions") - .HasDefaultValueSql("'{}'::jsonb"); - b.Property("Role") .IsRequired() .HasMaxLength(32) .HasColumnType("character varying(32)") .HasColumnName("role"); - b.Property("RoleTemplateId") - .HasColumnType("uuid") - .HasColumnName("role_template_id"); - b.Property("Status") .IsRequired() .HasMaxLength(32) @@ -13668,9 +13936,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.HasIndex("UserId") .HasDatabaseName("ix_tenant_memberships_user_id"); - b.HasIndex("TenantId", "RoleTemplateId") - .HasDatabaseName("ix_tenant_memberships_tenant_id_role_template_id"); - b.HasIndex("TenantId", "UserId", "Role") .IsUnique() .HasDatabaseName("ix_tenant_memberships_tenant_id_user_id_role"); @@ -13678,126 +13943,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.ToTable("tenant_memberships", (string)null); }); - modelBuilder.Entity("Tiku.Domain.Tenancy.TenantRoleTemplate", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasColumnName("id") - .HasDefaultValueSql("gen_random_uuid()"); - - b.Property("BaseRole") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasColumnName("base_role"); - - b.Property("Code") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)") - .HasColumnName("code"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("now()"); - - b.Property("CreatedBy") - .HasColumnType("uuid") - .HasColumnName("created_by"); - - b.Property("DataScope") - .ValueGeneratedOnAdd() - .HasColumnType("jsonb") - .HasColumnName("data_scope") - .HasDefaultValueSql("'{}'::jsonb"); - - b.Property("Description") - .HasColumnType("text") - .HasColumnName("description"); - - b.Property("FieldPermissions") - .ValueGeneratedOnAdd() - .HasColumnType("jsonb") - .HasColumnName("field_permissions") - .HasDefaultValueSql("'{}'::jsonb"); - - b.Property("IsSystem") - .HasColumnType("boolean") - .HasColumnName("is_system"); - - b.Property("MenuPermissions") - .ValueGeneratedOnAdd() - .HasColumnType("jsonb") - .HasColumnName("menu_permissions") - .HasDefaultValueSql("'{}'::jsonb"); - - b.Property("ModulePermissions") - .ValueGeneratedOnAdd() - .HasColumnType("jsonb") - .HasColumnName("module_permissions") - .HasDefaultValueSql("'{}'::jsonb"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasColumnName("name"); - - b.Property("Permissions") - .ValueGeneratedOnAdd() - .HasColumnType("jsonb") - .HasColumnName("permissions") - .HasDefaultValueSql("'{}'::jsonb"); - - b.Property("SortOrder") - .HasColumnType("integer") - .HasColumnName("sort_order"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasColumnName("status"); - - b.Property("TenantId") - .HasColumnType("uuid") - .HasColumnName("tenant_id"); - - b.Property("UpdatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("updated_at") - .HasDefaultValueSql("now()"); - - b.Property("UpdatedBy") - .HasColumnType("uuid") - .HasColumnName("updated_by"); - - b.HasKey("Id") - .HasName("pk_tenant_role_templates"); - - b.HasAlternateKey("TenantId", "Id") - .HasName("ak_tenant_role_templates_tenant_id_id"); - - b.HasIndex("CreatedBy") - .HasDatabaseName("ix_tenant_role_templates_created_by"); - - b.HasIndex("UpdatedBy") - .HasDatabaseName("ix_tenant_role_templates_updated_by"); - - b.HasIndex("TenantId", "Code") - .IsUnique() - .HasDatabaseName("ix_tenant_role_templates_tenant_id_code"); - - b.HasIndex("TenantId", "Status", "SortOrder") - .HasDatabaseName("ix_tenant_role_templates_tenant_id_status_sort_order"); - - b.ToTable("tenant_role_templates", (string)null); - }); - modelBuilder.Entity("Tiku.Domain.Tenancy.TenantSecret", b => { b.Property("Id") @@ -14153,6 +14298,36 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.ToTable("tenant_student_notes", (string)null); }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_claims_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_logins_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_tokens_users_user_id"); + }); + modelBuilder.Entity("Tiku.Domain.Catalog.Category", b => { b.HasOne("Tiku.Domain.Tenancy.Tenant", null) @@ -17204,12 +17379,28 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasConstraintName("fk_question_versions_questions_tenant_id_question_id"); }); - modelBuilder.Entity("Tiku.Domain.Tenancy.AuthLoginEvent", b => + modelBuilder.Entity("Tiku.Domain.Tenancy.AuthChallenge", b => { b.HasOne("Tiku.Domain.Tenancy.Tenant", null) .WithMany() .HasForeignKey("TenantId") .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_auth_challenges_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_auth_challenges_users_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.AuthLoginEvent", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.SetNull) .IsRequired() .HasConstraintName("fk_auth_login_events_tenants_tenant_id"); @@ -17226,7 +17417,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations .WithMany() .HasForeignKey("TenantId") .OnDelete(DeleteBehavior.Cascade) - .IsRequired() .HasConstraintName("fk_auth_sessions_tenants_tenant_id"); b.HasOne("Tiku.Domain.Identity.User", null) @@ -17375,35 +17565,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired() .HasConstraintName("fk_tenant_memberships_users_user_id"); - - b.HasOne("Tiku.Domain.Tenancy.TenantRoleTemplate", null) - .WithMany() - .HasForeignKey("TenantId", "RoleTemplateId") - .HasPrincipalKey("TenantId", "Id") - .OnDelete(DeleteBehavior.Restrict) - .HasConstraintName("fk_tenant_memberships_tenant_role_templates_tenant_id_role_tem~"); - }); - - modelBuilder.Entity("Tiku.Domain.Tenancy.TenantRoleTemplate", b => - { - b.HasOne("Tiku.Domain.Identity.User", null) - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.SetNull) - .HasConstraintName("fk_tenant_role_templates_users_created_by"); - - b.HasOne("Tiku.Domain.Tenancy.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_tenant_role_templates_tenants_tenant_id"); - - b.HasOne("Tiku.Domain.Identity.User", null) - .WithMany() - .HasForeignKey("UpdatedBy") - .OnDelete(DeleteBehavior.SetNull) - .HasConstraintName("fk_tenant_role_templates_users_updated_by"); }); modelBuilder.Entity("Tiku.Domain.Tenancy.TenantSecret", b => diff --git a/Tiku.Infrastructure/Persistence/TikuDbContext.cs b/Tiku.Infrastructure/Persistence/TikuDbContext.cs index 8e2b809..bff1790 100644 --- a/Tiku.Infrastructure/Persistence/TikuDbContext.cs +++ b/Tiku.Infrastructure/Persistence/TikuDbContext.cs @@ -1,4 +1,7 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity; using System.Reflection; using Tiku.Application.Security; using Tiku.Domain.Catalog; @@ -18,7 +21,7 @@ namespace Tiku.Infrastructure.Persistence; public sealed class TikuDbContext( DbContextOptions options, - ITenantContext tenantContext) : DbContext(options) + ITenantContext tenantContext) : IdentityUserContext(options), IDataProtectionKeyContext { public TikuDbContext(DbContextOptions options) : this(options, CreateToolingTenantContext()) @@ -37,7 +40,8 @@ public sealed class TikuDbContext( return context; } public DbSet Tenants => Set(); - public DbSet Users => Set(); + public new DbSet Users => Set(); + public DbSet DataProtectionKeys => Set(); public DbSet UserIdentities => Set(); public DbSet TenantMemberships => Set(); public DbSet TenantDomains => Set(); @@ -49,8 +53,8 @@ public sealed class TikuDbContext( public DbSet SmsVerificationCodes => Set(); public DbSet AuthLoginEvents => Set(); public DbSet AuthSessions => Set(); + public DbSet AuthChallenges => Set(); public DbSet SmsSendRateLimits => Set(); - public DbSet TenantRoleTemplates => Set(); public DbSet TenantClasses => Set(); public DbSet TenantClassMembers => Set(); public DbSet TenantStudentNotes => Set(); @@ -186,9 +190,14 @@ public sealed class TikuDbContext( protected override void OnModelCreating(ModelBuilder modelBuilder) { + base.OnModelCreating(modelBuilder); + modelBuilder.Entity>().ToTable("user_claims"); + modelBuilder.Entity>().ToTable("user_logins"); + modelBuilder.Entity>().ToTable("user_tokens"); modelBuilder.HasPostgresExtension("citext"); modelBuilder.HasPostgresExtension("ltree"); modelBuilder.ApplyConfigurationsFromAssembly(typeof(TikuDbContext).Assembly); + modelBuilder.Entity().ToTable("data_protection_keys"); ApplyTenantQueryFilters(modelBuilder); ValidateTenantModel(modelBuilder); modelBuilder.UseSnakeCaseIdentifiers(); @@ -298,6 +307,15 @@ public sealed class TikuDbContext( { var now = DateTimeOffset.UtcNow; + foreach (var entry in ChangeTracker.Entries().Where(entry => entry.State == EntityState.Modified)) + { + if (entry.Property(user => user.Status).IsModified || + entry.Property(user => user.PasswordHash).IsModified) + { + entry.Entity.SecurityStamp = Guid.NewGuid().ToString("N"); + } + } + foreach (var entry in ChangeTracker.Entries()) { if (entry.State == EntityState.Added) diff --git a/Tiku.Infrastructure/Profile/ProfileService.cs b/Tiku.Infrastructure/Profile/ProfileService.cs index 60628f4..26ab8f3 100644 --- a/Tiku.Infrastructure/Profile/ProfileService.cs +++ b/Tiku.Infrastructure/Profile/ProfileService.cs @@ -392,7 +392,7 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService return new StudentProfileItem( profile.Id, user.Id, - user.Username, + user.UserName, user.Phone, user.Email, user.Name, diff --git a/Tiku.Infrastructure/Security/CurrentAccessContext.cs b/Tiku.Infrastructure/Security/CurrentAccessContext.cs new file mode 100644 index 0000000..04a79b9 --- /dev/null +++ b/Tiku.Infrastructure/Security/CurrentAccessContext.cs @@ -0,0 +1,141 @@ +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Security; +using Tiku.Domain.Identity; +using Tiku.Domain.Operations; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Security; + +internal sealed class CurrentAccessContext( + ICurrentUser currentUser, + ITenantContext tenantContext, + TikuDbContext dbContext) : ICurrentAccessContext +{ + private Task? snapshotTask; + + public Task GetAsync(CancellationToken cancellationToken = default) + { + // The context is scoped to one request. Do not allow an aborted authorization + // check to poison the cached access snapshot used later in that request. + return snapshotTask ??= LoadAsync(CancellationToken.None); + } + + private async Task LoadAsync(CancellationToken cancellationToken) + { + if (!currentUser.IsAuthenticated || currentUser.UserId is not { } userId) + { + return Empty(); + } + + var isUserActive = await dbContext.Users.AsNoTracking() + .AnyAsync(user => user.Id == userId && user.Status == UserStatus.Active, cancellationToken); + if (!isUserActive) + { + return new CurrentAccessSnapshot( + userId, + tenantContext.TenantId, + false, + false, + new HashSet(StringComparer.Ordinal), + new HashSet(StringComparer.Ordinal), + CurrentDataScope.Self); + } + + var platformPermissions = await LoadPlatformPermissionsAsync(userId, cancellationToken); + if (tenantContext.TenantId is not { } tenantId) + { + return new CurrentAccessSnapshot( + userId, + null, + true, + false, + new HashSet(StringComparer.Ordinal), + platformPermissions, + CurrentDataScope.Self); + } + + var isTenantActive = await dbContext.Tenants.AsNoTracking() + .AnyAsync(tenant => tenant.Id == tenantId && tenant.Status == TenantStatus.Active, cancellationToken); + var isActiveMember = isTenantActive && await dbContext.TenantMemberships.AsNoTracking() + .AnyAsync( + membership => membership.TenantId == tenantId && + membership.UserId == userId && + membership.Status == MembershipStatus.Active, + cancellationToken); + + if (!isActiveMember) + { + return new CurrentAccessSnapshot( + userId, + tenantId, + true, + false, + new HashSet(StringComparer.Ordinal), + platformPermissions, + CurrentDataScope.Self); + } + + var tenantRoles = await ( + from userRole in dbContext.TenantBackendUserRoles.AsNoTracking() + join role in dbContext.TenantBackendRoles.AsNoTracking() on userRole.RoleId equals role.Id + where userRole.TenantId == tenantId && + userRole.UserId == userId && + role.Status == BackendRoleStatus.Active + select new { role.Id, role.DataScope }) + .ToArrayAsync(cancellationToken); + + var roleIds = tenantRoles.Select(role => role.Id).ToArray(); + var tenantPermissions = roleIds.Length == 0 + ? new HashSet(StringComparer.Ordinal) + : (await ( + from binding in dbContext.TenantBackendRolePermissions.AsNoTracking() + join permission in dbContext.BackendPermissions.AsNoTracking() + on binding.PermissionCode equals permission.Code + where binding.TenantId == tenantId && + roleIds.Contains(binding.RoleId) && + (permission.Area == BackendPermissionArea.Tenant || permission.Area == BackendPermissionArea.Both) + select binding.PermissionCode) + .Distinct() + .ToArrayAsync(cancellationToken)) + .ToHashSet(StringComparer.Ordinal); + + return new CurrentAccessSnapshot( + userId, + tenantId, + true, + true, + tenantPermissions, + platformPermissions, + CurrentDataScope.Merge(tenantRoles.Select(role => role.DataScope))); + } + + private async Task> LoadPlatformPermissionsAsync(Guid userId, CancellationToken cancellationToken) + { + return (await ( + from userRole in dbContext.PlatformBackendUserRoles.AsNoTracking() + join role in dbContext.PlatformBackendRoles.AsNoTracking() on userRole.RoleId equals role.Id + join binding in dbContext.PlatformBackendRolePermissions.AsNoTracking() on role.Id equals binding.RoleId + join permission in dbContext.BackendPermissions.AsNoTracking() + on binding.PermissionCode equals permission.Code + where userRole.UserId == userId && + role.Status == BackendRoleStatus.Active && + (permission.Area == BackendPermissionArea.Platform || permission.Area == BackendPermissionArea.Both) + select binding.PermissionCode) + .Distinct() + .ToArrayAsync(cancellationToken)) + .ToHashSet(StringComparer.Ordinal); + } + + private CurrentAccessSnapshot Empty() + { + return new CurrentAccessSnapshot( + null, + tenantContext.TenantId, + false, + false, + new HashSet(StringComparer.Ordinal), + new HashSet(StringComparer.Ordinal), + CurrentDataScope.Self); + } +} diff --git a/Tiku.Infrastructure/Security/DataProtectionKeyRingOptions.cs b/Tiku.Infrastructure/Security/DataProtectionKeyRingOptions.cs new file mode 100644 index 0000000..4a896bb --- /dev/null +++ b/Tiku.Infrastructure/Security/DataProtectionKeyRingOptions.cs @@ -0,0 +1,62 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace Tiku.Infrastructure.Security; + +public sealed class DataProtectionKeyRingOptions +{ + public const string SectionName = "Security:DataProtection"; + + public string ApplicationName { get; set; } = "Tiku.Api"; + public string CertificatePath { get; set; } = string.Empty; + public string CertificatePassword { get; set; } = string.Empty; + + public static bool BeValid(DataProtectionKeyRingOptions options, bool requireCertificate) + { + return !string.IsNullOrWhiteSpace(options.ApplicationName) && + (!requireCertificate || !string.IsNullOrWhiteSpace(options.CertificatePath)); + } + + public X509Certificate2? LoadCertificate(bool requireCertificate) + { + if (string.IsNullOrWhiteSpace(CertificatePath)) + { + if (requireCertificate) + { + throw new InvalidOperationException( + "Data Protection certificate is required outside Development. " + + "Configure Security:DataProtection:CertificatePath or " + + "TIKU_DATA_PROTECTION_CERTIFICATE_PATH."); + } + + return null; + } + + try + { + var certificate = X509CertificateLoader.LoadPkcs12FromFile( + Path.GetFullPath(CertificatePath.Trim()), + CertificatePassword, + X509KeyStorageFlags.DefaultKeySet); + if (!certificate.HasPrivateKey) + { + certificate.Dispose(); + throw new InvalidOperationException( + "Data Protection certificate must contain a private key."); + } + + return certificate; + } + catch (InvalidOperationException) + { + throw; + } + catch (Exception exception) when ( + exception is CryptographicException or IOException or UnauthorizedAccessException) + { + throw new InvalidOperationException( + "Data Protection certificate could not be loaded from the configured PKCS#12 file.", + exception); + } + } +} diff --git a/Tiku.Infrastructure/Security/DataScopeQueryableExtensions.cs b/Tiku.Infrastructure/Security/DataScopeQueryableExtensions.cs new file mode 100644 index 0000000..497eb62 --- /dev/null +++ b/Tiku.Infrastructure/Security/DataScopeQueryableExtensions.cs @@ -0,0 +1,47 @@ +using System.Linq.Expressions; +using Tiku.Application.Security; + +namespace Tiku.Infrastructure.Security; + +internal static class DataScopeQueryableExtensions +{ + public static IQueryable ApplyDataScope( + this IQueryable query, + CurrentDataScope scope, + Expression>? selfPredicate, + Expression>? restrictedPredicate) + { + if (scope.Mode == DataScopeMode.All) + { + return query; + } + + Expression>? predicate = null; + if (scope.IncludesSelf && selfPredicate is not null) + { + predicate = selfPredicate; + } + + if (scope.Mode == DataScopeMode.Restricted && restrictedPredicate is not null) + { + predicate = predicate is null ? restrictedPredicate : OrElse(predicate, restrictedPredicate); + } + + return predicate is null ? query.Where(_ => false) : query.Where(predicate); + } + + private static Expression> OrElse( + Expression> left, + Expression> right) + { + var parameter = Expression.Parameter(typeof(TEntity), "entity"); + var leftBody = new ReplaceParameterVisitor(left.Parameters[0], parameter).Visit(left.Body)!; + var rightBody = new ReplaceParameterVisitor(right.Parameters[0], parameter).Visit(right.Body)!; + return Expression.Lambda>(Expression.OrElse(leftBody, rightBody), parameter); + } + + private sealed class ReplaceParameterVisitor(ParameterExpression source, ParameterExpression target) : ExpressionVisitor + { + protected override Expression VisitParameter(ParameterExpression node) => node == source ? target : base.VisitParameter(node); + } +} diff --git a/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs b/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs index 027b31e..bd69673 100644 --- a/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs +++ b/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs @@ -1,8 +1,10 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; +using Tiku.Application.Auth; using Tiku.Application.Content; using Tiku.Application.Notifications; +using Tiku.Application.Security; using Tiku.Application.Tenancy; using Tiku.Application.TenantAdmin; using Tiku.Domain.Catalog; @@ -12,60 +14,31 @@ using Tiku.Domain.Learning; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.TenantAdmin; public sealed class TenantAdminDirectService( TikuDbContext dbContext, ITenantExternalProviderConfigService providerConfigService, - INotificationProvider notificationProvider) : ITenantAdminDirectService + INotificationProvider notificationProvider, + ICurrentAccessContext currentAccessContext, + IAuthSessionStore sessionStore) : ITenantAdminDirectService { - private static readonly TenantAdminPermissionCatalogItem[] PermissionCatalog = - [ - new("tenant:overview:read", "租户概览"), - new("tenant:branding:write", "品牌配置"), - new("tenant:theme:read", "主题查看"), - new("tenant:theme:write", "主题预览/发布"), - new("tenant:settings:write", "公开设置"), - new("tenant:domains:read", "域名查看"), - new("tenant:domains:write", "域名管理"), - new("tenant:auth:read", "登录配置查看"), - new("tenant:auth:write", "登录配置管理"), - new("members:read", "成员查看"), - new("members:write", "成员管理"), - new("roles:read", "角色模板查看"), - new("roles:write", "角色模板管理"), - new("audit:read", "审计日志查看"), - new("classes:read", "班级查看"), - new("classes:write", "班级管理"), - new("students:read", "学生查看"), - new("students:write", "学生管理"), - new("students:status:write", "学生状态管理"), - new("students:notes:read", "学生备注查看"), - new("students:notes:write", "学生备注管理"), - new("students:followups:read", "学生跟进查看"), - new("students:followups:write", "学生跟进管理"), - new("content:*", "内容维护") - ]; - - private static readonly IReadOnlyDictionary> RoleDefaults = - new Dictionary>(StringComparer.Ordinal) - { - ["tenant_owner"] = ["*"], - ["tenant_admin"] = ["*"], - ["tenant_operator"] = ["tenant:overview:read", "classes:read", "students:read", "content:*"], - ["teacher"] = ["classes:read", "students:read", "students:notes:read", "students:followups:read", "content:*"], - ["sales"] = ["students:read", "students:followups:read", "students:followups:write"], - ["agent"] = ["students:read"], - ["student"] = [] - }; - public async Task GetClassesAsync( TenantAdminActor actor, TenantAdminClassFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.TenantClasses.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); + var query = dbContext.TenantClasses.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + item => item.CreatedBy == actor.UserId, + item => classIds.Contains(item.Id) || (item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))); if (filter.RegionId.HasValue) { query = query.Where(item => item.RegionId == filter.RegionId.Value); @@ -114,7 +87,7 @@ public sealed class TenantAdminDirectService( return new TenantAdminClassList( items.Select(item => ToClassItem(item.Class, item.RegionName, item.StudentCount, item.StaffCount)).ToArray(), - Scoped: false); + Scoped: scope.Mode != DataScopeMode.All); } public async Task> UpsertClassAsync( @@ -122,11 +95,22 @@ public sealed class TenantAdminDirectService( UpsertTenantAdminClassCommand command, CancellationToken cancellationToken = default) { + var scope = await RequireDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); var item = await ResolveTenantEntityAsync(dbContext.TenantClasses, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; + if (item is not null && !scope.AllowsResource(actor.UserId, item.CreatedBy, item.RegionId, item.Id)) + { + throw new TenantAdminDirectException("Class was not found.", "class_not_found"); + } + + if (item is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId, command.Id)) + { + throw new TenantAdminDirectException("Class was not found.", "class_not_found"); + } + item ??= new TenantClass { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId, CreatedBy = actor.UserId }; item.RegionId = command.RegionId; item.LegacyId = Normalize(command.LegacyId); @@ -160,8 +144,16 @@ public sealed class TenantAdminDirectService( Guid classId, CancellationToken cancellationToken = default) { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); var item = await dbContext.TenantClasses - .FirstOrDefaultAsync(entity => entity.TenantId == actor.TenantId && entity.Id == classId, cancellationToken); + .Where(entity => entity.TenantId == actor.TenantId && entity.Id == classId) + .ApplyDataScope( + scope, + entity => entity.CreatedBy == actor.UserId, + entity => classIds.Contains(entity.Id) || (entity.RegionId.HasValue && regionIds.Contains(entity.RegionId.Value))) + .FirstOrDefaultAsync(cancellationToken); if (item is null) { throw new TenantAdminDirectException("Class was not found.", "class_not_found"); @@ -179,9 +171,20 @@ public sealed class TenantAdminDirectService( TenantAdminClassMemberFilter filter, CancellationToken cancellationToken = default) { - await AssertClassAsync(actor.TenantId, filter.ClassId, cancellationToken); + var scope = await RequireDataScopeAsync(actor, cancellationToken); + await AssertClassAsync(actor, scope, filter.ClassId, cancellationToken); + var classIds = scope.ClassIds.ToArray(); + var regionIds = scope.RegionIds.ToArray(); var query = dbContext.TenantClassMembers.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId && item.ClassId == filter.ClassId); + .Where(item => item.TenantId == actor.TenantId && item.ClassId == filter.ClassId) + .ApplyDataScope( + scope, + item => item.UserId == actor.UserId || item.CreatedBy == actor.UserId, + item => classIds.Contains(item.ClassId) || dbContext.TenantClasses.Any(tenantClass => + tenantClass.TenantId == actor.TenantId && + tenantClass.Id == item.ClassId && + tenantClass.RegionId.HasValue && + regionIds.Contains(tenantClass.RegionId.Value))); if (!string.IsNullOrWhiteSpace(filter.MemberType)) { @@ -211,7 +214,8 @@ public sealed class TenantAdminDirectService( UpsertTenantAdminClassMemberCommand command, CancellationToken cancellationToken = default) { - await AssertClassAsync(actor.TenantId, command.ClassId, cancellationToken); + var scope = await RequireDataScopeAsync(actor, cancellationToken); + await AssertClassAsync(actor, scope, command.ClassId, cancellationToken); var memberType = ParseEnum(command.MemberType, TenantClassMemberType.Student, "invalid_class_member_type"); var status = ParseEnum(command.Status, TenantClassMemberStatus.Active, "invalid_class_member_status"); var user = await ResolveUserAsync(command.User, memberType == TenantClassMemberType.Student ? "student" : "teacher", cancellationToken); @@ -255,8 +259,20 @@ public sealed class TenantAdminDirectService( Guid classMemberId, CancellationToken cancellationToken = default) { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var classIds = scope.ClassIds.ToArray(); + var regionIds = scope.RegionIds.ToArray(); var item = await dbContext.TenantClassMembers - .FirstOrDefaultAsync(member => member.TenantId == actor.TenantId && member.Id == classMemberId, cancellationToken); + .Where(member => member.TenantId == actor.TenantId && member.Id == classMemberId) + .ApplyDataScope( + scope, + member => member.UserId == actor.UserId || member.CreatedBy == actor.UserId, + member => classIds.Contains(member.ClassId) || dbContext.TenantClasses.Any(tenantClass => + tenantClass.TenantId == actor.TenantId && + tenantClass.Id == member.ClassId && + tenantClass.RegionId.HasValue && + regionIds.Contains(tenantClass.RegionId.Value))) + .FirstOrDefaultAsync(cancellationToken); if (item is null) { throw new TenantAdminDirectException("Class member was not found.", "class_member_not_found"); @@ -276,13 +292,29 @@ public sealed class TenantAdminDirectService( TenantAdminStudentFilter filter, CancellationToken cancellationToken = default) { + var scope = await RequireDataScopeAsync(actor, cancellationToken); var status = ParseEnum(filter.Status, MembershipStatus.Active, "invalid_student_status"); + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); var query = dbContext.TenantMemberships.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId && item.Role == TenantRole.Student && item.Status == status); + .Where(item => item.TenantId == actor.TenantId && item.Role == TenantRole.Student && item.Status == status) + .ApplyDataScope( + scope, + item => item.UserId == actor.UserId, + item => dbContext.StudentProfiles.Any(profile => + profile.TenantId == actor.TenantId && + profile.UserId == item.UserId && + profile.RegionId.HasValue && + regionIds.Contains(profile.RegionId.Value)) || + dbContext.TenantClassMembers.Any(member => + member.TenantId == actor.TenantId && + member.UserId == item.UserId && + member.Status == TenantClassMemberStatus.Active && + classIds.Contains(member.ClassId))); if (filter.ClassId.HasValue) { - await AssertClassAsync(actor.TenantId, filter.ClassId.Value, cancellationToken); + await AssertClassAsync(actor, scope, filter.ClassId.Value, cancellationToken); query = query.Where(item => dbContext.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.ClassId == filter.ClassId.Value && @@ -304,7 +336,7 @@ public sealed class TenantAdminDirectService( var keyword = filter.Keyword.Trim(); query = query.Where(item => dbContext.Users.Any(user => user.Id == item.UserId && - ((user.Username != null && user.Username.Contains(keyword)) || + ((user.UserName != null && user.UserName.Contains(keyword)) || (user.Phone != null && user.Phone.Contains(keyword)) || (user.Email != null && user.Email.Contains(keyword)) || (user.Name != null && user.Name.Contains(keyword))))); @@ -367,7 +399,7 @@ public sealed class TenantAdminDirectService( majors, studentClasses ?? []); }).ToArray(), - Scoped: false); + Scoped: scope.Mode != DataScopeMode.All); } public async Task> UpsertStudentAsync( @@ -375,10 +407,15 @@ public sealed class TenantAdminDirectService( UpsertTenantAdminStudentCommand command, CancellationToken cancellationToken = default) { + var scope = await RequireDataScopeAsync(actor, cancellationToken); await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.SelectedSchoolId, "school_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.SelectedMajorId, "major_not_found", cancellationToken); var user = await ResolveUserAsync(command.User, "student", cancellationToken); + if (!scope.AllowsResource(actor.UserId, user.Id, command.RegionId)) + { + throw new TenantAdminDirectException("Student was not found.", "student_not_found"); + } if (command.RawProfile.ValueKind == JsonValueKind.Object) { user.RawProfile = command.RawProfile.Clone(); @@ -416,13 +453,29 @@ public sealed class TenantAdminDirectService( UpdateTenantAdminStudentStatusCommand command, CancellationToken cancellationToken = default) { + var scope = await RequireDataScopeAsync(actor, cancellationToken); var status = ParseEnum(command.Status, MembershipStatus.Active, "invalid_student_status"); + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); var membership = await dbContext.TenantMemberships - .FirstOrDefaultAsync(item => + .Where(item => item.TenantId == actor.TenantId && item.UserId == command.UserId && - item.Role == TenantRole.Student, - cancellationToken); + item.Role == TenantRole.Student) + .ApplyDataScope( + scope, + item => item.UserId == actor.UserId, + item => dbContext.StudentProfiles.Any(profile => + profile.TenantId == actor.TenantId && + profile.UserId == item.UserId && + profile.RegionId.HasValue && + regionIds.Contains(profile.RegionId.Value)) || + dbContext.TenantClassMembers.Any(member => + member.TenantId == actor.TenantId && + member.UserId == item.UserId && + member.Status == TenantClassMemberStatus.Active && + classIds.Contains(member.ClassId))) + .FirstOrDefaultAsync(cancellationToken); if (membership is null) { throw new TenantAdminDirectException("Student membership was not found.", "student_not_found"); @@ -431,14 +484,8 @@ public sealed class TenantAdminDirectService( membership.Status = status; if (status != MembershipStatus.Active) { - var now = DateTimeOffset.UtcNow; - var sessions = await dbContext.AuthSessions - .Where(session => session.TenantId == actor.TenantId && session.UserId == command.UserId && session.RevokedAt == null) - .ToArrayAsync(cancellationToken); - foreach (var session in sessions) - { - session.RevokedAt = now; - } + await sessionStore.RevokeRealmAsync( + command.UserId, AuthRealm.Tenant, actor.TenantId, "membership_disabled", cancellationToken); } await AddAuditAsync(actor, "tenant.student.status_updated", "tenant_memberships", membership.Id, cancellationToken); @@ -452,7 +499,24 @@ public sealed class TenantAdminDirectService( TenantAdminStudentActivityFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.TenantStudentNotes.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); + var query = dbContext.TenantStudentNotes.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + item => item.StudentUserId == actor.UserId || item.CreatedBy == actor.UserId, + item => dbContext.StudentProfiles.Any(profile => + profile.TenantId == actor.TenantId && + profile.UserId == item.StudentUserId && + profile.RegionId.HasValue && + regionIds.Contains(profile.RegionId.Value)) || + dbContext.TenantClassMembers.Any(member => + member.TenantId == actor.TenantId && + member.UserId == item.StudentUserId && + member.Status == TenantClassMemberStatus.Active && + classIds.Contains(member.ClassId))); if (filter.StudentUserId.HasValue) { query = query.Where(item => item.StudentUserId == filter.StudentUserId.Value); @@ -472,11 +536,36 @@ public sealed class TenantAdminDirectService( UpsertTenantAdminStudentNoteCommand command, CancellationToken cancellationToken = default) { + var scope = await RequireDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Content); - await AssertStudentAsync(actor.TenantId, command.StudentUserId, cancellationToken); - var item = command.Id.HasValue - ? await dbContext.TenantStudentNotes.FirstOrDefaultAsync(note => note.TenantId == actor.TenantId && note.Id == command.Id.Value, cancellationToken) - : null; + await AssertStudentAsync(actor, scope, command.StudentUserId, cancellationToken); + TenantStudentNote? item = null; + if (command.Id.HasValue) + { + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); + item = await dbContext.TenantStudentNotes + .Where(note => note.TenantId == actor.TenantId && note.Id == command.Id.Value) + .ApplyDataScope( + scope, + note => note.StudentUserId == actor.UserId || note.CreatedBy == actor.UserId, + note => dbContext.StudentProfiles.Any(profile => + profile.TenantId == actor.TenantId && + profile.UserId == note.StudentUserId && + profile.RegionId.HasValue && + regionIds.Contains(profile.RegionId.Value)) || + dbContext.TenantClassMembers.Any(member => + member.TenantId == actor.TenantId && + member.UserId == note.StudentUserId && + member.Status == TenantClassMemberStatus.Active && + classIds.Contains(member.ClassId))) + .FirstOrDefaultAsync(cancellationToken); + if (item is null) + { + throw new TenantAdminDirectException("Student note was not found.", "student_note_not_found"); + } + } + var isNew = item is null; item ??= new TenantStudentNote { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId, StudentUserId = command.StudentUserId, CreatedBy = actor.UserId }; item.NoteType = ParseEnum(command.NoteType, StudentNoteType.General, "invalid_student_note_type"); @@ -500,7 +589,25 @@ public sealed class TenantAdminDirectService( TenantAdminStudentActivityFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.TenantStudentFollowups.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); + var query = dbContext.TenantStudentFollowups.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + item => item.StudentUserId == actor.UserId || item.AssignedToUserId == actor.UserId || item.CreatedBy == actor.UserId, + item => (item.ClassId.HasValue && classIds.Contains(item.ClassId.Value)) || + dbContext.StudentProfiles.Any(profile => + profile.TenantId == actor.TenantId && + profile.UserId == item.StudentUserId && + profile.RegionId.HasValue && + regionIds.Contains(profile.RegionId.Value)) || + dbContext.TenantClassMembers.Any(member => + member.TenantId == actor.TenantId && + member.UserId == item.StudentUserId && + member.Status == TenantClassMemberStatus.Active && + classIds.Contains(member.ClassId))); if (filter.StudentUserId.HasValue) { query = query.Where(item => item.StudentUserId == filter.StudentUserId.Value); @@ -526,14 +633,42 @@ public sealed class TenantAdminDirectService( UpsertTenantAdminStudentFollowupCommand command, CancellationToken cancellationToken = default) { + var scope = await RequireDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); - await AssertStudentAsync(actor.TenantId, command.StudentUserId, cancellationToken); - await AssertClassAsync(actor.TenantId, command.ClassId, cancellationToken); + await AssertStudentAsync(actor, scope, command.StudentUserId, cancellationToken); + await AssertClassAsync(actor, scope, command.ClassId, cancellationToken); await AssertTenantMemberAsync(actor.TenantId, command.AssignedToUserId, cancellationToken); - var item = command.Id.HasValue - ? await dbContext.TenantStudentFollowups.FirstOrDefaultAsync(followup => followup.TenantId == actor.TenantId && followup.Id == command.Id.Value, cancellationToken) - : null; + TenantStudentFollowup? item = null; + if (command.Id.HasValue) + { + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); + item = await dbContext.TenantStudentFollowups + .Where(followup => followup.TenantId == actor.TenantId && followup.Id == command.Id.Value) + .ApplyDataScope( + scope, + followup => followup.StudentUserId == actor.UserId || + followup.AssignedToUserId == actor.UserId || + followup.CreatedBy == actor.UserId, + followup => (followup.ClassId.HasValue && classIds.Contains(followup.ClassId.Value)) || + dbContext.StudentProfiles.Any(profile => + profile.TenantId == actor.TenantId && + profile.UserId == followup.StudentUserId && + profile.RegionId.HasValue && + regionIds.Contains(profile.RegionId.Value)) || + dbContext.TenantClassMembers.Any(member => + member.TenantId == actor.TenantId && + member.UserId == followup.StudentUserId && + member.Status == TenantClassMemberStatus.Active && + classIds.Contains(member.ClassId))) + .FirstOrDefaultAsync(cancellationToken); + if (item is null) + { + throw new TenantAdminDirectException("Student followup was not found.", "student_followup_not_found"); + } + } + var isNew = item is null; item ??= new TenantStudentFollowup { @@ -569,6 +704,7 @@ public sealed class TenantAdminDirectService( TenantAdminMemberFilter filter, CancellationToken cancellationToken = default) { + await RequireAllDataScopeAsync(actor, cancellationToken); var query = dbContext.TenantMemberships.AsNoTracking().Where(item => item.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(filter.Role)) { @@ -585,7 +721,7 @@ public sealed class TenantAdminDirectService( var keyword = filter.Keyword.Trim(); query = query.Where(item => dbContext.Users.Any(user => user.Id == item.UserId && - ((user.Username != null && user.Username.Contains(keyword)) || + ((user.UserName != null && user.UserName.Contains(keyword)) || (user.Phone != null && user.Phone.Contains(keyword)) || (user.Email != null && user.Email.Contains(keyword)) || (user.Name != null && user.Name.Contains(keyword))))); @@ -600,21 +736,14 @@ public sealed class TenantAdminDirectService( .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken); var userIds = memberships.Select(item => item.UserId).ToArray(); - var templateIds = memberships.Where(item => item.RoleTemplateId.HasValue).Select(item => item.RoleTemplateId!.Value).ToArray(); var users = await dbContext.Users.AsNoTracking() .Where(user => userIds.Contains(user.Id)) .ToDictionaryAsync(user => user.Id, cancellationToken); - var templates = await dbContext.TenantRoleTemplates.AsNoTracking() - .Where(template => template.TenantId == actor.TenantId && templateIds.Contains(template.Id)) - .ToDictionaryAsync(template => template.Id, cancellationToken); return new CatalogList(memberships.Select(item => { users.TryGetValue(item.UserId, out var user); - var template = item.RoleTemplateId.HasValue && templates.TryGetValue(item.RoleTemplateId.Value, out var resolvedTemplate) - ? resolvedTemplate - : null; - return ToMemberItem(item, user ?? new User { Id = item.UserId }, template); + return ToMemberItem(item, user ?? new User { Id = item.UserId }); }).ToArray()); } @@ -623,24 +752,10 @@ public sealed class TenantAdminDirectService( UpsertTenantAdminMemberCommand command, CancellationToken cancellationToken = default) { + await RequireAllDataScopeAsync(actor, cancellationToken); var role = ParseEnum(command.Role, TenantRole.Student, "invalid_member_role"); var status = ParseEnum(command.Status, MembershipStatus.Active, "invalid_member_status"); - var permissions = PermissionObject(command.Permissions); - TenantRoleTemplate? roleTemplate = null; - if (command.RoleTemplateId.HasValue) - { - roleTemplate = await dbContext.TenantRoleTemplates.FirstOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.Id == command.RoleTemplateId.Value && item.Status == TenantRoleTemplateStatus.Active, - cancellationToken); - if (roleTemplate is null) - { - throw new TenantAdminDirectException("Role template was not found.", "role_template_not_found"); - } - - role = roleTemplate.BaseRole; - } - - AssertGrantable(actor, role, permissions); + await AssertGrantableAsync(actor, role, cancellationToken); var primaryRole = Normalize(command.PrimaryRole) ?? RoleToPrimaryRole(role); var user = await ResolveUserAsync(command.User, primaryRole, cancellationToken); if (user.Id == actor.UserId && status == MembershipStatus.Disabled) @@ -667,6 +782,11 @@ public sealed class TenantAdminDirectService( } var isNew = membership is null; + if (membership?.Role == TenantRole.TenantOwner && role != TenantRole.TenantOwner) + { + throw new TenantAdminDirectException("Tenant owner membership cannot be downgraded.", "tenant_owner_required"); + } + membership ??= new TenantMembership { TenantId = actor.TenantId, @@ -675,8 +795,6 @@ public sealed class TenantAdminDirectService( membership.UserId = user.Id; membership.Role = role; membership.Status = status; - membership.RoleTemplateId = command.RoleTemplateId; - membership.Permissions = permissions; if (isNew) { dbContext.TenantMemberships.Add(membership); @@ -686,10 +804,14 @@ public sealed class TenantAdminDirectService( { await RevokeSessionsAsync(actor.TenantId, user.Id, cancellationToken); } + else if (role == TenantRole.TenantOwner) + { + await EnsureTenantOwnerBackendRoleAsync(actor.TenantId, user.Id, cancellationToken); + } await AddAuditAsync(actor, "tenant.member.upserted", "tenant_memberships", membership.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(ToMemberItem(membership, user, roleTemplate)); + return new ContentManagementResult(ToMemberItem(membership, user)); } public async Task> DisableMemberAsync( @@ -697,6 +819,7 @@ public sealed class TenantAdminDirectService( Guid membershipId, CancellationToken cancellationToken = default) { + await RequireAllDataScopeAsync(actor, cancellationToken); var membership = await dbContext.TenantMemberships.FirstOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == membershipId, cancellationToken); @@ -710,13 +833,13 @@ public sealed class TenantAdminDirectService( throw new TenantAdminDirectException("Cannot disable your own tenant membership.", "cannot_disable_self"); } - AssertGrantable(actor, membership.Role, JsonDefaults.Object()); + await AssertGrantableAsync(actor, membership.Role, cancellationToken); membership.Status = MembershipStatus.Disabled; await RevokeSessionsAsync(actor.TenantId, membership.UserId, cancellationToken); await AddAuditAsync(actor, "tenant.member.disabled", "tenant_memberships", membership.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); var user = await dbContext.Users.AsNoTracking().SingleAsync(item => item.Id == membership.UserId, cancellationToken); - return new ContentManagementResult(ToMemberItem(membership, user, null)); + return new ContentManagementResult(ToMemberItem(membership, user)); } public async Task> GetAuditLogsAsync( @@ -724,6 +847,7 @@ public sealed class TenantAdminDirectService( TenantAdminAuditLogFilter filter, CancellationToken cancellationToken = default) { + await RequireAllDataScopeAsync(actor, cancellationToken); var query = dbContext.AuditLogs.AsNoTracking().Where(item => item.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(filter.Action)) { @@ -764,116 +888,18 @@ public sealed class TenantAdminDirectService( item.Details, item.IpAddress, item.UserAgent, - user?.Name ?? user?.Username, + user?.Name ?? user?.UserName, user?.Phone, item.CreatedAt); }).ToArray()); } - public async Task GetPermissionMatrixAsync( - TenantAdminActor actor, - CancellationToken cancellationToken = default) - { - await Task.CompletedTask.WaitAsync(cancellationToken); - return new TenantAdminPermissionMatrix( - new TenantAdminCurrentPermission(actor.UserId, actor.TenantId, TenantRole.TenantAdmin), - PermissionCatalog, - RoleDefaults); - } - - public async Task> GetRoleTemplatesAsync( - TenantAdminActor actor, - TenantAdminRoleTemplateFilter filter, - CancellationToken cancellationToken = default) - { - var query = dbContext.TenantRoleTemplates.AsNoTracking().Where(item => item.TenantId == actor.TenantId); - if (!string.IsNullOrWhiteSpace(filter.Status)) - { - query = query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_role_template_status")); - } - - var items = await query - .OrderBy(item => item.SortOrder) - .ThenBy(item => item.CreatedAt) - .Take(ResolveLimit(filter.Limit)) - .Select(item => ToRoleTemplateItem(item)) - .ToArrayAsync(cancellationToken); - return new CatalogList(items); - } - - public async Task> UpsertRoleTemplateAsync( - TenantAdminActor actor, - UpsertTenantAdminRoleTemplateCommand command, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); - var baseRole = ParseEnum(command.BaseRole, TenantRole.Student, "invalid_member_role"); - var permissions = PermissionObject(command.Permissions); - AssertGrantable(actor, baseRole, permissions); - var code = NormalizeRoleCode(command.Code ?? command.Name); - var item = command.Id.HasValue - ? await dbContext.TenantRoleTemplates.FirstOrDefaultAsync(template => template.TenantId == actor.TenantId && template.Id == command.Id.Value, cancellationToken) - : await dbContext.TenantRoleTemplates.FirstOrDefaultAsync(template => template.TenantId == actor.TenantId && template.Code == code, cancellationToken); - var isNew = item is null; - item ??= new TenantRoleTemplate { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId, CreatedBy = actor.UserId }; - if (item.IsSystem) - { - throw new TenantAdminDirectException("System role template cannot be modified.", "system_role_template_locked"); - } - - item.Code = code; - item.Name = command.Name.Trim(); - item.Description = Normalize(command.Description); - item.BaseRole = baseRole; - item.Status = ParseEnum(command.Status, TenantRoleTemplateStatus.Active, "invalid_role_template_status"); - item.Permissions = permissions; - item.MenuPermissions = AccessMap(command.MenuPermissions, "menu_permissions"); - item.ModulePermissions = AccessMap(command.ModulePermissions, "module_permissions"); - item.FieldPermissions = AccessMap(command.FieldPermissions, "field_permissions"); - item.DataScope = DataScope(command.DataScope); - item.SortOrder = command.Order ?? item.SortOrder; - item.UpdatedBy = actor.UserId; - if (isNew) - { - dbContext.TenantRoleTemplates.Add(item); - } - - await AddAuditAsync(actor, "tenant.role_template.upserted", "tenant_role_templates", item.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(ToRoleTemplateItem(item)); - } - - public async Task> DisableRoleTemplateAsync( - TenantAdminActor actor, - Guid roleTemplateId, - CancellationToken cancellationToken = default) - { - var item = await dbContext.TenantRoleTemplates.FirstOrDefaultAsync( - template => template.TenantId == actor.TenantId && template.Id == roleTemplateId, - cancellationToken); - if (item is null) - { - throw new TenantAdminDirectException("Role template was not found.", "role_template_not_found"); - } - - if (item.IsSystem) - { - throw new TenantAdminDirectException("System role template cannot be disabled.", "system_role_template_locked"); - } - - AssertGrantable(actor, item.BaseRole, item.Permissions); - item.Status = TenantRoleTemplateStatus.Disabled; - item.UpdatedBy = actor.UserId; - await AddAuditAsync(actor, "tenant.role_template.disabled", "tenant_role_templates", item.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(ToRoleTemplateItem(item)); - } - public async Task> UpsertBrandingAsync( TenantAdminActor actor, UpsertTenantBrandingCommand command, CancellationToken cancellationToken = default) { + await RequireAllDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.BrandName); var item = await dbContext.TenantBrandings.FirstOrDefaultAsync(branding => branding.TenantId == actor.TenantId, cancellationToken); if (item is null) @@ -900,6 +926,7 @@ public sealed class TenantAdminDirectService( UpsertTenantSettingsCommand command, CancellationToken cancellationToken = default) { + await RequireAllDataScopeAsync(actor, cancellationToken); AssertNoSecrets(command.PublicConfig, "public_config"); var item = await dbContext.TenantSettings.FirstOrDefaultAsync(settings => settings.TenantId == actor.TenantId, cancellationToken); if (item is null) @@ -920,6 +947,7 @@ public sealed class TenantAdminDirectService( TenantAdminActor actor, CancellationToken cancellationToken = default) { + await RequireAllDataScopeAsync(actor, cancellationToken); await Task.CompletedTask.WaitAsync(cancellationToken); var items = await dbContext.TenantThemeTemplates.AsNoTracking() .Where(item => item.Status == TenantThemeTemplateStatus.Active) @@ -941,6 +969,7 @@ public sealed class TenantAdminDirectService( TenantAdminActor actor, CancellationToken cancellationToken = default) { + await RequireAllDataScopeAsync(actor, cancellationToken); var item = await dbContext.TenantThemeConfigs.AsNoTracking() .FirstOrDefaultAsync(theme => theme.TenantId == actor.TenantId, cancellationToken); if (item is not null) @@ -970,6 +999,7 @@ public sealed class TenantAdminDirectService( PreviewTenantThemeCommand command, CancellationToken cancellationToken = default) { + await RequireAllDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.TemplateCode); var template = await dbContext.TenantThemeTemplates.FirstOrDefaultAsync( item => item.Code == command.TemplateCode && item.Status == TenantThemeTemplateStatus.Active, @@ -1001,6 +1031,7 @@ public sealed class TenantAdminDirectService( PublishTenantThemeCommand command, CancellationToken cancellationToken = default) { + await RequireAllDataScopeAsync(actor, cancellationToken); var item = await dbContext.TenantThemeConfigs.FirstOrDefaultAsync(theme => theme.TenantId == actor.TenantId, cancellationToken); JsonElement activeTheme; JsonElement activeAssets; @@ -1063,6 +1094,7 @@ public sealed class TenantAdminDirectService( TenantAdminActor actor, CancellationToken cancellationToken = default) { + await RequireAllDataScopeAsync(actor, cancellationToken); var items = await dbContext.TenantDomains.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .OrderByDescending(item => item.IsPrimary) @@ -1077,6 +1109,7 @@ public sealed class TenantAdminDirectService( CreateTenantDomainCommand command, CancellationToken cancellationToken = default) { + await RequireAllDataScopeAsync(actor, cancellationToken); var host = NormalizeDomain(command.Host); if (command.IsPrimary) { @@ -1108,6 +1141,7 @@ public sealed class TenantAdminDirectService( TenantAdminActor actor, CancellationToken cancellationToken = default) { + await RequireAllDataScopeAsync(actor, cancellationToken); var items = await providerConfigService.GetProvidersAsync( actor.TenantId, TenantExternalProviderCapability.Identity, @@ -1120,6 +1154,7 @@ public sealed class TenantAdminDirectService( UpsertTenantIdentityProviderCommand command, CancellationToken cancellationToken = default) { + await RequireAllDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Provider); var item = await providerConfigService.UpsertProviderAsync( actor.TenantId, @@ -1144,6 +1179,7 @@ public sealed class TenantAdminDirectService( TenantAdminBadgeFilter filter, CancellationToken cancellationToken = default) { + await RequireAllDataScopeAsync(actor, cancellationToken); var query = dbContext.Badges.AsNoTracking().Where(item => item.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(filter.Category)) { @@ -1170,6 +1206,7 @@ public sealed class TenantAdminDirectService( UpsertTenantAdminBadgeCommand command, CancellationToken cancellationToken = default) { + await RequireAllDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); var item = await ResolveTenantEntityAsync(dbContext.Badges, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; @@ -1202,7 +1239,25 @@ public sealed class TenantAdminDirectService( TenantAdminBadgeGrantFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.UserBadges.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); + var query = dbContext.UserBadges.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + item => item.UserId == actor.UserId || item.GrantedBy == actor.UserId, + item => item.UserId.HasValue && + (dbContext.StudentProfiles.Any(profile => + profile.TenantId == actor.TenantId && + profile.UserId == item.UserId.Value && + profile.RegionId.HasValue && + regionIds.Contains(profile.RegionId.Value)) || + dbContext.TenantClassMembers.Any(member => + member.TenantId == actor.TenantId && + member.UserId == item.UserId.Value && + member.Status == TenantClassMemberStatus.Active && + classIds.Contains(member.ClassId)))); if (filter.UserId.HasValue) { query = query.Where(item => item.UserId == filter.UserId.Value); @@ -1239,6 +1294,7 @@ public sealed class TenantAdminDirectService( GrantTenantAdminBadgeCommand command, CancellationToken cancellationToken = default) { + var scope = await RequireDataScopeAsync(actor, cancellationToken); var badge = await dbContext.Badges.FirstOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == command.BadgeId, cancellationToken); @@ -1252,7 +1308,7 @@ public sealed class TenantAdminDirectService( throw new TenantAdminDirectException("Cannot grant inactive badge.", "badge_inactive"); } - await AssertTenantMemberAsync(actor.TenantId, command.UserId, cancellationToken); + await AssertStudentAsync(actor, scope, command.UserId, cancellationToken); var grant = await dbContext.UserBadges.FirstOrDefaultAsync( item => item.TenantId == actor.TenantId && item.UserId == command.UserId && item.BadgeId == command.BadgeId, cancellationToken); @@ -1303,7 +1359,24 @@ public sealed class TenantAdminDirectService( TenantAdminNotificationFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.UserNotifications.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); + var query = dbContext.UserNotifications.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + item => item.UserId == actor.UserId || item.CreatedBy == actor.UserId, + item => dbContext.StudentProfiles.Any(profile => + profile.TenantId == actor.TenantId && + profile.UserId == item.UserId && + profile.RegionId.HasValue && + regionIds.Contains(profile.RegionId.Value)) || + dbContext.TenantClassMembers.Any(member => + member.TenantId == actor.TenantId && + member.UserId == item.UserId && + member.Status == TenantClassMemberStatus.Active && + classIds.Contains(member.ClassId))); if (filter.UserId.HasValue) { query = query.Where(item => item.UserId == filter.UserId.Value); @@ -1332,10 +1405,11 @@ public sealed class TenantAdminDirectService( UpsertTenantAdminNotificationCommand command, CancellationToken cancellationToken = default) { + var scope = await RequireDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.NotificationType); ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); ArgumentException.ThrowIfNullOrWhiteSpace(command.Message); - await AssertTenantMemberAsync(actor.TenantId, command.UserId, cancellationToken); + await AssertStudentAsync(actor, scope, command.UserId, cancellationToken); var item = await notificationProvider.UpsertInAppAsync( new InAppNotificationRequest( @@ -1364,7 +1438,25 @@ public sealed class TenantAdminDirectService( TenantAdminFeedbackFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.Reports.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); + var query = dbContext.Reports.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + item => item.UserId == actor.UserId || item.HandledBy == actor.UserId, + item => item.UserId.HasValue && + (dbContext.StudentProfiles.Any(profile => + profile.TenantId == actor.TenantId && + profile.UserId == item.UserId.Value && + profile.RegionId.HasValue && + regionIds.Contains(profile.RegionId.Value)) || + dbContext.TenantClassMembers.Any(member => + member.TenantId == actor.TenantId && + member.UserId == item.UserId.Value && + member.Status == TenantClassMemberStatus.Active && + classIds.Contains(member.ClassId)))); if (filter.UserId.HasValue) { query = query.Where(item => item.UserId == filter.UserId.Value); @@ -1406,9 +1498,26 @@ public sealed class TenantAdminDirectService( UpdateTenantAdminFeedbackCommand command, CancellationToken cancellationToken = default) { - var report = await dbContext.Reports.FirstOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.Id == command.FeedbackId, - cancellationToken); + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); + var report = await dbContext.Reports + .Where(item => item.TenantId == actor.TenantId && item.Id == command.FeedbackId) + .ApplyDataScope( + scope, + item => item.UserId == actor.UserId || item.HandledBy == actor.UserId, + item => item.UserId.HasValue && + (dbContext.StudentProfiles.Any(profile => + profile.TenantId == actor.TenantId && + profile.UserId == item.UserId.Value && + profile.RegionId.HasValue && + regionIds.Contains(profile.RegionId.Value)) || + dbContext.TenantClassMembers.Any(member => + member.TenantId == actor.TenantId && + member.UserId == item.UserId.Value && + member.Status == TenantClassMemberStatus.Active && + classIds.Contains(member.ClassId)))) + .FirstOrDefaultAsync(cancellationToken); if (report is null) { throw new TenantAdminDirectException("Feedback was not found.", "feedback_not_found"); @@ -1461,7 +1570,7 @@ public sealed class TenantAdminDirectService( user = await dbContext.Users.FirstOrDefaultAsync(item => (phone != null && item.Phone == phone) || (email != null && item.Email == email) || - (username != null && item.Username == username), + (username != null && item.UserName == username), cancellationToken); if (user is null) @@ -1473,7 +1582,7 @@ public sealed class TenantAdminDirectService( user = new User { - Username = username ?? phone ?? email, + UserName = username ?? phone ?? email, Email = email, Phone = phone, Name = Normalize(command.Name) ?? username ?? phone ?? email, @@ -1484,7 +1593,7 @@ public sealed class TenantAdminDirectService( } } - user.Username = Normalize(command.Username) ?? user.Username; + user.UserName = Normalize(command.Username) ?? user.UserName; user.Email = Normalize(command.Email) ?? user.Email; user.Phone = Normalize(command.Phone) ?? user.Phone; user.Name = Normalize(command.Name) ?? user.Name; @@ -1509,8 +1618,7 @@ public sealed class TenantAdminDirectService( TenantId = tenantId, UserId = userId, Role = role, - Status = MembershipStatus.Active, - Permissions = JsonDefaults.Object() + Status = MembershipStatus.Active }; dbContext.TenantMemberships.Add(membership); } @@ -1572,6 +1680,36 @@ public sealed class TenantAdminDirectService( } } + private async Task AssertStudentAsync( + TenantAdminActor actor, + CurrentDataScope scope, + Guid userId, + CancellationToken cancellationToken) + { + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); + var exists = await dbContext.TenantMemberships + .Where(item => item.TenantId == actor.TenantId && item.UserId == userId && item.Role == TenantRole.Student) + .ApplyDataScope( + scope, + item => item.UserId == actor.UserId, + item => dbContext.StudentProfiles.Any(profile => + profile.TenantId == actor.TenantId && + profile.UserId == item.UserId && + profile.RegionId.HasValue && + regionIds.Contains(profile.RegionId.Value)) || + dbContext.TenantClassMembers.Any(member => + member.TenantId == actor.TenantId && + member.UserId == item.UserId && + member.Status == TenantClassMemberStatus.Active && + classIds.Contains(member.ClassId))) + .AnyAsync(cancellationToken); + if (!exists) + { + throw new TenantAdminDirectException("Student was not found.", "student_not_found"); + } + } + private async Task AssertTenantMemberAsync(Guid tenantId, Guid? userId, CancellationToken cancellationToken) { if (!userId.HasValue) @@ -1590,13 +1728,81 @@ public sealed class TenantAdminDirectService( private async Task RevokeSessionsAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken) { - var now = DateTimeOffset.UtcNow; - var sessions = await dbContext.AuthSessions - .Where(session => session.TenantId == tenantId && session.UserId == userId && session.RevokedAt == null) - .ToArrayAsync(cancellationToken); - foreach (var session in sessions) + await sessionStore.RevokeRealmAsync( + userId, AuthRealm.Tenant, tenantId, "membership_disabled", cancellationToken); + } + + private async Task EnsureTenantOwnerBackendRoleAsync( + Guid tenantId, + Guid userId, + CancellationToken cancellationToken) + { + const string roleCode = "tenant_owner"; + var role = await dbContext.TenantBackendRoles.FirstOrDefaultAsync( + item => item.TenantId == tenantId && item.Code == roleCode, + cancellationToken); + if (role is null) { - session.RevokedAt = now; + role = new TenantBackendRole + { + TenantId = tenantId, + Code = roleCode, + Name = "租户所有者", + Status = BackendRoleStatus.Active, + IsSystem = true, + Description = "系统内置租户所有者角色", + DataScope = JsonSerializer.SerializeToElement(new { mode = "All" }) + }; + dbContext.TenantBackendRoles.Add(role); + } + else + { + role.Status = BackendRoleStatus.Active; + role.IsSystem = true; + role.DataScope = JsonSerializer.SerializeToElement(new { mode = "All" }); + } + + var tenantPermissionCodes = BackendPermissions.Tenant.ToArray(); + var existingPermissionCodes = await dbContext.BackendPermissions + .Where(permission => tenantPermissionCodes.Contains(permission.Code)) + .Select(permission => permission.Code) + .ToArrayAsync(cancellationToken); + foreach (var permissionCode in BackendPermissions.Tenant.Except(existingPermissionCodes, StringComparer.Ordinal)) + { + dbContext.BackendPermissions.Add(new BackendPermission + { + Code = permissionCode, + Name = permissionCode, + Area = BackendPermissionArea.Tenant, + Module = permissionCode.Split(':')[1], + IsSystem = true + }); + } + + var boundPermissionCodes = await dbContext.TenantBackendRolePermissions + .Where(binding => binding.TenantId == tenantId && binding.RoleId == role.Id) + .Select(binding => binding.PermissionCode) + .ToArrayAsync(cancellationToken); + dbContext.TenantBackendRolePermissions.AddRange( + tenantPermissionCodes + .Except(boundPermissionCodes, StringComparer.Ordinal) + .Select(permissionCode => new TenantBackendRolePermission + { + TenantId = tenantId, + RoleId = role.Id, + PermissionCode = permissionCode + })); + + if (!await dbContext.TenantBackendUserRoles.AnyAsync( + binding => binding.TenantId == tenantId && binding.UserId == userId && binding.RoleId == role.Id, + cancellationToken)) + { + dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole + { + TenantId = tenantId, + UserId = userId, + RoleId = role.Id + }); } } @@ -1641,6 +1847,56 @@ public sealed class TenantAdminDirectService( } } + private async Task AssertClassAsync( + TenantAdminActor actor, + CurrentDataScope scope, + Guid? classId, + CancellationToken cancellationToken) + { + if (!classId.HasValue) + { + return; + } + + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); + var exists = await dbContext.TenantClasses + .Where(item => item.TenantId == actor.TenantId && item.Id == classId.Value) + .ApplyDataScope( + scope, + item => item.CreatedBy == actor.UserId, + item => classIds.Contains(item.Id) || (item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))) + .AnyAsync(cancellationToken); + if (!exists) + { + throw new TenantAdminDirectException("Class was not found.", "class_not_found"); + } + } + + private async Task RequireDataScopeAsync( + TenantAdminActor actor, + CancellationToken cancellationToken) + { + var access = await currentAccessContext.GetAsync(cancellationToken); + if (!access.IsCurrentTenantMember || access.UserId != actor.UserId || access.TenantId != actor.TenantId) + { + throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found"); + } + + return access.DataScope; + } + + private async Task RequireAllDataScopeAsync( + TenantAdminActor actor, + CancellationToken cancellationToken) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + if (scope.Mode != DataScopeMode.All) + { + throw new TenantAdminDirectException("Tenant-wide resource was not found.", "tenant_resource_not_found"); + } + } + private async Task AssertReferenceAsync( Guid tenantId, Guid? id, @@ -1772,7 +2028,7 @@ public sealed class TenantAdminDirectService( { return new TenantAdminUserSummary( user.Id, - user.Username, + user.UserName, user.Email, user.Phone, user.Name, @@ -1818,45 +2074,19 @@ public sealed class TenantAdminDirectService( item.UpdatedAt); } - private static TenantAdminMemberItem ToMemberItem(TenantMembership membership, User user, TenantRoleTemplate? roleTemplate) + private static TenantAdminMemberItem ToMemberItem(TenantMembership membership, User user) { return new TenantAdminMemberItem( membership.Id, membership.UserId, membership.Role, membership.Status, - membership.Permissions, - membership.RoleTemplateId, - roleTemplate?.Code, - roleTemplate?.Name, membership.LegacyRole, ToUserSummary(user), membership.CreatedAt, membership.UpdatedAt); } - private static TenantAdminRoleTemplateItem ToRoleTemplateItem(TenantRoleTemplate item) - { - return new TenantAdminRoleTemplateItem( - item.Id, - item.Code, - item.Name, - item.Description, - item.BaseRole, - item.Status, - item.Permissions, - item.MenuPermissions, - item.ModulePermissions, - item.FieldPermissions, - item.DataScope, - item.IsSystem, - item.SortOrder, - item.CreatedBy, - item.UpdatedBy, - item.CreatedAt, - item.UpdatedAt); - } - private static TenantBrandingItem ToBrandingItem(TenantBranding item) { return new TenantBrandingItem( @@ -1960,7 +2190,7 @@ public sealed class TenantAdminDirectService( grant.Id, grant.LegacyId, grant.UserId, - user?.Name ?? user?.Username, + user?.Name ?? user?.UserName, user?.Phone, grant.BadgeId, badge?.Name, @@ -1968,7 +2198,7 @@ public sealed class TenantAdminDirectService( badge?.IconUrl, badge?.Level, grant.GrantedBy, - grantedBy?.Name ?? grantedBy?.Username, + grantedBy?.Name ?? grantedBy?.UserName, grant.Note, grant.GrantedAt, grant.CreatedAt, @@ -2002,7 +2232,7 @@ public sealed class TenantAdminDirectService( return new TenantAdminFeedbackItem( report.Id, report.UserId, - user?.Name ?? user?.Username, + user?.Name ?? user?.UserName, user?.Phone, report.QuestionId, report.Type, @@ -2131,17 +2361,28 @@ public sealed class TenantAdminDirectService( return value.Clone(); } - private static void AssertGrantable(TenantAdminActor actor, TenantRole role, JsonElement permissions) + private async Task AssertGrantableAsync( + TenantAdminActor actor, + TenantRole role, + CancellationToken cancellationToken) { - if (actor.Role == TenantRole.TenantOwner) + if (role is not (TenantRole.TenantOwner or TenantRole.TenantAdmin)) { return; } - var grantsAll = permissions.ValueKind == JsonValueKind.Object && - permissions.TryGetProperty("*", out var wildcard) && - wildcard.ValueKind is JsonValueKind.True; - if (role is TenantRole.TenantOwner or TenantRole.TenantAdmin || grantsAll) + var isOwnerRoleHolder = await ( + from binding in dbContext.TenantBackendUserRoles.AsNoTracking() + join backendRole in dbContext.TenantBackendRoles.AsNoTracking() + on new { binding.TenantId, binding.RoleId } equals new { backendRole.TenantId, RoleId = backendRole.Id } + where binding.TenantId == actor.TenantId && + binding.UserId == actor.UserId && + backendRole.Code == "tenant_owner" && + backendRole.IsSystem && + backendRole.Status == BackendRoleStatus.Active + select binding.Id) + .AnyAsync(cancellationToken); + if (!isOwnerRoleHolder) { throw new TenantAdminDirectException("Only tenant owner can grant owner/admin permissions.", "tenant_owner_required"); } @@ -2158,7 +2399,6 @@ public sealed class TenantAdminDirectService( TenantRole.TenantOperator => "tenant_operator", TenantRole.TenantAdmin => "tenant_admin", TenantRole.TenantOwner => "tenant_owner", - TenantRole.PlatformAdmin => "platform_admin", _ => "student" }; } diff --git a/Tiku.Infrastructure/Tiku.Infrastructure.csproj b/Tiku.Infrastructure/Tiku.Infrastructure.csproj index 97f5dba..9793ad6 100644 --- a/Tiku.Infrastructure/Tiku.Infrastructure.csproj +++ b/Tiku.Infrastructure/Tiku.Infrastructure.csproj @@ -11,6 +11,8 @@ + + diff --git a/Tiku.IntegrationTests/Api/ApiTestFactory.cs b/Tiku.IntegrationTests/Api/ApiTestFactory.cs index 3533056..54666ec 100644 --- a/Tiku.IntegrationTests/Api/ApiTestFactory.cs +++ b/Tiku.IntegrationTests/Api/ApiTestFactory.cs @@ -1,8 +1,10 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Npgsql; +using System.Text.Json; using Tiku.Application.Commerce; using Tiku.Application.Auth; using Tiku.Application.Growth; @@ -11,6 +13,7 @@ using Tiku.Application.Security; using Tiku.Application.Tenancy; using Tiku.Api; using Tiku.Domain.Identity; +using Tiku.Domain.Operations; using Tiku.Domain.QuestionBanks; using Tiku.Domain.Content; using Tiku.Domain.Tenancy; @@ -25,12 +28,32 @@ public sealed class ApiTestFactory( IReferralQrcodeGenerator? referralQrcodeGenerator = null, IPaymentProviderGateway? paymentProviderGateway = null, IDomainOwnershipVerifier? domainOwnershipVerifier = null, - IDomainGatewayProvisioner? domainGatewayProvisioner = null) : WebApplicationFactory + IDomainGatewayProvisioner? domainGatewayProvisioner = null, + ISmsProvider? smsProvider = null, + IReadOnlyDictionary? configurationOverrides = null) : WebApplicationFactory { private readonly PostgresTestDatabase database = PostgresTestDatabase.Create(); protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder) { + builder.ConfigureAppConfiguration((_, configuration) => + { + var values = new Dictionary + { + ["Security:Jwt:KeyId"] = TestJwtKeys.KeyId, + ["Security:Jwt:PrivateKeyPem"] = TestJwtKeys.PrivateKeyPem, + ["Tenancy:Resolution:TenantCodePathPrefixes:0"] = "/api" + }; + if (configurationOverrides is not null) + { + foreach (var pair in configurationOverrides) + { + values[pair.Key] = pair.Value; + } + } + configuration.AddInMemoryCollection(values); + }); + builder.ConfigureServices(services => { foreach (var descriptor in services @@ -53,6 +76,8 @@ public sealed class ApiTestFactory( npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName)); options.AddInterceptors(serviceProvider.GetRequiredService()); }); + services.RemoveAll(); + services.AddSingleton(); if (wechatOAuthClient is not null) { @@ -85,6 +110,12 @@ public sealed class ApiTestFactory( services.RemoveAll(); services.AddSingleton(domainGatewayProvisioner); } + + if (smsProvider is not null) + { + services.RemoveAll(); + services.AddSingleton(smsProvider); + } }); } @@ -96,6 +127,91 @@ public sealed class ApiTestFactory( var dbContext = scope.ServiceProvider.GetRequiredService(); dbContext.AddRange(entities); await dbContext.SaveChangesAsync(); + + var backendMembers = entities + .OfType() + .Where(membership => + membership.Status == MembershipStatus.Active && + membership.Role is TenantRole.TenantOwner or TenantRole.TenantAdmin) + .Select(membership => (membership.TenantId, membership.UserId)) + .Distinct() + .ToArray(); + if (backendMembers.Length > 0) + { + await EnsureTenantBackendAccessAsync(dbContext, backendMembers); + } + } + + private static async Task EnsureTenantBackendAccessAsync( + TikuDbContext dbContext, + IEnumerable<(Guid TenantId, Guid UserId)> members) + { + var permissionCodes = BackendPermissions.Tenant.Order(StringComparer.Ordinal).ToArray(); + var existingPermissionCodes = await dbContext.BackendPermissions + .Where(permission => permissionCodes.Contains(permission.Code)) + .Select(permission => permission.Code) + .ToListAsync(); + foreach (var permissionCode in permissionCodes.Except(existingPermissionCodes, StringComparer.Ordinal)) + { + dbContext.BackendPermissions.Add(new BackendPermission + { + Code = permissionCode, + Name = permissionCode, + Area = BackendPermissionArea.Tenant, + Module = permissionCode.Split(':')[1], + IsSystem = true + }); + } + + foreach (var tenantGroup in members.GroupBy(member => member.TenantId)) + { + var tenantId = tenantGroup.Key; + var role = await dbContext.TenantBackendRoles.SingleOrDefaultAsync(entity => + entity.TenantId == tenantId && entity.Code == "integration_test_admin"); + if (role is null) + { + role = new TenantBackendRole + { + TenantId = tenantId, + Code = "integration_test_admin", + Name = "Integration Test Administrator", + Status = BackendRoleStatus.Active, + IsSystem = true, + DataScope = JsonSerializer.SerializeToElement(new { mode = "all" }) + }; + dbContext.TenantBackendRoles.Add(role); + } + + var assignedPermissionCodes = await dbContext.TenantBackendRolePermissions + .Where(entity => entity.TenantId == tenantId && entity.RoleId == role.Id) + .Select(entity => entity.PermissionCode) + .ToListAsync(); + foreach (var permissionCode in permissionCodes.Except(assignedPermissionCodes, StringComparer.Ordinal)) + { + dbContext.TenantBackendRolePermissions.Add(new TenantBackendRolePermission + { + TenantId = tenantId, + RoleId = role.Id, + PermissionCode = permissionCode + }); + } + + var assignedUserIds = await dbContext.TenantBackendUserRoles + .Where(entity => entity.TenantId == tenantId && entity.RoleId == role.Id) + .Select(entity => entity.UserId) + .ToListAsync(); + foreach (var member in tenantGroup.Where(member => !assignedUserIds.Contains(member.UserId))) + { + dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole + { + TenantId = tenantId, + UserId = member.UserId, + RoleId = role.Id + }); + } + } + + await dbContext.SaveChangesAsync(); } public IServiceScope CreateSystemScope(string reason = "Integration test verification") @@ -151,39 +267,51 @@ public sealed class ApiTestFactory( public async Task SeedActiveSessionAsync( Guid userId, Guid? tenantId = null, - string tokenHash = "integration-test-token-hash") + string tokenHash = "integration-test-token-hash", + bool includeMembership = false) { var resolvedTenantId = tenantId ?? Guid.NewGuid(); - await SeedAsync( + var user = new User + { + Id = userId, + Phone = "13800000000" + }; + var session = new AuthSession + { + Id = Guid.NewGuid(), + Realm = AuthRealm.Tenant, + TenantId = resolvedTenantId, + UserId = userId, + TokenFamilyId = Guid.NewGuid(), + TokenHash = tokenHash, + SecurityStamp = user.SecurityStamp ?? string.Empty, + Provider = "test", + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1) + }; + var entities = new List + { new Tenant { Id = resolvedTenantId, Slug = resolvedTenantId.ToString("N"), Name = "Test Tenant" }, - new User + user, + session + }; + if (includeMembership) + { + entities.Add(new TenantMembership { - Id = userId, - Phone = "13800000000" - }, - new AuthSession - { - Id = Guid.NewGuid(), TenantId = resolvedTenantId, UserId = userId, - TokenHash = tokenHash, - Provider = "test", - ExpiresAt = DateTimeOffset.UtcNow.AddHours(1) + Role = TenantRole.Student, + Status = MembershipStatus.Active }); + } - using var scope = Services.CreateScope(); - scope.ServiceProvider.GetRequiredService() - .InitializeSystem(resolvedTenantId, "Integration test session lookup"); - var dbContext = scope.ServiceProvider.GetRequiredService(); - return await dbContext.AuthSessions - .Where(session => session.UserId == userId) - .Select(session => session.Id) - .SingleAsync(); + await SeedAsync([.. entities]); + return session.Id; } protected override void Dispose(bool disposing) diff --git a/Tiku.IntegrationTests/Api/AssetAccessEndpointTests.cs b/Tiku.IntegrationTests/Api/AssetAccessEndpointTests.cs index bcd00e5..67a08b3 100644 --- a/Tiku.IntegrationTests/Api/AssetAccessEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/AssetAccessEndpointTests.cs @@ -214,8 +214,6 @@ public sealed class AssetAccessEndpointTests { var userId = Guid.NewGuid(); var phone = "13800000000"; - var passwordHash = new PasswordHasher().Hash("passw0rd!"); - await factory.SeedAsync( Tenant(tenantId, tenantId.ToString("N")), new User @@ -223,21 +221,13 @@ public sealed class AssetAccessEndpointTests Id = userId, Phone = phone, Name = "Test User" - }, + }.WithTestPassword(), new TenantMembership { TenantId = tenantId, UserId = userId, Role = TenantRole.Student, Status = MembershipStatus.Active - }, - new UserIdentity - { - UserId = userId, - Provider = "password", - ProviderSubject = phone, - Phone = phone, - SecretPayload = CreateSecretPayload(passwordHash) }); return (tenantId, userId, phone); @@ -247,20 +237,7 @@ public sealed class AssetAccessEndpointTests HttpClient client, (Guid TenantId, Guid UserId, string Phone) seed) { - var loginResponse = await client.PostAsJsonAsync( - "/api/auth/login/password", - new PasswordLoginDto - { - TenantCode = seed.TenantId.ToString("N"), - Phone = seed.Phone, - Password = "passw0rd!" - }); - var loginJson = await ReadJsonAsync(loginResponse); - var accessToken = loginJson.RootElement - .GetProperty("tokens") - .GetProperty("accessToken") - .GetString(); - client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone)); } private static async Task ReadJsonAsync(HttpResponseMessage response) @@ -269,13 +246,6 @@ public sealed class AssetAccessEndpointTests return await JsonDocument.ParseAsync(stream); } - private static JsonElement CreateSecretPayload(string passwordHash) - { - using var document = JsonDocument.Parse( - $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); - return document.RootElement.Clone(); - } - private sealed class FakeObjectStorageService : IObjectStorageService { public string ConfiguredDefaultProvider() => ObjectStorageProviders.LocalDev; diff --git a/Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs b/Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs index 3ca7a20..03179b8 100644 --- a/Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs @@ -255,8 +255,6 @@ public sealed class AssetManagementEndpointTests var tenantId = Guid.NewGuid(); var userId = Guid.NewGuid(); var phone = "13900000000"; - var passwordHash = new PasswordHasher().Hash("passw0rd!"); - await factory.SeedAsync( new Tenant { @@ -271,21 +269,13 @@ public sealed class AssetManagementEndpointTests Id = userId, Phone = phone, Name = "Tenant Admin" - }, + }.WithTestPassword(), new TenantMembership { TenantId = tenantId, UserId = userId, Role = TenantRole.TenantAdmin, Status = MembershipStatus.Active - }, - new UserIdentity - { - UserId = userId, - Provider = "password", - ProviderSubject = phone, - Phone = phone, - SecretPayload = CreateSecretPayload(passwordHash) }); return (tenantId, userId, phone); @@ -295,20 +285,7 @@ public sealed class AssetManagementEndpointTests HttpClient client, (Guid TenantId, Guid UserId, string Phone) seed) { - var loginResponse = await client.PostAsJsonAsync( - "/api/auth/login/password", - new PasswordLoginDto - { - TenantCode = seed.TenantId.ToString("N"), - Phone = seed.Phone, - Password = "passw0rd!" - }); - var loginJson = await ReadJsonAsync(loginResponse); - var accessToken = loginJson.RootElement - .GetProperty("tokens") - .GetProperty("accessToken") - .GetString(); - client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone)); } private static async Task ReadJsonAsync(HttpResponseMessage response) @@ -317,13 +294,6 @@ public sealed class AssetManagementEndpointTests return await JsonDocument.ParseAsync(stream); } - private static JsonElement CreateSecretPayload(string passwordHash) - { - using var document = JsonDocument.Parse( - $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); - return document.RootElement.Clone(); - } - private sealed class FakeObjectStorageService : IObjectStorageService { public long? MetadataSizeBytes { get; init; } diff --git a/Tiku.IntegrationTests/Api/AuthEndpointTests.cs b/Tiku.IntegrationTests/Api/AuthEndpointTests.cs index b5af5dc..0570c51 100644 --- a/Tiku.IntegrationTests/Api/AuthEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/AuthEndpointTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http.Json; using System.Text.Json; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Tiku.Application.Auth; @@ -15,6 +16,59 @@ namespace Tiku.IntegrationTests.Api; public sealed class AuthEndpointTests { + [Fact] + public async Task Sms_send_creates_login_code_without_exposing_it_and_rejects_platform_realm() + { + var provider = new CapturingSmsProvider(); + await using var factory = new ApiTestFactory(smsProvider: provider); + var tenantId = Guid.NewGuid(); + await factory.SeedAsync(new Tenant + { + Id = tenantId, + Slug = tenantId.ToString("N"), + Name = "SMS Tenant" + }); + using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); + + var response = await client.PostAsJsonAsync( + "/api/auth/sms/send", + new SendSmsCodeDto + { + Realm = AuthRealm.Tenant, + TenantCode = tenantId.ToString("N"), + Phone = "13800000000", + DeviceId = "sms-endpoint-device" + }); + var body = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.DoesNotContain(provider.Code!, body, StringComparison.Ordinal); + Assert.Matches("^[0-9]{6}$", provider.Code!); + using (var scope = factory.CreateSystemScope("Verify SMS send endpoint")) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + var dimensions = await dbContext.SmsSendRateLimits + .Select(item => item.Dimension) + .ToArrayAsync(); + Assert.Contains(SmsRateLimitDimension.Tenant, dimensions); + Assert.Contains(SmsRateLimitDimension.Phone, dimensions); + Assert.Contains(SmsRateLimitDimension.Device, dimensions); + } + + client.DefaultRequestHeaders.Remove("x-tenant-code"); + var platformResponse = await client.PostAsJsonAsync( + "/api/auth/sms/send", + new SendSmsCodeDto + { + Realm = AuthRealm.Platform, + Phone = "13800000000", + DeviceId = "sms-endpoint-device" + }); + Assert.Equal(HttpStatusCode.BadRequest, platformResponse.StatusCode); + Assert.Equal(1, provider.SendCount); + } + [Fact] public async Task Custom_host_rejects_jwt_from_another_tenant_and_ignores_spoofed_tenant_header() { @@ -39,20 +93,11 @@ public sealed class AuthEndpointTests IsPrimary = true }); using var client = factory.CreateClient(); - var loginResponse = await client.PostAsJsonAsync( - "/api/auth/login/password", - new PasswordLoginDto - { - TenantCode = tenantB.TenantId.ToString("N"), - Phone = tenantB.Phone, - Password = "passw0rd!" - }); - var loginJson = await ReadJsonAsync(loginResponse); - var accessToken = loginJson.RootElement.GetProperty("tokens").GetProperty("accessToken").GetString(); + var tokens = await client.LoginAsTenantAsync(tenantB.TenantId, tenantB.Phone); using var jwtRequest = new HttpRequestMessage(HttpMethod.Get, "/api/me"); jwtRequest.Headers.Host = "a.example.test"; - jwtRequest.Headers.Authorization = new("Bearer", accessToken); + jwtRequest.Headers.Authorization = new("Bearer", tokens.AccessToken); var jwtResponse = await client.SendAsync(jwtRequest); using var spoofRequest = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login/password"); @@ -60,8 +105,9 @@ public sealed class AuthEndpointTests spoofRequest.Headers.Add("x-tenant-code", tenantB.TenantId.ToString("N")); spoofRequest.Content = JsonContent.Create(new PasswordLoginDto { + Realm = AuthRealm.Tenant, Phone = tenantB.Phone, - Password = "passw0rd!" + Password = PasswordTestUserExtensions.TestPassword }); var spoofResponse = await client.SendAsync(spoofRequest); @@ -69,6 +115,54 @@ public sealed class AuthEndpointTests Assert.NotEqual(HttpStatusCode.OK, spoofResponse.StatusCode); } + [Fact] + public async Task Platform_authentication_artifacts_are_rejected_on_an_unconfigured_host() + { + await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary + { + ["Tenancy:Resolution:ExemptPathPrefixes:3"] = "/api/auth" + }); + using var client = factory.CreateClient(); + var refreshToken = $"v2.p.-.{Guid.NewGuid():N}.{new string('a', 86)}"; + var requests = new[] + { + new HttpRequestMessage(HttpMethod.Post, "/api/auth/login/password") + { + Content = JsonContent.Create(new PasswordLoginDto + { + Realm = AuthRealm.Platform, + Phone = "admin@example.com", + Password = PasswordTestUserExtensions.TestPassword + }) + }, + new HttpRequestMessage(HttpMethod.Post, "/api/auth/refresh") + { + Content = JsonContent.Create(new RefreshSessionDto { RefreshToken = refreshToken }) + }, + new HttpRequestMessage(HttpMethod.Post, "/api/auth/logout") + { + Content = JsonContent.Create(new RefreshSessionDto { RefreshToken = refreshToken }) + }, + new HttpRequestMessage(HttpMethod.Post, "/api/auth/mfa/totp/setup") + { + Content = JsonContent.Create(new MfaChallengeDto + { + ChallengeToken = $"c1.p.-.{new string('b', 86)}" + }) + } + }; + + foreach (var request in requests) + { + using (request) + { + request.Headers.Host = "unconfigured.example.test"; + using var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + } + } + [Fact] public async Task Password_login_can_access_current_user_and_tenant() { @@ -76,25 +170,11 @@ public sealed class AuthEndpointTests var seed = await SeedLoginUserAsync(factory); using var client = factory.CreateClient(); - var loginResponse = await client.PostAsJsonAsync( - "/api/auth/login/password", - new PasswordLoginDto - { - TenantCode = seed.TenantId.ToString("N"), - Phone = seed.Phone, - Password = "passw0rd!" - }); - var loginJson = await ReadJsonAsync(loginResponse); - var accessToken = loginJson.RootElement - .GetProperty("tokens") - .GetProperty("accessToken") - .GetString(); - - client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + var tokens = await client.LoginAsTenantAsync(seed.TenantId, seed.Phone); + client.UseAccessToken(tokens); var meResponse = await client.GetAsync("/api/me"); var tenantResponse = await client.GetAsync("/api/tenants/current"); - Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, tenantResponse.StatusCode); Assert.Contains(seed.UserId.ToString(), await meResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); @@ -108,22 +188,22 @@ public sealed class AuthEndpointTests var seed = await SeedLoginUserAsync(factory); await SeedSmsCodeAsync(factory, seed.TenantId, seed.Phone, "123456"); using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N")); var loginResponse = await client.PostAsJsonAsync( "/api/auth/login/sms", new SmsLoginDto { + Realm = AuthRealm.Tenant, TenantCode = seed.TenantId.ToString("N"), Phone = seed.Phone, Code = "123456" }); - var loginJson = await ReadJsonAsync(loginResponse); - var accessToken = loginJson.RootElement - .GetProperty("tokens") - .GetProperty("accessToken") - .GetString(); - - client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + var tokens = await client.CompleteTenantAuthenticationAsync( + loginResponse, + seed.TenantId, + seed.Phone); + client.UseAccessToken(tokens); var meResponse = await client.GetAsync("/api/me"); Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode); @@ -136,27 +216,16 @@ public sealed class AuthEndpointTests await using var factory = new ApiTestFactory(); var seed = await SeedLoginUserAsync(factory); using var client = factory.CreateClient(); - var loginResponse = await client.PostAsJsonAsync( - "/api/auth/login/password", - new PasswordLoginDto - { - TenantCode = seed.TenantId.ToString("N"), - Phone = seed.Phone, - Password = "passw0rd!" - }); - var loginJson = await ReadJsonAsync(loginResponse); - var tokens = loginJson.RootElement.GetProperty("tokens"); - var accessToken = tokens.GetProperty("accessToken").GetString(); - var refreshToken = tokens.GetProperty("refreshToken").GetString(); + var tokens = await client.LoginAsTenantAsync(seed.TenantId, seed.Phone); var logoutResponse = await client.PostAsJsonAsync( "/api/auth/logout", - new RefreshSessionDto { RefreshToken = refreshToken! }); - client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + new RefreshSessionDto { RefreshToken = tokens.RefreshToken }); + client.UseAccessToken(tokens); var meResponse = await client.GetAsync("/api/me"); var refreshResponse = await client.PostAsJsonAsync( "/api/auth/refresh", - new RefreshSessionDto { RefreshToken = refreshToken! }); + new RefreshSessionDto { RefreshToken = tokens.RefreshToken }); Assert.Equal(HttpStatusCode.NoContent, logoutResponse.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, meResponse.StatusCode); @@ -206,16 +275,19 @@ public sealed class AuthEndpointTests EncryptionTag = protectedSecret.Tag }); using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); var loginResponse = await client.PostAsJsonAsync( "/api/auth/oauth/wechat-miniapp", new OAuthCodeDto { + Realm = AuthRealm.Tenant, TenantCode = tenantId.ToString("N"), Code = "wx-code" }); var loginJson = await ReadJsonAsync(loginResponse); var accessToken = loginJson.RootElement + .GetProperty("user") .GetProperty("tokens") .GetProperty("accessToken") .GetString(); @@ -231,6 +303,13 @@ public sealed class AuthEndpointTests identity.Provider == "wechat_miniapp" && identity.OpenId == "mini-open-id" && identity.UnionId == "union-id"); + var persistedUser = dbContext.Users.Single(user => + dbContext.UserIdentities.Any(identity => + identity.UserId == user.Id && identity.Provider == "wechat_miniapp")); + Assert.DoesNotContain( + "session_key", + persistedUser.RawProfile.GetRawText(), + StringComparison.OrdinalIgnoreCase); } private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLoginUserAsync( @@ -239,8 +318,6 @@ public sealed class AuthEndpointTests var tenantId = Guid.NewGuid(); var userId = Guid.NewGuid(); var phone = "13800000000"; - var passwordHash = new PasswordHasher().Hash("passw0rd!"); - await factory.SeedAsync( new Tenant { @@ -253,21 +330,13 @@ public sealed class AuthEndpointTests Id = userId, Phone = phone, Name = "Test User" - }, + }.WithTestPassword(), new TenantMembership { TenantId = tenantId, UserId = userId, Role = TenantRole.TenantAdmin, Status = MembershipStatus.Active - }, - new UserIdentity - { - UserId = userId, - Provider = "password", - ProviderSubject = phone, - Phone = phone, - SecretPayload = CreateSecretPayload(passwordHash) }); return (tenantId, userId, phone); @@ -281,12 +350,18 @@ public sealed class AuthEndpointTests { using var scope = factory.CreateSystemScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); + var smsOptions = scope.ServiceProvider.GetRequiredService>().Value; dbContext.SmsVerificationCodes.Add(new SmsVerificationCode { TenantId = tenantId, Phone = phone, Purpose = SmsPurpose.Login, - CodeHash = SmsCodeHashing.Hash(tenantId, phone, SmsPurpose.Login, code), + CodeHash = SmsCodeHashing.Hash( + tenantId, + phone, + SmsPurpose.Login, + code, + smsOptions.CodePepper), Status = SmsVerificationStatus.Sent, ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5) }); @@ -299,13 +374,6 @@ public sealed class AuthEndpointTests return await JsonDocument.ParseAsync(stream); } - private static JsonElement CreateSecretPayload(string passwordHash) - { - using var document = JsonDocument.Parse( - $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); - return document.RootElement.Clone(); - } - private static ProtectedTenantSecret ProtectTenantSecret(Guid tenantId, string secretRef, JsonElement payload) { var protector = new TenantSecretProtector(Options.Create(new TenantSecretEncryptionOptions @@ -346,4 +414,19 @@ public sealed class AuthEndpointTests """{"openid":"mini-open-id","unionid":"union-id","session_key":"session-key"}""")); } } + + private sealed class CapturingSmsProvider : ISmsProvider + { + public int SendCount { get; private set; } + public string? Code { get; private set; } + + public Task SendAsync( + SmsProviderSendRequest request, + CancellationToken cancellationToken = default) + { + SendCount++; + Code = request.Code; + return Task.FromResult(new SmsProviderSendResult("test", "sent", "sms-message-id")); + } + } } diff --git a/Tiku.IntegrationTests/Api/AuthMfaLifecycleTests.cs b/Tiku.IntegrationTests/Api/AuthMfaLifecycleTests.cs new file mode 100644 index 0000000..815fc16 --- /dev/null +++ b/Tiku.IntegrationTests/Api/AuthMfaLifecycleTests.cs @@ -0,0 +1,169 @@ +using System.Net; +using System.Net.Http.Json; +using System.Reflection; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Tiku.Api.Contracts; +using Tiku.Api.Controllers; +using Tiku.Domain.Identity; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.IntegrationTests.Api; + +public sealed class AuthMfaLifecycleTests +{ + [Fact] + public async Task Enrollment_returns_recovery_codes_once_then_subsequent_login_requires_mfa() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedBackendUserAsync(factory); + using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N")); + + using var login = await PostPasswordLoginAsync(client, seed); + Assert.Equal("mfa_enrollment_required", login.RootElement.GetProperty("status").GetString()); + var challengeToken = login.RootElement.GetProperty("challengeToken").GetString()!; + + var setupResponse = await client.PostAsJsonAsync( + "/api/auth/mfa/totp/setup", + new MfaChallengeDto { ChallengeToken = challengeToken }); + setupResponse.EnsureSuccessStatusCode(); + using var setup = JsonDocument.Parse(await setupResponse.Content.ReadAsStringAsync()); + var sharedKey = setup.RootElement.GetProperty("sharedKey").GetString()!; + + var confirmRequest = new MfaChallengeDto + { + ChallengeToken = challengeToken, + Code = AuthenticationTestClientExtensions.GenerateTotp(sharedKey) + }; + var confirmResponse = await client.PostAsJsonAsync("/api/auth/mfa/totp/confirm", confirmRequest); + confirmResponse.EnsureSuccessStatusCode(); + using var confirmation = JsonDocument.Parse(await confirmResponse.Content.ReadAsStringAsync()); + Assert.Equal( + "authenticated", + confirmation.RootElement.GetProperty("authentication").GetProperty("status").GetString()); + Assert.Equal(10, confirmation.RootElement.GetProperty("recoveryCodes").GetArrayLength()); + var recoveryCode = confirmation.RootElement.GetProperty("recoveryCodes")[0].GetString()!; + + var replayResponse = await client.PostAsJsonAsync("/api/auth/mfa/totp/confirm", confirmRequest); + Assert.Equal(HttpStatusCode.Unauthorized, replayResponse.StatusCode); + + using var nextLogin = await PostPasswordLoginAsync(client, seed); + Assert.Equal("mfa_required", nextLogin.RootElement.GetProperty("status").GetString()); + Assert.False(nextLogin.RootElement.TryGetProperty("recoveryCodes", out _)); + + var recoveryResponse = await client.PostAsJsonAsync( + "/api/auth/mfa/totp/verify", + new MfaChallengeDto + { + ChallengeToken = nextLogin.RootElement.GetProperty("challengeToken").GetString()!, + Code = recoveryCode + }); + recoveryResponse.EnsureSuccessStatusCode(); + + using var finalLogin = await PostPasswordLoginAsync(client, seed); + var replayedRecoveryResponse = await client.PostAsJsonAsync( + "/api/auth/mfa/totp/verify", + new MfaChallengeDto + { + ChallengeToken = finalLogin.RootElement.GetProperty("challengeToken").GetString()!, + Code = recoveryCode + }); + Assert.Equal(HttpStatusCode.Unauthorized, replayedRecoveryResponse.StatusCode); + + using var scope = factory.CreateSystemScope("Verify recovery code audit"); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var recoveryAudits = await dbContext.AuditLogs + .Where(item => item.Action == "auth.mfa.verified") + .ToArrayAsync(); + var recoveryAudit = Assert.Single(recoveryAudits, item => + item.Details.ToString().Contains("recovery_code", StringComparison.Ordinal)); + Assert.Equal(seed.TenantId, recoveryAudit.TenantId); + } + + [Fact] + public async Task Forced_password_change_precedes_mfa_enrollment() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedBackendUserAsync(factory, forcePasswordChange: true); + using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N")); + + using var login = await PostPasswordLoginAsync(client, seed); + + Assert.Equal("password_change_required", login.RootElement.GetProperty("status").GetString()); + Assert.False(string.IsNullOrWhiteSpace(login.RootElement.GetProperty("challengeToken").GetString())); + } + + [Theory] + [InlineData(nameof(AuthController.LoginWithPassword), "login/password")] + [InlineData(nameof(AuthController.SendSmsCode), "sms/send")] + [InlineData(nameof(AuthController.LoginWithSms), "login/sms")] + [InlineData(nameof(AuthController.LoginWithWechatWeb), "oauth/wechat")] + [InlineData(nameof(AuthController.LoginWithWechatMiniApp), "oauth/wechat-miniapp")] + [InlineData(nameof(AuthController.SetupTotp), "mfa/totp/setup")] + [InlineData(nameof(AuthController.ConfirmTotp), "mfa/totp/confirm")] + [InlineData(nameof(AuthController.VerifyTotp), "mfa/totp/verify")] + [InlineData(nameof(AuthController.Refresh), "refresh")] + [InlineData(nameof(AuthController.Logout), "logout")] + [InlineData(nameof(AuthController.LogoutAll), "logout-all")] + public void Authentication_routes_match_the_v2_contract(string actionName, string route) + { + var action = typeof(AuthController).GetMethod(actionName, BindingFlags.Public | BindingFlags.Instance); + var attribute = action?.GetCustomAttribute(); + + Assert.NotNull(attribute); + Assert.Equal(route, attribute.Template); + } + + private static async Task PostPasswordLoginAsync( + HttpClient client, + (Guid TenantId, string Phone) seed) + { + var response = await client.PostAsJsonAsync( + "/api/auth/login/password", + new PasswordLoginDto + { + Realm = AuthRealm.Tenant, + TenantCode = seed.TenantId.ToString("N"), + Identifier = seed.Phone, + Password = PasswordTestUserExtensions.TestPassword + }); + response.EnsureSuccessStatusCode(); + return JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + } + + private static async Task<(Guid TenantId, string Phone)> SeedBackendUserAsync( + ApiTestFactory factory, + bool forcePasswordChange = false) + { + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + const string phone = "13800000000"; + await factory.SeedAsync( + new Tenant + { + Id = tenantId, + Slug = tenantId.ToString("N"), + Name = "MFA Lifecycle Tenant" + }, + new User + { + Id = userId, + Phone = phone, + Name = "MFA Lifecycle User", + ForcePasswordChange = forcePasswordChange + }.WithTestPassword(), + new TenantMembership + { + TenantId = tenantId, + UserId = userId, + Role = TenantRole.TenantAdmin, + Status = MembershipStatus.Active + }); + return (tenantId, phone); + } +} diff --git a/Tiku.IntegrationTests/Api/AuthRateLimitPolicyTests.cs b/Tiku.IntegrationTests/Api/AuthRateLimitPolicyTests.cs new file mode 100644 index 0000000..ff7ca28 --- /dev/null +++ b/Tiku.IntegrationTests/Api/AuthRateLimitPolicyTests.cs @@ -0,0 +1,188 @@ +using System.Net; +using System.Reflection; +using System.Text; +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Configuration; +using Tiku.Api.Controllers; +using Tiku.Api.Middleware; +using Tiku.Api.Options; + +namespace Tiku.IntegrationTests.Api; + +public sealed class AuthRateLimitPolicyTests +{ + [Fact] + public void Authentication_rate_limits_bind_from_the_named_configuration_section() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [$"{AuthRateLimitOptions.SectionName}:PasswordPermitLimit"] = "7", + [$"{AuthRateLimitOptions.SectionName}:PasswordWindowSeconds"] = "600", + [$"{AuthRateLimitOptions.SectionName}:SmsPermitLimit"] = "3", + [$"{AuthRateLimitOptions.SectionName}:SmsWindowSeconds"] = "90", + [$"{AuthRateLimitOptions.SectionName}:MfaPermitLimit"] = "4", + [$"{AuthRateLimitOptions.SectionName}:MfaWindowSeconds"] = "120" + }) + .Build(); + + var options = configuration + .GetSection(AuthRateLimitOptions.SectionName) + .Get(); + + Assert.NotNull(options); + Assert.Equal(7, options.PasswordPermitLimit); + Assert.Equal(600, options.PasswordWindowSeconds); + Assert.Equal(3, options.SmsPermitLimit); + Assert.Equal(90, options.SmsWindowSeconds); + Assert.Equal(4, options.MfaPermitLimit); + Assert.Equal(120, options.MfaWindowSeconds); + } + + [Fact] + public void Authentication_rate_limit_values_must_be_positive() + { + var options = new AuthRateLimitOptions + { + PasswordPermitLimit = 0, + MfaWindowSeconds = 0 + }; + var validationResults = new List(); + + var valid = Validator.TryValidateObject( + options, + new ValidationContext(options), + validationResults, + validateAllProperties: true); + + Assert.False(valid); + Assert.Equal(2, validationResults.Count); + } + + [Fact] + public void Password_login_uses_the_password_named_policy() + { + AssertPolicy(nameof(AuthController.LoginWithPassword), AuthRateLimitPolicies.Password); + } + + [Fact] + public void Sms_send_uses_the_sms_named_policy() + { + AssertPolicy(nameof(AuthController.SendSmsCode), AuthRateLimitPolicies.Sms); + } + + [Theory] + [InlineData(nameof(AuthController.SetupTotp))] + [InlineData(nameof(AuthController.ConfirmTotp))] + [InlineData(nameof(AuthController.VerifyTotp))] + public void Mfa_challenge_endpoints_use_the_mfa_named_policy(string methodName) + { + AssertPolicy(methodName, AuthRateLimitPolicies.Mfa); + } + + [Fact] + public async Task Password_partition_combines_account_and_ip_without_exposing_the_account() + { + var first = await CapturePartitionAsync( + AuthRateLimitPolicies.Password, + """{"Phone":"13800000000","password":"secret"}""", + "127.0.0.1"); + var same = await CapturePartitionAsync( + AuthRateLimitPolicies.Password, + """{"phone":"13800000000","password":"different"}""", + "127.0.0.1"); + var differentAccount = await CapturePartitionAsync( + AuthRateLimitPolicies.Password, + """{"phone":"13900000000","password":"secret"}""", + "127.0.0.1"); + var differentIp = await CapturePartitionAsync( + AuthRateLimitPolicies.Password, + """{"phone":"13800000000","password":"secret"}""", + "127.0.0.2"); + + Assert.Equal(first, same); + Assert.NotEqual(first, differentAccount); + Assert.NotEqual(first, differentIp); + Assert.DoesNotContain("13800000000", first, StringComparison.Ordinal); + Assert.DoesNotContain("secret", first, StringComparison.Ordinal); + } + + [Fact] + public async Task Sms_partition_combines_phone_and_ip_without_exposing_the_phone() + { + var first = await CapturePartitionAsync( + AuthRateLimitPolicies.Sms, + """{"phone":"13800000000","deviceId":"device-one"}""", + "127.0.0.1"); + var differentPhone = await CapturePartitionAsync( + AuthRateLimitPolicies.Sms, + """{"phone":"13900000000","deviceId":"device-one"}""", + "127.0.0.1"); + var differentIp = await CapturePartitionAsync( + AuthRateLimitPolicies.Sms, + """{"phone":"13800000000","deviceId":"device-one"}""", + "127.0.0.2"); + + Assert.NotEqual(first, differentPhone); + Assert.NotEqual(first, differentIp); + Assert.DoesNotContain("13800000000", first, StringComparison.Ordinal); + } + + [Fact] + public async Task Mfa_partition_uses_the_challenge_token_and_resets_the_request_body() + { + const string body = """{"challengeToken":"challenge-one","code":"123456"}"""; + var first = await CapturePartitionAsync( + AuthRateLimitPolicies.Mfa, + body, + "127.0.0.1"); + var second = await CapturePartitionAsync( + AuthRateLimitPolicies.Mfa, + """{"challengeToken":"challenge-two","code":"123456"}""", + "127.0.0.1"); + + Assert.NotEqual(first, second); + Assert.DoesNotContain("challenge-one", first, StringComparison.Ordinal); + } + + private static void AssertPolicy(string methodName, string expectedPolicy) + { + var method = typeof(AuthController).GetMethod(methodName, BindingFlags.Public | BindingFlags.Instance); + var attribute = method?.GetCustomAttribute(); + + Assert.NotNull(attribute); + Assert.Equal(expectedPolicy, attribute.PolicyName); + } + + private static async Task CapturePartitionAsync( + string policyName, + string json, + string ipAddress) + { + var context = new DefaultHttpContext(); + context.Connection.RemoteIpAddress = IPAddress.Parse(ipAddress); + context.Request.Method = HttpMethods.Post; + context.Request.ContentType = "application/json"; + context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json)); + context.SetEndpoint(new Endpoint( + _ => Task.CompletedTask, + new EndpointMetadataCollection(new EnableRateLimitingAttribute(policyName)), + "auth-rate-limit-test")); + + string? partition = null; + var middleware = new AuthRateLimitPartitionMiddleware(async nextContext => + { + partition = AuthRateLimitPartitionKey.Resolve(nextContext, policyName); + using var reader = new StreamReader( + nextContext.Request.Body, + Encoding.UTF8, + leaveOpen: true); + Assert.Equal(json, await reader.ReadToEndAsync()); + }); + + await middleware.InvokeAsync(context); + return Assert.IsType(partition); + } +} diff --git a/Tiku.IntegrationTests/Api/AuthSessionLifecycleTests.cs b/Tiku.IntegrationTests/Api/AuthSessionLifecycleTests.cs new file mode 100644 index 0000000..202eec4 --- /dev/null +++ b/Tiku.IntegrationTests/Api/AuthSessionLifecycleTests.cs @@ -0,0 +1,287 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Tiku.Application.Auth; +using Tiku.Domain.Identity; +using Tiku.Domain.Operations; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.IntegrationTests.Api; + +public sealed class AuthSessionLifecycleTests +{ + [Fact] + public async Task Refresh_rotation_creates_a_child_and_replay_revokes_the_entire_family() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedActiveMemberAsync(factory); + var original = await IssueAsync(factory, seed); + + AuthTokenPair rotated; + using (var scope = factory.CreateSystemScope("Rotate refresh token")) + { + rotated = await scope.ServiceProvider.GetRequiredService() + .RotateAsync(original.RefreshToken, "127.0.0.1", "integration-test"); + } + + Assert.True(TryLocate(factory, original.RefreshToken, out var originalLocator)); + Assert.True(TryLocate(factory, rotated.RefreshToken, out var rotatedLocator)); + + using (var scope = factory.CreateSystemScope("Verify rotated session lineage")) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + var sessions = await dbContext.AuthSessions + .Where(session => session.Id == originalLocator.SessionId || session.Id == rotatedLocator.SessionId) + .OrderBy(session => session.ParentSessionId == null ? 0 : 1) + .ToListAsync(); + + Assert.Equal(2, sessions.Count); + Assert.Equal(originalLocator.SessionId, sessions[0].Id); + Assert.Equal(rotatedLocator.SessionId, sessions[0].ReplacedBySessionId); + Assert.Equal("rotated", sessions[0].RevokedReason); + Assert.Equal(originalLocator.SessionId, sessions[1].ParentSessionId); + Assert.Equal(sessions[0].TokenFamilyId, sessions[1].TokenFamilyId); + } + + using (var scope = factory.CreateSystemScope("Replay rotated refresh token")) + { + await Assert.ThrowsAsync(() => + scope.ServiceProvider.GetRequiredService() + .RotateAsync(original.RefreshToken, null, null)); + } + + using (var scope = factory.CreateSystemScope("Verify refresh family revocation")) + { + var store = scope.ServiceProvider.GetRequiredService(); + var validation = await store.ValidateAccessSessionAsync( + rotatedLocator.SessionId, + seed.UserId, + AuthRealm.Tenant, + seed.TenantId); + Assert.Null(validation); + + var dbContext = scope.ServiceProvider.GetRequiredService(); + var family = await dbContext.AuthSessions + .Where(session => session.TokenFamilyId == originalLocator.SessionId) + .ToListAsync(); + Assert.All(family, session => Assert.NotNull(session.RevokedAt)); + Assert.Contains(family, session => session.RevokedReason == "refresh_token_reuse"); + } + } + + [Fact] + public async Task Concurrent_refresh_allows_only_one_rotation_and_revokes_the_replayed_family() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedActiveMemberAsync(factory); + var original = await IssueAsync(factory, seed); + Assert.True(TryLocate(factory, original.RefreshToken, out var originalLocator)); + + using var firstScope = factory.CreateSystemScope("First concurrent refresh"); + using var secondScope = factory.CreateSystemScope("Second concurrent refresh"); + var first = TryRotateAsync( + firstScope.ServiceProvider.GetRequiredService(), + original.RefreshToken); + var second = TryRotateAsync( + secondScope.ServiceProvider.GetRequiredService(), + original.RefreshToken); + var results = await Task.WhenAll(first, second); + + Assert.Single(results, result => result is not null); + Assert.Single(results, result => result is null); + + using var verificationScope = factory.CreateSystemScope("Verify concurrent refresh family"); + var dbContext = verificationScope.ServiceProvider.GetRequiredService(); + var family = await dbContext.AuthSessions + .Where(session => session.TokenFamilyId == originalLocator.SessionId) + .ToListAsync(); + Assert.Equal(2, family.Count); + Assert.All(family, session => Assert.NotNull(session.RevokedAt)); + Assert.Contains(family, session => session.RevokedReason == "refresh_token_reuse"); + } + + [Fact] + public async Task Access_session_fails_immediately_after_membership_is_disabled() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedActiveMemberAsync(factory); + var tokens = await IssueAsync(factory, seed); + Assert.True(TryLocate(factory, tokens.RefreshToken, out var locator)); + + using (var scope = factory.CreateSystemScope("Disable tenant membership")) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + var membership = await dbContext.TenantMemberships.SingleAsync(item => + item.TenantId == seed.TenantId && item.UserId == seed.UserId); + membership.Status = MembershipStatus.Disabled; + await dbContext.SaveChangesAsync(); + } + + using (var scope = factory.CreateSystemScope("Validate disabled membership session")) + { + var validation = await scope.ServiceProvider.GetRequiredService() + .ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId); + Assert.Null(validation); + } + } + + [Fact] + public async Task Access_session_fails_immediately_after_security_stamp_changes() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedActiveMemberAsync(factory); + var tokens = await IssueAsync(factory, seed); + Assert.True(TryLocate(factory, tokens.RefreshToken, out var locator)); + + using (var scope = factory.CreateSystemScope("Change user security stamp")) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + var user = await dbContext.Users.SingleAsync(item => item.Id == seed.UserId); + user.SecurityStamp = Guid.NewGuid().ToString("N"); + await dbContext.SaveChangesAsync(); + } + + using (var scope = factory.CreateSystemScope("Validate stale security stamp session")) + { + var validation = await scope.ServiceProvider.GetRequiredService() + .ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId); + Assert.Null(validation); + } + } + + [Fact] + public async Task Backend_session_and_refresh_fail_immediately_after_the_last_permission_is_revoked() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedActiveMemberAsync(factory); + var role = new TenantBackendRole + { + TenantId = seed.TenantId, + Code = "session-test-admin", + Name = "Session test administrator" + }; + const string permissionCode = "tenant:session-test:manage"; + await factory.SeedAsync( + new BackendPermission + { + Code = permissionCode, + Name = permissionCode, + Area = BackendPermissionArea.Tenant, + Module = "test" + }, + role, + new TenantBackendRolePermission + { + TenantId = seed.TenantId, + RoleId = role.Id, + PermissionCode = permissionCode + }, + new TenantBackendUserRole + { + TenantId = seed.TenantId, + UserId = seed.UserId, + RoleId = role.Id + }); + var tokens = await IssueAsync(factory, seed, mfaSatisfied: true); + Assert.True(TryLocate(factory, tokens.RefreshToken, out var locator)); + + using (var scope = factory.CreateSystemScope("Revoke final backend permission")) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + var binding = await dbContext.TenantBackendRolePermissions.SingleAsync(item => + item.TenantId == seed.TenantId && item.RoleId == role.Id); + dbContext.TenantBackendRolePermissions.Remove(binding); + await dbContext.SaveChangesAsync(); + } + + using (var scope = factory.CreateSystemScope("Validate revoked backend session")) + { + var store = scope.ServiceProvider.GetRequiredService(); + Assert.Null(await store.ValidateAccessSessionAsync( + locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId)); + await Assert.ThrowsAsync(() => + store.RotateAsync(tokens.RefreshToken, null, null)); + } + + using (var scope = factory.CreateSystemScope("Verify revoked backend family")) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + var session = await dbContext.AuthSessions.SingleAsync(item => item.Id == locator.SessionId); + Assert.NotNull(session.RevokedAt); + Assert.Equal("realm_access_revoked", session.RevokedReason); + } + } + + private static bool TryLocate( + ApiTestFactory factory, + string refreshToken, + out RefreshTokenLocator locator) + { + using var scope = factory.CreateSystemScope("Parse refresh token locator"); + return scope.ServiceProvider.GetRequiredService() + .TryParseRefreshToken(refreshToken, out locator); + } + + private static async Task TryRotateAsync(IAuthSessionStore store, string refreshToken) + { + try + { + return await store.RotateAsync(refreshToken, null, null); + } + catch (SessionRevokedException) + { + return null; + } + } + + private static async Task IssueAsync( + ApiTestFactory factory, + SessionSeed seed, + bool mfaSatisfied = false) + { + using var scope = factory.CreateSystemScope("Issue authentication session"); + return await scope.ServiceProvider.GetRequiredService().IssueAsync( + new AuthSessionIssueRequest( + seed.UserId, + seed.Phone, + null, + seed.SecurityStamp, + AuthRealm.Tenant, + seed.TenantId, + "integration-test", + mfaSatisfied, + "127.0.0.1", + "integration-test")); + } + + private static async Task SeedActiveMemberAsync(ApiTestFactory factory) + { + var tenantId = Guid.NewGuid(); + var user = new User + { + Id = Guid.NewGuid(), + Phone = $"13{Random.Shared.Next(100_000_000, 1_000_000_000)}", + Name = "Session lifecycle user" + }; + await factory.SeedAsync( + new Tenant + { + Id = tenantId, + Slug = tenantId.ToString("N"), + Name = "Session lifecycle tenant", + Status = TenantStatus.Active + }, + user, + new TenantMembership + { + TenantId = tenantId, + UserId = user.Id, + Role = TenantRole.Student, + Status = MembershipStatus.Active + }); + + return new SessionSeed(tenantId, user.Id, user.Phone, user.SecurityStamp!); + } + + private sealed record SessionSeed(Guid TenantId, Guid UserId, string Phone, string SecurityStamp); +} diff --git a/Tiku.IntegrationTests/Api/AuthenticationTestClientExtensions.cs b/Tiku.IntegrationTests/Api/AuthenticationTestClientExtensions.cs new file mode 100644 index 0000000..9c14bcc --- /dev/null +++ b/Tiku.IntegrationTests/Api/AuthenticationTestClientExtensions.cs @@ -0,0 +1,183 @@ +using System.Collections.Concurrent; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text.Json; +using Tiku.Api.Contracts; +using Tiku.Domain.Identity; +using Tiku.Domain.Tenancy; + +namespace Tiku.IntegrationTests.Api; + +internal sealed record TestAuthenticationTokens(string AccessToken, string RefreshToken); + +internal static class AuthenticationTestClientExtensions +{ + private static readonly ConcurrentDictionary AuthenticatorKeys = new(StringComparer.Ordinal); + + public static async Task LoginAsTenantAsync( + this HttpClient client, + Guid tenantId, + string identifier, + string password = PasswordTestUserExtensions.TestPassword) + { + SetTenantHeader(client, tenantId); + var response = await client.PostAsJsonAsync( + "/api/auth/login/password", + new PasswordLoginDto + { + Realm = AuthRealm.Tenant, + TenantCode = tenantId.ToString("N"), + Identifier = identifier, + Password = password + }); + + return await client.CompleteTenantAuthenticationAsync(response, tenantId, identifier); + } + + public static async Task CompleteTenantAuthenticationAsync( + this HttpClient client, + HttpResponseMessage response, + Guid tenantId, + string authenticatorCacheKey) + { + SetTenantHeader(client, tenantId); + using var authentication = await ReadSuccessfulJsonAsync(response); + var root = authentication.RootElement; + var status = root.GetProperty("status").GetString(); + + if (string.Equals(status, "authenticated", StringComparison.OrdinalIgnoreCase)) + { + return ReadTokens(root.GetProperty("user").GetProperty("tokens")); + } + + var challengeToken = root.GetProperty("challengeToken").GetString() + ?? throw new InvalidOperationException("Authentication challenge did not contain a challenge token."); + var keyId = $"{tenantId:N}:{authenticatorCacheKey}"; + + if (string.Equals(status, "mfa_enrollment_required", StringComparison.OrdinalIgnoreCase)) + { + var setupResponse = await client.PostAsJsonAsync( + "/api/auth/mfa/totp/setup", + new MfaChallengeDto { ChallengeToken = challengeToken }); + using var setup = await ReadSuccessfulJsonAsync(setupResponse); + var sharedKey = setup.RootElement.GetProperty("sharedKey").GetString() + ?? throw new InvalidOperationException("MFA setup did not return a shared key."); + AuthenticatorKeys[keyId] = sharedKey; + + var confirmResponse = await client.PostAsJsonAsync( + "/api/auth/mfa/totp/confirm", + new MfaChallengeDto + { + ChallengeToken = challengeToken, + Code = GenerateTotp(sharedKey) + }); + using var confirmation = await ReadSuccessfulJsonAsync(confirmResponse); + return ReadTokens( + confirmation.RootElement + .GetProperty("authentication") + .GetProperty("user") + .GetProperty("tokens")); + } + + if (string.Equals(status, "mfa_required", StringComparison.OrdinalIgnoreCase) && + AuthenticatorKeys.TryGetValue(keyId, out var existingKey)) + { + var verifyResponse = await client.PostAsJsonAsync( + "/api/auth/mfa/totp/verify", + new MfaChallengeDto + { + ChallengeToken = challengeToken, + Code = GenerateTotp(existingKey) + }); + using var verification = await ReadSuccessfulJsonAsync(verifyResponse); + return ReadTokens(verification.RootElement.GetProperty("user").GetProperty("tokens")); + } + + throw new InvalidOperationException($"Unsupported test authentication status '{status}'."); + } + + public static void UseAccessToken(this HttpClient client, TestAuthenticationTokens tokens) + { + client.DefaultRequestHeaders.Authorization = new("Bearer", tokens.AccessToken); + } + + private static void SetTenantHeader(HttpClient client, Guid tenantId) + { + client.DefaultRequestHeaders.Remove("x-tenant-code"); + client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); + } + + private static async Task ReadSuccessfulJsonAsync(HttpResponseMessage response) + { + var body = await response.Content.ReadAsStringAsync(); + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException( + $"Authentication request failed with {(int)response.StatusCode} ({response.StatusCode}): {body}"); + } + + return JsonDocument.Parse(body); + } + + private static TestAuthenticationTokens ReadTokens(JsonElement tokens) + { + var accessToken = tokens.GetProperty("accessToken").GetString() + ?? throw new InvalidOperationException("Authentication response did not contain an access token."); + var refreshToken = tokens.GetProperty("refreshToken").GetString() + ?? throw new InvalidOperationException("Authentication response did not contain a refresh token."); + return new TestAuthenticationTokens(accessToken, refreshToken); + } + + internal static string GenerateTotp(string sharedKey) + { + var secret = DecodeBase32(sharedKey); + var counter = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / 30; + Span counterBytes = stackalloc byte[8]; + for (var index = counterBytes.Length - 1; index >= 0; index--) + { + counterBytes[index] = (byte)(counter & 0xff); + counter >>= 8; + } + + var hash = HMACSHA1.HashData(secret, counterBytes); + var offset = hash[^1] & 0x0f; + var binaryCode = ((hash[offset] & 0x7f) << 24) | + (hash[offset + 1] << 16) | + (hash[offset + 2] << 8) | + hash[offset + 3]; + return (binaryCode % 1_000_000).ToString("D6", System.Globalization.CultureInfo.InvariantCulture); + } + + private static byte[] DecodeBase32(string value) + { + var normalized = value.Replace(" ", string.Empty, StringComparison.Ordinal) + .TrimEnd('=') + .ToUpperInvariant(); + var output = new byte[normalized.Length * 5 / 8]; + var buffer = 0; + var bitsInBuffer = 0; + var outputIndex = 0; + + foreach (var character in normalized) + { + var digit = character switch + { + >= 'A' and <= 'Z' => character - 'A', + >= '2' and <= '7' => character - '2' + 26, + _ => throw new FormatException("Authenticator shared key is not valid Base32.") + }; + buffer = (buffer << 5) | digit; + bitsInBuffer += 5; + if (bitsInBuffer < 8) + { + continue; + } + + output[outputIndex++] = (byte)(buffer >> (bitsInBuffer - 8)); + bitsInBuffer -= 8; + buffer &= (1 << bitsInBuffer) - 1; + } + + return output; + } +} diff --git a/Tiku.IntegrationTests/Api/BackofficeUiBootstrapTests.cs b/Tiku.IntegrationTests/Api/BackofficeUiBootstrapTests.cs new file mode 100644 index 0000000..3ca9adb --- /dev/null +++ b/Tiku.IntegrationTests/Api/BackofficeUiBootstrapTests.cs @@ -0,0 +1,91 @@ +using System.Net; +using System.Text.Json; +using Tiku.Application.Backoffice; +using Tiku.Application.Security; +using Tiku.Domain.Common; +using Tiku.Domain.Identity; +using Tiku.Domain.Operations; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Auth; + +namespace Tiku.IntegrationTests.Api; + +public sealed class BackofficeUiBootstrapTests +{ + [Fact] + public async Task TenantUiBootstrap_ReturnsOnlyMenusAllowedByEffectivePermissions() + { + await using var factory = new ApiTestFactory(); + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var roleId = Guid.NewGuid(); + var phone = "13710000000"; + await factory.SeedAsync( + new Tenant + { + Id = tenantId, + Slug = tenantId.ToString("N"), + Name = "Scoped UI Tenant", + Status = TenantStatus.Active, + Metadata = JsonDefaults.Object() + }, + new User + { + Id = userId, + Phone = phone, + Name = "Dashboard Operator" + }.WithTestPassword(), + new TenantMembership + { + TenantId = tenantId, + UserId = userId, + Role = TenantRole.Student, + Status = MembershipStatus.Active + }, + new BackendPermission + { + Code = BackendPermissions.TenantDashboardView, + Name = "Tenant dashboard", + Area = BackendPermissionArea.Tenant, + Module = "tenant_dashboard", + IsSystem = true + }, + new TenantBackendRole + { + Id = roleId, + TenantId = tenantId, + Code = "dashboard_operator", + Name = "Dashboard Operator", + Status = BackendRoleStatus.Active, + DataScope = JsonSerializer.SerializeToElement(new { mode = "self" }) + }, + new TenantBackendRolePermission + { + TenantId = tenantId, + RoleId = roleId, + PermissionCode = BackendPermissions.TenantDashboardView + }, + new TenantBackendUserRole + { + TenantId = tenantId, + UserId = userId, + RoleId = roleId + }); + + using var client = factory.CreateClient(); + client.UseAccessToken(await client.LoginAsTenantAsync(tenantId, phone)); + + using var response = await client.GetAsync("/api/backoffice/tenant/ui-bootstrap"); + using var bootstrap = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + using var roleManagementResponse = await client.GetAsync("/api/backoffice/tenant/bootstrap"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal( + [BackendPermissions.TenantDashboardView], + bootstrap.RootElement.GetProperty("permissionCodes").EnumerateArray().Select(item => item.GetString())); + Assert.Equal( + ["tenant.dashboard"], + bootstrap.RootElement.GetProperty("menus").EnumerateArray().Select(item => item.GetProperty("code").GetString())); + Assert.Equal(HttpStatusCode.Forbidden, roleManagementResponse.StatusCode); + } +} diff --git a/Tiku.IntegrationTests/Api/CommerceEndpointTests.cs b/Tiku.IntegrationTests/Api/CommerceEndpointTests.cs index 7537267..be7c7e5 100644 --- a/Tiku.IntegrationTests/Api/CommerceEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/CommerceEndpointTests.cs @@ -1,11 +1,8 @@ -using System.IdentityModel.Tokens.Jwt; using System.Net; using System.Net.Http.Json; using System.Security.Claims; -using System.Text; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; -using Microsoft.IdentityModel.Tokens; using Tiku.Api.Contracts; using Tiku.Api.Options; using Tiku.Application.Auth; @@ -21,13 +18,6 @@ namespace Tiku.IntegrationTests.Api; public sealed class CommerceEndpointTests { - private static readonly JwtOptions JwtOptions = new() - { - Issuer = "tiku-backend", - Audience = "tiku-api", - SigningKey = "development-only-tiku-signing-key-change-before-production" - }; - [Fact] public async Task Anonymous_commerce_request_returns_401() { @@ -60,11 +50,10 @@ public sealed class CommerceEndpointTests using var client = factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new( "Bearer", - CreateToken([ + TestJwtKeys.CreateToken([ new Claim(TikuClaimTypes.UserId, userId.ToString()), new Claim(TikuClaimTypes.SessionId, sessionId.ToString()), - new Claim(TikuClaimTypes.TenantId, tenantId.ToString()), - new Claim(TikuClaimTypes.TenantRole, TenantRole.Student.ToString()) + new Claim(TikuClaimTypes.TenantId, tenantId.ToString()) ])); var response = await client.PostAsJsonAsync( @@ -343,7 +332,6 @@ public sealed class CommerceEndpointTests var userId = Guid.NewGuid(); var planId = Guid.NewGuid(); var phone = "13800000000"; - var passwordHash = new PasswordHasher().Hash("passw0rd!"); var entities = new List { new Tenant @@ -357,15 +345,7 @@ public sealed class CommerceEndpointTests Id = userId, Phone = phone, Name = "Commerce User" - }, - new UserIdentity - { - UserId = userId, - Provider = "password", - ProviderSubject = phone, - Phone = phone, - SecretPayload = CreateSecretPayload(passwordHash) - }, + }.WithTestPassword(), new SvipPlan { Id = planId, @@ -401,44 +381,7 @@ public sealed class CommerceEndpointTests private static async Task LoginAsync(HttpClient client, LoginSeed seed) { - var loginResponse = await client.PostAsJsonAsync( - "/api/auth/login/password", - new PasswordLoginDto - { - TenantCode = seed.TenantId.ToString("N"), - Phone = seed.Phone, - Password = "passw0rd!" - }); - loginResponse.EnsureSuccessStatusCode(); - using var loginJson = await JsonDocument.ParseAsync(await loginResponse.Content.ReadAsStreamAsync()); - var accessToken = loginJson.RootElement - .GetProperty("tokens") - .GetProperty("accessToken") - .GetString(); - client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); - } - - private static JsonElement CreateSecretPayload(string passwordHash) - { - using var document = JsonDocument.Parse( - $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); - return document.RootElement.Clone(); - } - - private static string CreateToken(IEnumerable claims) - { - var credentials = new SigningCredentials( - new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtOptions.SigningKey)), - SecurityAlgorithms.HmacSha256); - - var token = new JwtSecurityToken( - JwtOptions.Issuer, - JwtOptions.Audience, - claims, - expires: DateTime.UtcNow.AddMinutes(5), - signingCredentials: credentials); - - return new JwtSecurityTokenHandler().WriteToken(token); + client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone)); } private sealed record LoginSeed(Guid TenantId, Guid UserId, Guid PlanId, string Phone); diff --git a/Tiku.IntegrationTests/Api/CommissionEndpointTests.cs b/Tiku.IntegrationTests/Api/CommissionEndpointTests.cs index a2a0af8..6747625 100644 --- a/Tiku.IntegrationTests/Api/CommissionEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/CommissionEndpointTests.cs @@ -108,9 +108,6 @@ public sealed class CommissionEndpointTests User(admin, "tenant_admin"), User(referrer, "sales"), User(student, "student"), - Identity(admin), - Identity(referrer), - Identity(student), Membership(admin, TenantRole.TenantAdmin), Membership(referrer, TenantRole.Sales), Membership(student, TenantRole.Student), @@ -156,22 +153,13 @@ public sealed class CommissionEndpointTests return new CommissionSeed(tenantId, admin, referrer, student); } - private static User User(LoginSeed seed, string role) => new() { Id = seed.UserId, Phone = seed.Phone, Name = role, PrimaryRole = role }; + private static User User(LoginSeed seed, string role) => + new User { Id = seed.UserId, Phone = seed.Phone, Name = role, PrimaryRole = role }.WithTestPassword(); private static TenantMembership Membership(LoginSeed seed, TenantRole role) => new() { TenantId = seed.TenantId, UserId = seed.UserId, Role = role, Status = MembershipStatus.Active }; - private static UserIdentity Identity(LoginSeed seed) => new() { UserId = seed.UserId, Provider = "password", ProviderSubject = seed.Phone, Phone = seed.Phone, SecretPayload = CreateSecretPayload(new PasswordHasher().Hash("passw0rd!")) }; private static async Task LoginAsync(HttpClient client, LoginSeed seed) { - var loginResponse = await client.PostAsJsonAsync("/api/auth/login/password", new PasswordLoginDto { TenantCode = seed.TenantId.ToString("N"), Phone = seed.Phone, Password = "passw0rd!" }); - loginResponse.EnsureSuccessStatusCode(); - using var loginJson = await JsonDocument.ParseAsync(await loginResponse.Content.ReadAsStreamAsync()); - client.DefaultRequestHeaders.Authorization = new("Bearer", loginJson.RootElement.GetProperty("tokens").GetProperty("accessToken").GetString()); - } - - private static JsonElement CreateSecretPayload(string passwordHash) - { - using var document = JsonDocument.Parse($$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); - return document.RootElement.Clone(); + client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone)); } private sealed record LoginSeed(Guid TenantId, Guid UserId, string Phone); diff --git a/Tiku.IntegrationTests/Api/ContentManagementEndpointTests.cs b/Tiku.IntegrationTests/Api/ContentManagementEndpointTests.cs index 88d615c..0c39d0b 100644 --- a/Tiku.IntegrationTests/Api/ContentManagementEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/ContentManagementEndpointTests.cs @@ -168,6 +168,79 @@ public sealed class ContentManagementEndpointTests Assert.NotEmpty(template.RootElement.GetProperty("contentBase64").GetString() ?? string.Empty); } + [Fact] + public async Task ContentEntries_ApplySelfAndRestrictedScopesAndHideUnauthorizedUpdates() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedAdminAsync(factory); + var allowedRegionId = Guid.NewGuid(); + var outsideRegionId = Guid.NewGuid(); + var regionalCreatorId = Guid.NewGuid(); + var outsideCreatorId = Guid.NewGuid(); + var ownEntry = new ContentEntry + { + TenantId = seed.TenantId, + RegionId = outsideRegionId, + EntryKey = "own-entry", + Name = "Own Entry", + CreatedBy = seed.UserId + }; + var regionalEntry = new ContentEntry + { + TenantId = seed.TenantId, + RegionId = allowedRegionId, + EntryKey = "regional-entry", + Name = "Regional Entry", + CreatedBy = regionalCreatorId + }; + var outsideEntry = new ContentEntry + { + TenantId = seed.TenantId, + RegionId = outsideRegionId, + EntryKey = "outside-entry", + Name = "Outside Entry", + CreatedBy = outsideCreatorId + }; + await factory.SeedAsync( + new Tiku.Domain.Catalog.Region { Id = allowedRegionId, TenantId = seed.TenantId, Name = "Allowed Region" }, + new Tiku.Domain.Catalog.Region { Id = outsideRegionId, TenantId = seed.TenantId, Name = "Outside Region" }, + new User { Id = regionalCreatorId, Name = "Regional Creator" }, + new User { Id = outsideCreatorId, Name = "Outside Creator" }, + ownEntry, + regionalEntry, + outsideEntry); + await SetDataScopeAsync(factory, seed.TenantId, new { mode = "self" }); + + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + using var selfResponse = await client.GetAsync("/api/tenant-content/entries?includeInactive=true"); + using var selfJson = await ReadJsonAsync(selfResponse); + + await SetDataScopeAsync(factory, seed.TenantId, new + { + mode = "restricted", + regionIds = new[] { allowedRegionId }, + includesSelf = false + }); + using var restrictedResponse = await client.GetAsync("/api/tenant-content/entries?includeInactive=true"); + using var restrictedJson = await ReadJsonAsync(restrictedResponse); + using var deniedUpdate = await client.PostAsJsonAsync( + "/api/tenant-content/entries", + new UpsertContentEntryDto + { + Id = outsideEntry.Id, + RegionId = outsideRegionId, + EntryKey = outsideEntry.EntryKey, + Name = "Must stay hidden" + }); + + Assert.Equal([ownEntry.Id], selfJson.RootElement.GetProperty("items").EnumerateArray() + .Select(item => item.GetProperty("id").GetGuid())); + Assert.Equal([regionalEntry.Id], restrictedJson.RootElement.GetProperty("items").EnumerateArray() + .Select(item => item.GetProperty("id").GetGuid())); + Assert.Equal(HttpStatusCode.NotFound, deniedUpdate.StatusCode); + } + private static async Task CreateEntryAsync(HttpClient client) { using var response = await client.PostAsJsonAsync( @@ -187,8 +260,6 @@ public sealed class ContentManagementEndpointTests var tenantId = Guid.NewGuid(); var userId = Guid.NewGuid(); var phone = "13700000000"; - var passwordHash = new PasswordHasher().Hash("passw0rd!"); - await factory.SeedAsync( new Tenant { @@ -203,21 +274,13 @@ public sealed class ContentManagementEndpointTests Id = userId, Phone = phone, Name = "Tenant Admin" - }, + }.WithTestPassword(), new TenantMembership { TenantId = tenantId, UserId = userId, Role = TenantRole.TenantAdmin, Status = MembershipStatus.Active - }, - new UserIdentity - { - UserId = userId, - Provider = "password", - ProviderSubject = phone, - Phone = phone, - SecretPayload = CreateSecretPayload(passwordHash) }); return (tenantId, userId, phone); @@ -227,20 +290,17 @@ public sealed class ContentManagementEndpointTests HttpClient client, (Guid TenantId, Guid UserId, string Phone) seed) { - var loginResponse = await client.PostAsJsonAsync( - "/api/auth/login/password", - new PasswordLoginDto - { - TenantCode = seed.TenantId.ToString("N"), - Phone = seed.Phone, - Password = "passw0rd!" - }); - var loginJson = await ReadJsonAsync(loginResponse); - var accessToken = loginJson.RootElement - .GetProperty("tokens") - .GetProperty("accessToken") - .GetString(); - client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone)); + } + + private static async Task SetDataScopeAsync(ApiTestFactory factory, Guid tenantId, object value) + { + using var scope = factory.CreateSystemScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var role = dbContext.TenantBackendRoles.Single(item => + item.TenantId == tenantId && item.Code == "integration_test_admin"); + role.DataScope = JsonSerializer.SerializeToElement(value); + await dbContext.SaveChangesAsync(); } private static async Task ReadJsonAsync(HttpResponseMessage response) @@ -249,10 +309,4 @@ public sealed class ContentManagementEndpointTests return await JsonDocument.ParseAsync(stream); } - private static JsonElement CreateSecretPayload(string passwordHash) - { - using var document = JsonDocument.Parse( - $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); - return document.RootElement.Clone(); - } } diff --git a/Tiku.IntegrationTests/Api/CrmEndpointTests.cs b/Tiku.IntegrationTests/Api/CrmEndpointTests.cs index 6a84608..87556f6 100644 --- a/Tiku.IntegrationTests/Api/CrmEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/CrmEndpointTests.cs @@ -121,49 +121,20 @@ public sealed class CrmEndpointTests var phone = "13800002001"; await factory.SeedAsync( new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "CRM Tenant" }, - new User { Id = userId, Phone = phone, Name = "CRM Admin" }, + new User { Id = userId, Phone = phone, Name = "CRM Admin" }.WithTestPassword(), new TenantMembership { TenantId = tenantId, UserId = userId, Role = TenantRole.TenantAdmin, Status = MembershipStatus.Active - }, - new UserIdentity - { - UserId = userId, - Provider = "password", - ProviderSubject = phone, - Phone = phone, - SecretPayload = CreateSecretPayload(new PasswordHasher().Hash("passw0rd!")) }); return new LoginSeed(tenantId, userId, phone); } private static async Task LoginAsync(HttpClient client, LoginSeed seed) { - var loginResponse = await client.PostAsJsonAsync( - "/api/auth/login/password", - new PasswordLoginDto - { - TenantCode = seed.TenantId.ToString("N"), - Phone = seed.Phone, - Password = "passw0rd!" - }); - loginResponse.EnsureSuccessStatusCode(); - using var loginJson = await JsonDocument.ParseAsync(await loginResponse.Content.ReadAsStreamAsync()); - var accessToken = loginJson.RootElement - .GetProperty("tokens") - .GetProperty("accessToken") - .GetString(); - client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); - } - - private static JsonElement CreateSecretPayload(string passwordHash) - { - using var document = JsonDocument.Parse( - $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); - return document.RootElement.Clone(); + client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone)); } private sealed record LoginSeed(Guid TenantId, Guid UserId, string Phone); diff --git a/Tiku.IntegrationTests/Api/DatabasePermissionServiceAuthorizationTests.cs b/Tiku.IntegrationTests/Api/DatabasePermissionServiceAuthorizationTests.cs new file mode 100644 index 0000000..f1a006e --- /dev/null +++ b/Tiku.IntegrationTests/Api/DatabasePermissionServiceAuthorizationTests.cs @@ -0,0 +1,85 @@ +using System.Net; +using System.Text.Json; +using Tiku.Application.Security; +using Tiku.Domain.Common; +using Tiku.Domain.Identity; +using Tiku.Domain.Operations; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Auth; + +namespace Tiku.IntegrationTests.Api; + +public sealed class DatabasePermissionServiceAuthorizationTests +{ + [Fact] + public async Task BusinessMembershipRoleDoesNotOverrideCommissionAndCrmPermissions() + { + await using var factory = new ApiTestFactory(); + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var roleId = Guid.NewGuid(); + var phone = "13720000000"; + await factory.SeedAsync( + new Tenant + { + Id = tenantId, + Slug = tenantId.ToString("N"), + Name = "Permission Tenant", + Status = TenantStatus.Active, + Metadata = JsonDefaults.Object() + }, + new User { Id = userId, Phone = phone, Name = "Permission Operator" }.WithTestPassword(), + new TenantMembership + { + TenantId = tenantId, + UserId = userId, + Role = TenantRole.Student, + Status = MembershipStatus.Active + }, + new BackendPermission + { + Code = BackendPermissions.TenantCommissionManage, + Name = "Commission", + Area = BackendPermissionArea.Tenant, + Module = "commission" + }, + new BackendPermission + { + Code = BackendPermissions.TenantCrmManage, + Name = "CRM", + Area = BackendPermissionArea.Tenant, + Module = "crm" + }, + new TenantBackendRole + { + Id = roleId, + TenantId = tenantId, + Code = "growth_operator", + Name = "Growth Operator", + Status = BackendRoleStatus.Active, + DataScope = JsonSerializer.SerializeToElement(new { mode = "all" }) + }, + new TenantBackendRolePermission + { + TenantId = tenantId, + RoleId = roleId, + PermissionCode = BackendPermissions.TenantCommissionManage + }, + new TenantBackendRolePermission + { + TenantId = tenantId, + RoleId = roleId, + PermissionCode = BackendPermissions.TenantCrmManage + }, + new TenantBackendUserRole { TenantId = tenantId, UserId = userId, RoleId = roleId }); + + using var client = factory.CreateClient(); + client.UseAccessToken(await client.LoginAsTenantAsync(tenantId, phone)); + + using var commission = await client.GetAsync("/api/commission/settings"); + using var referral = await client.GetAsync("/api/referral/stats"); + + Assert.Equal(HttpStatusCode.OK, commission.StatusCode); + Assert.Equal(HttpStatusCode.OK, referral.StatusCode); + } +} diff --git a/Tiku.IntegrationTests/Api/DirectContentEndpointTests.cs b/Tiku.IntegrationTests/Api/DirectContentEndpointTests.cs index 82f44ea..1f24e70 100644 --- a/Tiku.IntegrationTests/Api/DirectContentEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/DirectContentEndpointTests.cs @@ -225,13 +225,60 @@ public sealed class DirectContentEndpointTests Assert.Single(recordsJson.RootElement.GetProperty("items").EnumerateArray()); } + [Fact] + public async Task RegionBackedDirectContent_UsesRestrictedScopeAndReturns404ForOutsideWrite() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedAdminAsync(factory); + var allowedRegionId = Guid.NewGuid(); + var outsideRegionId = Guid.NewGuid(); + var allowedSchool = new School + { + TenantId = seed.TenantId, + RegionId = allowedRegionId, + Name = "Allowed School" + }; + var outsideSchool = new School + { + TenantId = seed.TenantId, + RegionId = outsideRegionId, + Name = "Outside School" + }; + await factory.SeedAsync( + new Region { Id = allowedRegionId, TenantId = seed.TenantId, Name = "Allowed Region" }, + new Region { Id = outsideRegionId, TenantId = seed.TenantId, Name = "Outside Region" }, + allowedSchool, + outsideSchool); + await SetDataScopeAsync(factory, seed.TenantId, new + { + mode = "restricted", + regionIds = new[] { allowedRegionId }, + includesSelf = false + }); + + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + using var listResponse = await client.GetAsync("/api/tenant-content/scoreline/schools"); + using var list = await ReadJsonAsync(listResponse); + using var deniedUpdate = await client.PutAsJsonAsync( + "/api/tenant-content/scoreline/schools", + new DirectSchoolDto + { + Id = outsideSchool.Id, + RegionId = outsideRegionId, + Name = "Hidden Update" + }); + + Assert.Equal([allowedSchool.Id], list.RootElement.GetProperty("items").EnumerateArray() + .Select(item => item.GetProperty("id").GetGuid())); + Assert.Equal(HttpStatusCode.NotFound, deniedUpdate.StatusCode); + } + private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedAdminAsync(ApiTestFactory factory) { var tenantId = Guid.NewGuid(); var userId = Guid.NewGuid(); var phone = $"137{Random.Shared.Next(10000000, 99999999)}"; - var passwordHash = new PasswordHasher().Hash("passw0rd!"); - await factory.SeedAsync( new Tenant { @@ -246,21 +293,13 @@ public sealed class DirectContentEndpointTests Id = userId, Phone = phone, Name = "Tenant Admin" - }, + }.WithTestPassword(), new TenantMembership { TenantId = tenantId, UserId = userId, Role = TenantRole.TenantAdmin, Status = MembershipStatus.Active - }, - new UserIdentity - { - UserId = userId, - Provider = "password", - ProviderSubject = phone, - Phone = phone, - SecretPayload = CreateSecretPayload(passwordHash) }); return (tenantId, userId, phone); @@ -270,20 +309,17 @@ public sealed class DirectContentEndpointTests HttpClient client, (Guid TenantId, Guid UserId, string Phone) seed) { - var loginResponse = await client.PostAsJsonAsync( - "/api/auth/login/password", - new PasswordLoginDto - { - TenantCode = seed.TenantId.ToString("N"), - Phone = seed.Phone, - Password = "passw0rd!" - }); - var loginJson = await ReadJsonAsync(loginResponse); - var accessToken = loginJson.RootElement - .GetProperty("tokens") - .GetProperty("accessToken") - .GetString(); - client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone)); + } + + private static async Task SetDataScopeAsync(ApiTestFactory factory, Guid tenantId, object value) + { + using var scope = factory.CreateSystemScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var role = dbContext.TenantBackendRoles.Single(item => + item.TenantId == tenantId && item.Code == "integration_test_admin"); + role.DataScope = JsonSerializer.SerializeToElement(value); + await dbContext.SaveChangesAsync(); } private static async Task ReadJsonAsync(HttpResponseMessage response) @@ -292,10 +328,4 @@ public sealed class DirectContentEndpointTests return await JsonDocument.ParseAsync(stream); } - private static JsonElement CreateSecretPayload(string passwordHash) - { - using var document = JsonDocument.Parse( - $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); - return document.RootElement.Clone(); - } } diff --git a/Tiku.IntegrationTests/Api/ExceptionHandlingMiddlewareTests.cs b/Tiku.IntegrationTests/Api/ExceptionHandlingMiddlewareTests.cs new file mode 100644 index 0000000..85b3b18 --- /dev/null +++ b/Tiku.IntegrationTests/Api/ExceptionHandlingMiddlewareTests.cs @@ -0,0 +1,50 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Tiku.Api.Middleware; +using Tiku.Infrastructure.Backoffice; + +namespace Tiku.IntegrationTests.Api; + +public sealed class ExceptionHandlingMiddlewareTests +{ + [Theory] + [InlineData("platform_access_denied", StatusCodes.Status403Forbidden)] + [InlineData("role_not_found", StatusCodes.Status404NotFound)] + [InlineData("permission_not_found", StatusCodes.Status404NotFound)] + [InlineData("system_role_locked", StatusCodes.Status400BadRequest)] + [InlineData("tenant_required", StatusCodes.Status400BadRequest)] + public async Task Backoffice_exception_is_returned_as_problem_details(string code, int expectedStatus) + { + var context = new DefaultHttpContext(); + context.Request.Path = "/api/backoffice/test"; + context.Response.Body = new MemoryStream(); + context.TraceIdentifier = "backoffice-test-trace"; + var middleware = new ExceptionHandlingMiddleware( + _ => throw new BackofficeException("Backoffice request failed.", code), + NullLogger.Instance, + new TestHostEnvironment()); + + await middleware.InvokeAsync(context); + + Assert.Equal(expectedStatus, context.Response.StatusCode); + context.Response.Body.Position = 0; + using var document = await JsonDocument.ParseAsync(context.Response.Body); + var root = document.RootElement; + Assert.Equal("Backoffice request failed.", root.GetProperty("title").GetString()); + Assert.Equal(expectedStatus, root.GetProperty("status").GetInt32()); + Assert.Equal("/api/backoffice/test", root.GetProperty("instance").GetString()); + Assert.Equal(code, root.GetProperty("code").GetString()); + Assert.Equal("backoffice-test-trace", root.GetProperty("traceId").GetString()); + } + + private sealed class TestHostEnvironment : IHostEnvironment + { + public string EnvironmentName { get; set; } = Environments.Production; + public string ApplicationName { get; set; } = "Tiku.IntegrationTests"; + public string ContentRootPath { get; set; } = AppContext.BaseDirectory; + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + } +} diff --git a/Tiku.IntegrationTests/Api/LearningEndpointTests.cs b/Tiku.IntegrationTests/Api/LearningEndpointTests.cs index 05962e9..95282ee 100644 --- a/Tiku.IntegrationTests/Api/LearningEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/LearningEndpointTests.cs @@ -411,8 +411,6 @@ public sealed class LearningEndpointTests var tenantId = Guid.NewGuid(); var userId = Guid.NewGuid(); var phone = "13900000000"; - var passwordHash = new PasswordHasher().Hash("passw0rd!"); - await factory.SeedAsync( new Tenant { @@ -425,21 +423,13 @@ public sealed class LearningEndpointTests Id = userId, Phone = phone, Name = "Learning User" - }, + }.WithTestPassword(), new TenantMembership { TenantId = tenantId, UserId = userId, Role = TenantRole.Student, Status = MembershipStatus.Active - }, - new UserIdentity - { - UserId = userId, - Provider = "password", - ProviderSubject = phone, - Phone = phone, - SecretPayload = CreateSecretPayload(passwordHash) }); return (tenantId, userId, phone); @@ -507,20 +497,7 @@ public sealed class LearningEndpointTests HttpClient client, (Guid TenantId, Guid UserId, string Phone) seed) { - var loginResponse = await client.PostAsJsonAsync( - "/api/auth/login/password", - new PasswordLoginDto - { - TenantCode = seed.TenantId.ToString("N"), - Phone = seed.Phone, - Password = "passw0rd!" - }); - var loginJson = await ReadJsonAsync(loginResponse); - var accessToken = loginJson.RootElement - .GetProperty("tokens") - .GetProperty("accessToken") - .GetString(); - client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone)); } private static async Task ReadJsonAsync(HttpResponseMessage response) @@ -539,10 +516,4 @@ public sealed class LearningEndpointTests .ToArray(); } - private static JsonElement CreateSecretPayload(string passwordHash) - { - using var document = JsonDocument.Parse( - $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); - return document.RootElement.Clone(); - } } diff --git a/Tiku.IntegrationTests/Api/PasswordTestUserExtensions.cs b/Tiku.IntegrationTests/Api/PasswordTestUserExtensions.cs new file mode 100644 index 0000000..7809882 --- /dev/null +++ b/Tiku.IntegrationTests/Api/PasswordTestUserExtensions.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using Tiku.Domain.Identity; + +namespace Tiku.IntegrationTests.Api; + +internal static class PasswordTestUserExtensions +{ + public const string TestPassword = "passw0rd!123"; + + public static User WithTestPassword(this User user) + { + user.UserName ??= user.Phone ?? user.Email ?? user.Id.ToString("N"); + user.NormalizedUserName ??= user.UserName.ToUpperInvariant(); + var hasher = new PasswordHasher(Options.Create(new PasswordHasherOptions + { + IterationCount = 210_000 + })); + user.PasswordHash = hasher.HashPassword(user, TestPassword); + return user; + } +} diff --git a/Tiku.IntegrationTests/Api/PointsEndpointTests.cs b/Tiku.IntegrationTests/Api/PointsEndpointTests.cs index e238f35..05b52a9 100644 --- a/Tiku.IntegrationTests/Api/PointsEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/PointsEndpointTests.cs @@ -1,10 +1,7 @@ using System.Net; using System.Net.Http.Json; -using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; -using System.Text; using System.Text.Json; -using Microsoft.IdentityModel.Tokens; using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; using Tiku.Application.Auth; @@ -21,13 +18,6 @@ namespace Tiku.IntegrationTests.Api; public sealed class PointsEndpointTests { - private static readonly JwtOptions JwtOptions = new() - { - Issuer = "tiku-backend", - Audience = "tiku-api", - SigningKey = "development-only-tiku-signing-key-change-before-production" - }; - [Fact] public async Task Anonymous_points_request_returns_401() { @@ -57,11 +47,10 @@ public sealed class PointsEndpointTests using var client = factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new( "Bearer", - CreateToken([ + TestJwtKeys.CreateToken([ new Claim(TikuClaimTypes.UserId, seed.UserId.ToString()), new Claim(TikuClaimTypes.SessionId, sessionId.ToString()), - new Claim(TikuClaimTypes.TenantId, seed.TenantId.ToString()), - new Claim(TikuClaimTypes.TenantRole, TenantRole.Student.ToString()) + new Claim(TikuClaimTypes.TenantId, seed.TenantId.ToString()) ])); var response = await client.GetAsync("/api/points/summary"); @@ -175,7 +164,6 @@ public sealed class PointsEndpointTests var userId = Guid.NewGuid(); var exchangeItemId = Guid.NewGuid(); var phone = "13800000001"; - var passwordHash = new PasswordHasher().Hash("passw0rd!"); var entities = new List { new Tenant @@ -189,15 +177,7 @@ public sealed class PointsEndpointTests Id = userId, Phone = phone, Name = "Points User" - }, - new UserIdentity - { - UserId = userId, - Provider = "password", - ProviderSubject = phone, - Phone = phone, - SecretPayload = CreateSecretPayload(passwordHash) - }, + }.WithTestPassword(), new PointActivityTask { TenantId = tenantId, @@ -248,44 +228,7 @@ public sealed class PointsEndpointTests private static async Task LoginAsync(HttpClient client, PointSeed seed) { - var loginResponse = await client.PostAsJsonAsync( - "/api/auth/login/password", - new PasswordLoginDto - { - TenantCode = seed.TenantId.ToString("N"), - Phone = seed.Phone, - Password = "passw0rd!" - }); - loginResponse.EnsureSuccessStatusCode(); - using var loginJson = await JsonDocument.ParseAsync(await loginResponse.Content.ReadAsStreamAsync()); - var accessToken = loginJson.RootElement - .GetProperty("tokens") - .GetProperty("accessToken") - .GetString(); - client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); - } - - private static JsonElement CreateSecretPayload(string passwordHash) - { - using var document = JsonDocument.Parse( - $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); - return document.RootElement.Clone(); - } - - private static string CreateToken(IEnumerable claims) - { - var credentials = new SigningCredentials( - new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtOptions.SigningKey)), - SecurityAlgorithms.HmacSha256); - - var token = new JwtSecurityToken( - JwtOptions.Issuer, - JwtOptions.Audience, - claims, - expires: DateTime.UtcNow.AddMinutes(5), - signingCredentials: credentials); - - return new JwtSecurityTokenHandler().WriteToken(token); + client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone)); } private sealed record PointSeed(Guid TenantId, Guid UserId, Guid ExchangeItemId, string Phone); diff --git a/Tiku.IntegrationTests/Api/ProductionConfigurationTests.cs b/Tiku.IntegrationTests/Api/ProductionConfigurationTests.cs index a2b1094..a885430 100644 --- a/Tiku.IntegrationTests/Api/ProductionConfigurationTests.cs +++ b/Tiku.IntegrationTests/Api/ProductionConfigurationTests.cs @@ -32,15 +32,42 @@ public sealed class ProductionConfigurationTests } [Fact] - public void Production_rejects_the_committed_development_jwt_key() + public void Production_requires_an_explicit_rsa_private_key() { var options = new JwtOptions { - SigningKey = OptionsValidation.DevelopmentSigningKey + KeyId = "production-key", + PrivateKeyPem = string.Empty }; Assert.False(OptionsValidation.BeValidJwtOptions(options, isProduction: true)); Assert.True(OptionsValidation.BeValidJwtOptions(options, isProduction: false)); + + options.PrivateKeyPem = TestJwtKeys.PrivateKeyPem; + Assert.True(OptionsValidation.BeValidJwtOptions(options, isProduction: true)); + } + + [Fact] + public void Jwt_configuration_rejects_development_kid_malformed_keys_and_current_kid_in_old_key_set() + { + var options = new JwtOptions + { + KeyId = "development-ephemeral", + PrivateKeyPem = TestJwtKeys.PrivateKeyPem + }; + Assert.False(OptionsValidation.BeValidJwtOptions(options, isProduction: true)); + + options.KeyId = "current-key"; + options.PrivateKeyPem = "-----BEGIN PRIVATE KEY-----\ninvalid\n-----END PRIVATE KEY-----"; + Assert.False(OptionsValidation.BeValidJwtOptions(options, isProduction: false)); + + options.PrivateKeyPem = TestJwtKeys.PrivateKeyPem; + options.PublicKeys[options.KeyId] = TestJwtKeys.PublicKeyPem; + Assert.False(OptionsValidation.BeValidJwtOptions(options, isProduction: false)); + + options.PublicKeys.Clear(); + options.PublicKeys["old-key"] = TestJwtKeys.PublicKeyPem; + Assert.True(OptionsValidation.BeValidJwtOptions(options, isProduction: true)); } [Fact] diff --git a/Tiku.IntegrationTests/Api/ProfileEndpointTests.cs b/Tiku.IntegrationTests/Api/ProfileEndpointTests.cs index 08ceeef..d061e30 100644 --- a/Tiku.IntegrationTests/Api/ProfileEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/ProfileEndpointTests.cs @@ -194,7 +194,6 @@ public sealed class ProfileEndpointTests var tenantId = Guid.NewGuid(); var userId = Guid.NewGuid(); var phone = $"136{Random.Shared.Next(10000000, 99999999)}"; - var passwordHash = new PasswordHasher().Hash("passw0rd!"); await factory.SeedAsync( new Tenant { @@ -209,21 +208,13 @@ public sealed class ProfileEndpointTests Id = userId, Phone = phone, Name = "Student" - }, + }.WithTestPassword(), new TenantMembership { TenantId = tenantId, UserId = userId, Role = TenantRole.Student, Status = MembershipStatus.Active - }, - new UserIdentity - { - UserId = userId, - Provider = "password", - ProviderSubject = phone, - Phone = phone, - SecretPayload = CreateSecretPayload(passwordHash) }); return (tenantId, userId, phone); @@ -233,20 +224,7 @@ public sealed class ProfileEndpointTests HttpClient client, (Guid TenantId, Guid UserId, string Phone) seed) { - var loginResponse = await client.PostAsJsonAsync( - "/api/auth/login/password", - new PasswordLoginDto - { - TenantCode = seed.TenantId.ToString("N"), - Phone = seed.Phone, - Password = "passw0rd!" - }); - var loginJson = await ReadJsonAsync(loginResponse); - var accessToken = loginJson.RootElement - .GetProperty("tokens") - .GetProperty("accessToken") - .GetString(); - client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone)); } private static async Task ReadJsonAsync(HttpResponseMessage response) @@ -255,10 +233,4 @@ public sealed class ProfileEndpointTests return await JsonDocument.ParseAsync(stream); } - private static JsonElement CreateSecretPayload(string passwordHash) - { - using var document = JsonDocument.Parse( - $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); - return document.RootElement.Clone(); - } } diff --git a/Tiku.IntegrationTests/Api/RbacAuthorizationTests.cs b/Tiku.IntegrationTests/Api/RbacAuthorizationTests.cs new file mode 100644 index 0000000..79ad747 --- /dev/null +++ b/Tiku.IntegrationTests/Api/RbacAuthorizationTests.cs @@ -0,0 +1,250 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; +using Tiku.Api.Security; +using Tiku.Application.Security; + +namespace Tiku.IntegrationTests.Api; + +public sealed class RbacAuthorizationTests +{ + [Fact] + public async Task TenantPolicy_RequiresCurrentMembershipPermissionAndMfa() + { + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var snapshot = Snapshot( + userId, + tenantId, + tenantPermissions: [BackendPermissions.TenantRoleManage]); + await using var provider = Services(snapshot); + var authorization = provider.GetRequiredService(); + + var allowed = await authorization.AuthorizeAsync( + Principal(userId, "tenant", tenantId, hasMfa: true), + null, + BackendPermissions.TenantRoleManage); + var missingMfa = await authorization.AuthorizeAsync( + Principal(userId, "tenant", tenantId, hasMfa: false), + null, + BackendPermissions.TenantRoleManage); + var wrongTenant = await authorization.AuthorizeAsync( + Principal(userId, "tenant", Guid.NewGuid(), hasMfa: true), + null, + BackendPermissions.TenantRoleManage); + + Assert.True(allowed.Succeeded); + Assert.False(missingMfa.Succeeded); + Assert.False(wrongTenant.Succeeded); + } + + [Fact] + public async Task PlatformPolicy_RejectsTenantRealmEvenWhenUserHasPlatformPermission() + { + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var snapshot = Snapshot( + userId, + tenantId, + platformPermissions: [BackendPermissions.PlatformRoleManage]); + await using var provider = Services(snapshot); + var authorization = provider.GetRequiredService(); + + var tenantRealm = await authorization.AuthorizeAsync( + Principal(userId, "tenant", tenantId, hasMfa: true), + null, + BackendPermissions.PlatformRoleManage); + var platformRealm = await authorization.AuthorizeAsync( + Principal(userId, "platform", null, hasMfa: true), + null, + BackendPermissions.PlatformRoleManage); + + Assert.False(tenantRealm.Succeeded); + Assert.True(platformRealm.Succeeded); + } + + [Fact] + public async Task PermissionPolicy_DoesNotUseJwtRoleClaims() + { + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var snapshot = Snapshot(userId, tenantId); + await using var provider = Services(snapshot); + var authorization = provider.GetRequiredService(); + var principal = Principal(userId, "tenant", tenantId, hasMfa: true); + ((ClaimsIdentity)principal.Identity!).AddClaim(new Claim(ClaimTypes.Role, "TenantOwner")); + + var result = await authorization.AuthorizeAsync( + principal, + null, + BackendPermissions.TenantRoleManage); + + Assert.False(result.Succeeded); + } + + [Fact] + public async Task AllScopePolicy_RejectsRestrictedOrSelfDataScope() + { + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var principal = Principal(userId, "tenant", tenantId, hasMfa: true); + var selfSnapshot = Snapshot( + userId, + tenantId, + tenantPermissions: [BackendPermissions.TenantContentManage]); + await using var selfProvider = Services(selfSnapshot); + var denied = await selfProvider.GetRequiredService().AuthorizeAsync( + principal, + null, + TikuPolicies.TenantContentManageAllScope); + + var allScope = new CurrentDataScope(DataScopeMode.All, new HashSet(), new HashSet(), true); + var allSnapshot = Snapshot( + userId, + tenantId, + tenantPermissions: [BackendPermissions.TenantContentManage], + dataScope: allScope); + await using var allProvider = Services(allSnapshot); + var allowed = await allProvider.GetRequiredService().AuthorizeAsync( + principal, + null, + TikuPolicies.TenantContentManageAllScope); + + Assert.False(denied.Succeeded); + Assert.True(allowed.Succeeded); + } + + [Fact] + public async Task ResourceRequirement_UsesOwnerRegionClassAndTenantBoundary() + { + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var regionId = Guid.NewGuid(); + var classId = Guid.NewGuid(); + var scope = new CurrentDataScope( + DataScopeMode.Restricted, + new HashSet { regionId }, + new HashSet { classId }, + true); + await using var provider = Services(Snapshot(userId, tenantId, dataScope: scope)); + var authorization = provider.GetRequiredService(); + var principal = Principal(userId, "tenant", tenantId, hasMfa: true); + var requirement = new TenantResourceAccessRequirement(); + + var own = await authorization.AuthorizeAsync( + principal, + new TenantResourceAuthorizationResource(tenantId, OwnerUserId: userId), + requirement); + var region = await authorization.AuthorizeAsync( + principal, + new TenantResourceAuthorizationResource(tenantId, RegionId: regionId), + requirement); + var @class = await authorization.AuthorizeAsync( + principal, + new TenantResourceAuthorizationResource(tenantId, ClassId: classId), + requirement); + var outside = await authorization.AuthorizeAsync( + principal, + new TenantResourceAuthorizationResource(tenantId, RegionId: Guid.NewGuid()), + requirement); + var otherTenant = await authorization.AuthorizeAsync( + principal, + new TenantResourceAuthorizationResource(Guid.NewGuid(), OwnerUserId: userId), + requirement); + + Assert.True(own.Succeeded); + Assert.True(region.Succeeded); + Assert.True(@class.Succeeded); + Assert.False(outside.Succeeded); + Assert.False(otherTenant.Succeeded); + } + + [Fact] + public async Task BackofficeBootstrapPolicies_RequireCurrentRealmAndEffectiveAccess() + { + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var tenantSnapshot = Snapshot(userId, tenantId); + await using var tenantProvider = Services(tenantSnapshot); + var tenantAuthorization = tenantProvider.GetRequiredService(); + var tenantAllowed = await tenantAuthorization.AuthorizeAsync( + Principal(userId, "tenant", tenantId, hasMfa: true), + null, + TikuPolicies.TenantBackofficeBootstrap); + + var platformSnapshot = Snapshot( + userId, + null, + platformPermissions: [BackendPermissions.PlatformDashboardView]); + await using var platformProvider = Services(platformSnapshot); + var platformAuthorization = platformProvider.GetRequiredService(); + var platformAllowed = await platformAuthorization.AuthorizeAsync( + Principal(userId, "platform", null, hasMfa: true), + null, + TikuPolicies.PlatformBackofficeBootstrap); + var tenantRealmDenied = await platformAuthorization.AuthorizeAsync( + Principal(userId, "tenant", tenantId, hasMfa: true), + null, + TikuPolicies.PlatformBackofficeBootstrap); + + Assert.True(tenantAllowed.Succeeded); + Assert.True(platformAllowed.Succeeded); + Assert.False(tenantRealmDenied.Succeeded); + } + + private static ServiceProvider Services(CurrentAccessSnapshot snapshot) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(new StubCurrentAccessContext(snapshot)); + services.AddTikuRbacAuthorization(); + return services.BuildServiceProvider(); + } + + private static ClaimsPrincipal Principal( + Guid userId, + string realm, + Guid? tenantId, + bool hasMfa) + { + var claims = new List + { + new(TikuClaimTypes.UserId, userId.ToString()), + new(TikuClaimTypes.Realm, realm) + }; + if (tenantId.HasValue) + { + claims.Add(new Claim(TikuClaimTypes.TenantId, tenantId.Value.ToString())); + } + + if (hasMfa) + { + claims.Add(new Claim(TikuClaimTypes.Mfa, "totp")); + } + + return new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); + } + + private static CurrentAccessSnapshot Snapshot( + Guid userId, + Guid? tenantId, + IEnumerable? tenantPermissions = null, + IEnumerable? platformPermissions = null, + CurrentDataScope? dataScope = null) + { + return new CurrentAccessSnapshot( + userId, + tenantId, + true, + tenantId.HasValue, + (tenantPermissions ?? []).ToHashSet(StringComparer.Ordinal), + (platformPermissions ?? []).ToHashSet(StringComparer.Ordinal), + dataScope ?? CurrentDataScope.Self); + } + + private sealed class StubCurrentAccessContext(CurrentAccessSnapshot snapshot) : ICurrentAccessContext + { + public Task GetAsync(CancellationToken cancellationToken = default) => + Task.FromResult(snapshot); + } +} diff --git a/Tiku.IntegrationTests/Api/ReferralEndpointTests.cs b/Tiku.IntegrationTests/Api/ReferralEndpointTests.cs index 5789c64..663784e 100644 --- a/Tiku.IntegrationTests/Api/ReferralEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/ReferralEndpointTests.cs @@ -225,9 +225,6 @@ public sealed class ReferralEndpointTests User(referrer.UserId, referrer.Phone, "Referral Teacher", "teacher"), User(student.UserId, student.Phone, "Referral Student", "student"), User(admin.UserId, admin.Phone, "Referral Admin", "tenant_admin"), - Identity(referrer), - Identity(student), - Identity(admin), new TenantMembership { TenantId = tenantId, @@ -273,45 +270,12 @@ public sealed class ReferralEndpointTests Phone = phone, Name = name, PrimaryRole = role - }; - } - - private static UserIdentity Identity(LoginSeed seed) - { - return new UserIdentity - { - UserId = seed.UserId, - Provider = "password", - ProviderSubject = seed.Phone, - Phone = seed.Phone, - SecretPayload = CreateSecretPayload(new PasswordHasher().Hash("passw0rd!")) - }; + }.WithTestPassword(); } private static async Task LoginAsync(HttpClient client, LoginSeed seed) { - var loginResponse = await client.PostAsJsonAsync( - "/api/auth/login/password", - new PasswordLoginDto - { - TenantCode = seed.TenantId.ToString("N"), - Phone = seed.Phone, - Password = "passw0rd!" - }); - loginResponse.EnsureSuccessStatusCode(); - using var loginJson = await JsonDocument.ParseAsync(await loginResponse.Content.ReadAsStreamAsync()); - var accessToken = loginJson.RootElement - .GetProperty("tokens") - .GetProperty("accessToken") - .GetString(); - client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); - } - - private static JsonElement CreateSecretPayload(string passwordHash) - { - using var document = JsonDocument.Parse( - $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); - return document.RootElement.Clone(); + client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone)); } private sealed class FakeReferralQrcodeGenerator : IReferralQrcodeGenerator diff --git a/Tiku.IntegrationTests/Api/SecurityFoundationTests.cs b/Tiku.IntegrationTests/Api/SecurityFoundationTests.cs index 1a91ad5..bd6f2f1 100644 --- a/Tiku.IntegrationTests/Api/SecurityFoundationTests.cs +++ b/Tiku.IntegrationTests/Api/SecurityFoundationTests.cs @@ -1,24 +1,13 @@ -using System.IdentityModel.Tokens.Jwt; using System.Net; using System.Security.Claims; -using System.Text; using System.Text.Json; -using Microsoft.IdentityModel.Tokens; using Tiku.Api.Options; using Tiku.Application.Security; -using Tiku.Domain.Tenancy; namespace Tiku.IntegrationTests.Api; public sealed class SecurityFoundationTests { - private static readonly JwtOptions JwtOptions = new() - { - Issuer = "tiku-backend", - Audience = "tiku-api", - SigningKey = "development-only-tiku-signing-key-change-before-production" - }; - [Fact] public async Task Authenticated_policy_returns_unauthorized_without_token() { @@ -36,15 +25,18 @@ public sealed class SecurityFoundationTests await using var factory = CreateFactory(); var userId = Guid.NewGuid(); var tenantId = Guid.NewGuid(); - var sessionId = await factory.SeedActiveSessionAsync(userId, tenantId); + var sessionId = await factory.SeedActiveSessionAsync( + userId, + tenantId, + includeMembership: true); using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); client.DefaultRequestHeaders.Authorization = new( "Bearer", - CreateToken([ + TestJwtKeys.CreateToken([ new Claim(TikuClaimTypes.UserId, userId.ToString()), new Claim(TikuClaimTypes.SessionId, sessionId.ToString()), - new Claim(TikuClaimTypes.TenantId, tenantId.ToString()), - new Claim(TikuClaimTypes.TenantRole, TenantRole.Student.ToString()) + new Claim(TikuClaimTypes.TenantId, tenantId.ToString()) ])); var response = await client.GetAsync("/api/_security/tenant-admin"); @@ -58,11 +50,15 @@ public sealed class SecurityFoundationTests var userId = Guid.NewGuid(); await using var factory = CreateFactory(); var tenantId = Guid.NewGuid(); - var sessionId = await factory.SeedActiveSessionAsync(userId, tenantId); + var sessionId = await factory.SeedActiveSessionAsync( + userId, + tenantId, + includeMembership: true); using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); client.DefaultRequestHeaders.Authorization = new( "Bearer", - CreateToken([ + TestJwtKeys.CreateToken([ new Claim(TikuClaimTypes.UserId, userId.ToString()), new Claim(TikuClaimTypes.SessionId, sessionId.ToString()), new Claim(TikuClaimTypes.TenantId, tenantId.ToString()) @@ -76,6 +72,73 @@ public sealed class SecurityFoundationTests Assert.Contains("true", body, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task Jwt_without_jti_and_iat_is_rejected() + { + await using var factory = CreateFactory(); + var userId = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + var sessionId = await factory.SeedActiveSessionAsync(userId, tenantId, includeMembership: true); + using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); + client.DefaultRequestHeaders.Authorization = new( + "Bearer", + TestJwtKeys.CreateToken([ + new Claim(TikuClaimTypes.UserId, userId.ToString()), + new Claim(TikuClaimTypes.SessionId, sessionId.ToString()), + new Claim(TikuClaimTypes.TenantId, tenantId.ToString()) + ], includeStandardClaims: false)); + + var response = await client.GetAsync("/api/_security/authenticated"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Jwt_mfa_claim_must_match_the_database_session() + { + await using var factory = CreateFactory(); + var userId = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + var sessionId = await factory.SeedActiveSessionAsync(userId, tenantId, includeMembership: true); + using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); + client.DefaultRequestHeaders.Authorization = new( + "Bearer", + TestJwtKeys.CreateToken([ + new Claim(TikuClaimTypes.UserId, userId.ToString()), + new Claim(TikuClaimTypes.SessionId, sessionId.ToString()), + new Claim(TikuClaimTypes.TenantId, tenantId.ToString()), + new Claim(TikuClaimTypes.Mfa, "mfa") + ])); + + var response = await client.GetAsync("/api/_security/authenticated"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Jwt_with_unknown_kid_is_rejected() + { + await using var factory = CreateFactory(); + var userId = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + var sessionId = await factory.SeedActiveSessionAsync(userId, tenantId, includeMembership: true); + using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); + client.DefaultRequestHeaders.Authorization = new( + "Bearer", + TestJwtKeys.CreateToken([ + new Claim(TikuClaimTypes.UserId, userId.ToString()), + new Claim(TikuClaimTypes.SessionId, sessionId.ToString()), + new Claim(TikuClaimTypes.TenantId, tenantId.ToString()) + ], keyId: "unknown-key")); + + var response = await client.GetAsync("/api/_security/authenticated"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + [Fact] public async Task Global_rate_limiter_returns_too_many_requests_problem() { @@ -129,19 +192,4 @@ public sealed class SecurityFoundationTests return new ApiTestFactory(); } - private static string CreateToken(IEnumerable claims) - { - var credentials = new SigningCredentials( - new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtOptions.SigningKey)), - SecurityAlgorithms.HmacSha256); - - var token = new JwtSecurityToken( - JwtOptions.Issuer, - JwtOptions.Audience, - claims, - expires: DateTime.UtcNow.AddMinutes(5), - signingCredentials: credentials); - - return new JwtSecurityTokenHandler().WriteToken(token); - } } diff --git a/Tiku.IntegrationTests/Api/SmsVerificationConcurrencyTests.cs b/Tiku.IntegrationTests/Api/SmsVerificationConcurrencyTests.cs new file mode 100644 index 0000000..bd47214 --- /dev/null +++ b/Tiku.IntegrationTests/Api/SmsVerificationConcurrencyTests.cs @@ -0,0 +1,94 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Tiku.Application.Auth; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Auth; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.IntegrationTests.Api; + +public sealed class SmsVerificationConcurrencyTests +{ + [Fact] + public async Task Concurrent_verification_consumes_a_code_exactly_once() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedCodeAsync(factory, "123456"); + + var results = await Task.WhenAll( + TryVerifyAsync(factory, seed.TenantId, "123456"), + TryVerifyAsync(factory, seed.TenantId, "123456")); + + Assert.Single(results, succeeded => succeeded); + using var scope = factory.CreateSystemScope("Verify concurrent SMS consumption"); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var verification = await dbContext.SmsVerificationCodes.SingleAsync(item => item.Id == seed.CodeId); + Assert.Equal(SmsVerificationStatus.Verified, verification.Status); + Assert.NotNull(verification.ConsumedAt); + } + + [Fact] + public async Task Concurrent_invalid_attempts_atomically_block_on_the_fifth_failure() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedCodeAsync(factory, "123456"); + + var results = await Task.WhenAll(Enumerable.Range(0, 5) + .Select(_ => TryVerifyAsync(factory, seed.TenantId, "999999"))); + + Assert.DoesNotContain(true, results); + using var scope = factory.CreateSystemScope("Verify concurrent SMS blocking"); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var verification = await dbContext.SmsVerificationCodes.SingleAsync(item => item.Id == seed.CodeId); + Assert.Equal(5, verification.Attempts); + Assert.Equal(SmsVerificationStatus.Blocked, verification.Status); + } + + private static async Task TryVerifyAsync(ApiTestFactory factory, Guid tenantId, string code) + { + using var scope = factory.CreateSystemScope("Concurrent SMS verification"); + var service = scope.ServiceProvider.GetRequiredService(); + try + { + await service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, code); + return true; + } + catch (InvalidCredentialsException) + { + return false; + } + } + + private static async Task<(Guid TenantId, Guid CodeId)> SeedCodeAsync( + ApiTestFactory factory, + string code) + { + using var scope = factory.CreateSystemScope("Read SMS test options"); + var options = scope.ServiceProvider.GetRequiredService>().Value; + var tenantId = Guid.NewGuid(); + var verification = new SmsVerificationCode + { + TenantId = tenantId, + Phone = "13800000000", + Purpose = SmsPurpose.Login, + CodeHash = SmsCodeHashing.Hash( + tenantId, + "13800000000", + SmsPurpose.Login, + code, + options.CodePepper), + Status = SmsVerificationStatus.Sent, + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5) + }; + await factory.SeedAsync( + new Tenant + { + Id = tenantId, + Slug = tenantId.ToString("N"), + Name = "Concurrent SMS Tenant" + }, + verification); + return (tenantId, verification.Id); + } +} diff --git a/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs b/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs index 1503841..0db84e7 100644 --- a/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs @@ -251,38 +251,18 @@ public sealed class TenantAdminDirectEndpointTests } [Fact] - public async Task Tenant_admin_can_manage_role_templates_members_and_audit_logs() + public async Task Tenant_admin_can_manage_members_and_audit_logs() { await using var factory = new ApiTestFactory(); var seed = await SeedAdminAsync(factory); using var client = factory.CreateClient(); await LoginAsync(client, seed); - var roleResponse = await client.PutAsJsonAsync( - "/api/tenant-admin/role-templates", - new UpsertTenantAdminRoleTemplateDto - { - Code = "teacher-basic", - Name = "教师基础权限", - BaseRole = "teacher", - Permissions = JsonSerializer.SerializeToElement(new Dictionary - { - ["classes:read"] = true, - ["students:read"] = true - }), - FieldPermissions = JsonSerializer.SerializeToElement(new Dictionary - { - ["student.phone"] = false - }) - }); - var roleJson = await ReadJsonAsync(roleResponse); - var roleTemplateId = roleJson.RootElement.GetProperty("item").GetProperty("id").GetGuid(); - var memberResponse = await client.PutAsJsonAsync( "/api/tenant-admin/members", new UpsertTenantAdminMemberDto { - RoleTemplateId = roleTemplateId, + Role = "teacher", User = new TenantAdminUserLookupDto { Phone = "13900000004", @@ -290,15 +270,12 @@ public sealed class TenantAdminDirectEndpointTests } }); var membersResponse = await client.GetAsync("/api/tenant-admin/members?role=teacher"); - var permissionsResponse = await client.GetAsync("/api/tenant-admin/permissions"); - var auditResponse = await client.GetAsync("/api/tenant-admin/audit-logs?action=tenant.role_template"); + var auditResponse = await client.GetAsync("/api/tenant-admin/audit-logs?action=tenant.member"); var membersJson = await ReadJsonAsync(membersResponse); var auditJson = await ReadJsonAsync(auditResponse); - Assert.Equal(HttpStatusCode.OK, roleResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, memberResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, membersResponse.StatusCode); - Assert.Equal(HttpStatusCode.OK, permissionsResponse.StatusCode); Assert.Single(membersJson.RootElement.GetProperty("items").EnumerateArray()); Assert.NotEmpty(auditJson.RootElement.GetProperty("items").EnumerateArray()); } @@ -394,8 +371,6 @@ public sealed class TenantAdminDirectEndpointTests var tenantId = Guid.NewGuid(); var userId = Guid.NewGuid(); var phone = $"137{Random.Shared.Next(10000000, 99999999)}"; - var passwordHash = new PasswordHasher().Hash("passw0rd!"); - await factory.SeedAsync( new Tenant { @@ -410,21 +385,13 @@ public sealed class TenantAdminDirectEndpointTests Id = userId, Phone = phone, Name = "Tenant Admin" - }, + }.WithTestPassword(), new TenantMembership { TenantId = tenantId, UserId = userId, Role = role, Status = MembershipStatus.Active - }, - new UserIdentity - { - UserId = userId, - Provider = "password", - ProviderSubject = phone, - Phone = phone, - SecretPayload = CreateSecretPayload(passwordHash) }); return (tenantId, userId, phone); @@ -434,20 +401,7 @@ public sealed class TenantAdminDirectEndpointTests HttpClient client, (Guid TenantId, Guid UserId, string Phone) seed) { - var loginResponse = await client.PostAsJsonAsync( - "/api/auth/login/password", - new PasswordLoginDto - { - TenantCode = seed.TenantId.ToString("N"), - Phone = seed.Phone, - Password = "passw0rd!" - }); - var loginJson = await ReadJsonAsync(loginResponse); - var accessToken = loginJson.RootElement - .GetProperty("tokens") - .GetProperty("accessToken") - .GetString(); - client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone)); } private static async Task ReadJsonAsync(HttpResponseMessage response) @@ -456,10 +410,4 @@ public sealed class TenantAdminDirectEndpointTests return await JsonDocument.ParseAsync(stream); } - private static JsonElement CreateSecretPayload(string passwordHash) - { - using var document = JsonDocument.Parse( - $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); - return document.RootElement.Clone(); - } } diff --git a/Tiku.IntegrationTests/Api/TenantCommerceEndpointTests.cs b/Tiku.IntegrationTests/Api/TenantCommerceEndpointTests.cs index 8da8d1f..5c18633 100644 --- a/Tiku.IntegrationTests/Api/TenantCommerceEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/TenantCommerceEndpointTests.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; using Tiku.Application.Auth; using Tiku.Application.Commerce; +using Tiku.Domain.Catalog; using Tiku.Domain.Commerce; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; @@ -203,12 +204,59 @@ public sealed class TenantCommerceEndpointTests Assert.Equal(HttpStatusCode.OK, reportResponse.StatusCode); } + [Fact] + public async Task OrderAndRefundOperations_ApplySelfAndRestrictedScopesInSql() + { + await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway()); + var seed = await SeedLoginUserAsync(factory, TenantRole.TenantAdmin); + var allowedRegionId = Guid.NewGuid(); + var otherRegionId = Guid.NewGuid(); + var regionalUserId = Guid.NewGuid(); + var outsideUserId = Guid.NewGuid(); + var ownOrder = NewPaidOrder(seed.TenantId, seed.UserId, otherRegionId, "SELF-ORDER"); + var regionalOrder = NewPaidOrder(seed.TenantId, regionalUserId, allowedRegionId, "REGION-ORDER"); + var outsideOrder = NewPaidOrder(seed.TenantId, outsideUserId, otherRegionId, "OUTSIDE-ORDER"); + await factory.SeedAsync( + new Region { Id = allowedRegionId, TenantId = seed.TenantId, Name = "Allowed Region" }, + new Region { Id = otherRegionId, TenantId = seed.TenantId, Name = "Other Region" }, + new User { Id = regionalUserId, Name = "Regional Buyer" }, + new User { Id = outsideUserId, Name = "Outside Buyer" }, + ownOrder, + regionalOrder, + outsideOrder); + await SetAdminDataScopeAsync(factory, seed.TenantId, new { mode = "self" }); + + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + var selfOrders = await client.GetFromJsonAsync("/api/tenant-commerce/orders"); + + await SetAdminDataScopeAsync(factory, seed.TenantId, new + { + mode = "restricted", + regionIds = new[] { allowedRegionId }, + includesSelf = false + }); + var regionalOrders = await client.GetFromJsonAsync("/api/tenant-commerce/orders"); + using var deniedRefund = await client.PostAsJsonAsync( + "/api/tenant-commerce/refunds", + new CreateRefundRequestDto { OrderId = outsideOrder.Id, AmountCents = 100 }); + using var allowedRefund = await client.PostAsJsonAsync( + "/api/tenant-commerce/refunds", + new CreateRefundRequestDto { OrderId = regionalOrder.Id, AmountCents = 100 }); + + Assert.NotNull(selfOrders); + Assert.Equal(["SELF-ORDER"], selfOrders.Items.Select(item => item.OrderNo)); + Assert.NotNull(regionalOrders); + Assert.Equal(["REGION-ORDER"], regionalOrders.Items.Select(item => item.OrderNo)); + Assert.Equal(HttpStatusCode.NotFound, deniedRefund.StatusCode); + Assert.Equal(HttpStatusCode.OK, allowedRefund.StatusCode); + } + private static async Task SeedLoginUserAsync(ApiTestFactory factory, TenantRole role) { var tenantId = Guid.NewGuid(); var userId = Guid.NewGuid(); var phone = "13800000000"; - var passwordHash = new PasswordHasher().Hash("passw0rd!"); await factory.SeedAsync( new Tenant { @@ -221,21 +269,13 @@ public sealed class TenantCommerceEndpointTests Id = userId, Phone = phone, Name = "Tenant Commerce User" - }, + }.WithTestPassword(), new TenantMembership { TenantId = tenantId, UserId = userId, Role = role, Status = MembershipStatus.Active - }, - new UserIdentity - { - UserId = userId, - Provider = "password", - ProviderSubject = phone, - Phone = phone, - SecretPayload = CreateSecretPayload(passwordHash) }); return new LoginSeed(tenantId, userId, phone); @@ -243,28 +283,28 @@ public sealed class TenantCommerceEndpointTests private static async Task LoginAsync(HttpClient client, LoginSeed seed) { - var loginResponse = await client.PostAsJsonAsync( - "/api/auth/login/password", - new PasswordLoginDto - { - TenantCode = seed.TenantId.ToString("N"), - Phone = seed.Phone, - Password = "passw0rd!" - }); - loginResponse.EnsureSuccessStatusCode(); - using var loginJson = await JsonDocument.ParseAsync(await loginResponse.Content.ReadAsStreamAsync()); - var accessToken = loginJson.RootElement - .GetProperty("tokens") - .GetProperty("accessToken") - .GetString(); - client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone)); } - private static JsonElement CreateSecretPayload(string passwordHash) + private static Order NewPaidOrder(Guid tenantId, Guid userId, Guid regionId, string orderNo) => new() { - using var document = JsonDocument.Parse( - $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); - return document.RootElement.Clone(); + TenantId = tenantId, + UserId = userId, + RegionId = regionId, + OrderNo = orderNo, + Status = OrderStatus.Paid, + AmountCents = 1_000, + PaidAt = DateTimeOffset.UtcNow + }; + + private static async Task SetAdminDataScopeAsync(ApiTestFactory factory, Guid tenantId, object value) + { + using var scope = factory.CreateSystemScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var role = await dbContext.TenantBackendRoles.SingleAsync(item => + item.TenantId == tenantId && item.Code == "integration_test_admin"); + role.DataScope = JsonSerializer.SerializeToElement(value); + await dbContext.SaveChangesAsync(); } private sealed record LoginSeed(Guid TenantId, Guid UserId, string Phone); diff --git a/Tiku.IntegrationTests/Api/TestJwtKeys.cs b/Tiku.IntegrationTests/Api/TestJwtKeys.cs new file mode 100644 index 0000000..14987cc --- /dev/null +++ b/Tiku.IntegrationTests/Api/TestJwtKeys.cs @@ -0,0 +1,98 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using Microsoft.IdentityModel.Tokens; +using Tiku.Application.Security; +using Tiku.Domain.Tenancy; + +namespace Tiku.IntegrationTests.Api; + +internal static class TestJwtKeys +{ + public const string Issuer = "tiku-backend"; + public const string Audience = "tiku-api"; + public const string KeyId = "integration-test-rsa-key"; + + public static string PrivateKeyPem { get; } = CreatePrivateKeyPem(); + public static string PublicKeyPem { get; } = CreatePublicKeyPem(); + + public static string CreateToken( + IEnumerable claims, + AuthRealm realm = AuthRealm.Tenant, + bool includeStandardClaims = true, + string? keyId = null) + { + using var rsa = RSA.Create(); + rsa.ImportFromPem(PrivateKeyPem); + var key = new RsaSecurityKey(rsa) + { + KeyId = keyId ?? KeyId, + CryptoProviderFactory = new CryptoProviderFactory + { + CacheSignatureProviders = false + } + }; + var credentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256); + var tokenClaims = claims.ToList(); + if (tokenClaims.All(claim => claim.Type != TikuClaimTypes.Realm)) + { + tokenClaims.Add(new Claim( + TikuClaimTypes.Realm, + realm.ToString().ToLowerInvariant())); + } + + if (includeStandardClaims) + { + tokenClaims.Add(new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N"))); + tokenClaims.Add(new Claim( + JwtRegisteredClaimNames.Iat, + DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), + ClaimValueTypes.Integer64)); + } + + var token = new JwtSecurityToken( + Issuer, + Audience, + tokenClaims, + expires: DateTime.UtcNow.AddMinutes(5), + signingCredentials: credentials); + return new JwtSecurityTokenHandler().WriteToken(token); + } + + private static string CreatePrivateKeyPem() + { + using var rsa = RSA.Create(2048); + return rsa.ExportPkcs8PrivateKeyPem(); + } + + private static string CreatePublicKeyPem() + { + using var rsa = RSA.Create(); + rsa.ImportFromPem(PrivateKeyPem); + return rsa.ExportSubjectPublicKeyInfoPem(); + } +} + +internal sealed class TestJwtKeyRing : IJwtKeyRing +{ + private static readonly RsaSecurityKey Key = CreateKey(); + + public SigningCredentials SigningCredentials { get; } = + new(Key, SecurityAlgorithms.RsaSha256); + + public IReadOnlyCollection ValidationKeys { get; } = [Key]; + + private static RsaSecurityKey CreateKey() + { + var rsa = RSA.Create(); + rsa.ImportFromPem(TestJwtKeys.PrivateKeyPem); + return new RsaSecurityKey(rsa) + { + KeyId = TestJwtKeys.KeyId, + CryptoProviderFactory = new CryptoProviderFactory + { + CacheSignatureProviders = false + } + }; + } +} diff --git a/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs b/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs index 35db427..c78850d 100644 --- a/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs +++ b/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs @@ -2,6 +2,150 @@ namespace Tiku.IntegrationTests; public sealed class ArchitectureBoundaryTests { + [Fact] + public void Production_authorization_does_not_depend_on_legacy_role_claims() + { + var root = FindRepositoryRoot(); + var authorizationFiles = Directory + .EnumerateFiles(Path.Combine(root, "Tiku.Api", "Security"), "*.cs", SearchOption.AllDirectories) + .Append(Path.Combine(root, "Tiku.Api", "Program.cs")); + var forbidden = new[] + { + "TikuClaimTypes.TenantRole", + "TenantRoleAuthorization", + "PrimaryRole" + }; + + AssertNoForbiddenSymbols( + root, + authorizationFiles, + forbidden, + "Production authorization still depends on a legacy role claim or primary role"); + } + + [Fact] + public void Backoffice_controllers_do_not_construct_platform_access_flags() + { + var root = FindRepositoryRoot(); + var controllerFiles = Directory.EnumerateFiles( + Path.Combine(root, "Tiku.Api", "Controllers"), + "*Backoffice*Controller.cs", + SearchOption.AllDirectories); + var forbidden = new[] + { + "new BackofficeActor(", + "IsPlatformAdmin(", + "IsPlatform =" + }; + + AssertNoForbiddenSymbols( + root, + controllerFiles, + forbidden, + "Backoffice controllers must use the resolved access context instead of constructing platform access flags"); + } + + [Fact] + public void BackendAuthorizationDoesNotUseMembershipBusinessRoles() + { + var root = FindRepositoryRoot(); + var files = new[] + { + Path.Combine(root, "Tiku.Infrastructure", "Growth", "CommissionService.cs"), + Path.Combine(root, "Tiku.Infrastructure", "Growth", "ReferralService.cs"), + Path.Combine(root, "Tiku.Application", "TenantAdmin", "TenantAdminDirectModels.cs"), + Path.Combine(root, "Tiku.Api", "Controllers", "TenantAdminDirectController.cs") + }; + + AssertNoForbiddenSymbols( + root, + files, + new[] + { + "item.Role == TenantRole.TenantOwner", + "item.Role == TenantRole.TenantAdmin", + "TenantRole Role = TenantRole.TenantAdmin", + "new TenantAdminActor(" + }, + "Backend authorization must use database role permissions rather than membership business roles or fabricated admin actors"); + } + + [Fact] + public void Auth_sessions_are_accessed_only_through_the_session_store() + { + var root = FindRepositoryRoot(); + var sourceRoots = new[] { "Tiku.Api", "Tiku.Application", "Tiku.Infrastructure", "Tiku.Worker" }; + var allowedFiles = new[] + { + "TikuDbContext.cs", + "AuthSessionStore.cs", + "SessionStore.cs" + }; + + var files = sourceRoots + .SelectMany(directory => Directory.EnumerateFiles( + Path.Combine(root, directory), + "*.cs", + SearchOption.AllDirectories)) + .Where(path => !path.Contains( + $"{Path.DirectorySeparatorChar}Persistence{Path.DirectorySeparatorChar}Migrations{Path.DirectorySeparatorChar}", + StringComparison.Ordinal)) + .Where(path => !path.Contains( + $"{Path.DirectorySeparatorChar}Persistence{Path.DirectorySeparatorChar}Configurations{Path.DirectorySeparatorChar}", + StringComparison.Ordinal)) + .Where(path => !allowedFiles.Contains(Path.GetFileName(path), StringComparer.Ordinal)); + + AssertNoForbiddenSymbols( + root, + files, + new[] { ".AuthSessions", "Set" }, + "AuthSession DbSet access must be encapsulated by IAuthSessionStore"); + } + + [Fact] + public void Api_uses_an_authenticated_fallback_policy() + { + var root = FindRepositoryRoot(); + var program = File.ReadAllText(Path.Combine(root, "Tiku.Api", "Program.cs")); + + Assert.Contains("FallbackPolicy", program, StringComparison.Ordinal); + Assert.Contains("RequireAuthenticatedUser()", program, StringComparison.Ordinal); + } + + [Fact] + public void Every_controller_action_declares_authorization_or_anonymous_access() + { + var controllerAssembly = typeof(Tiku.Api.Controllers.AuthController).Assembly; + var violations = controllerAssembly + .GetTypes() + .Where(type => !type.IsAbstract && typeof(Microsoft.AspNetCore.Mvc.ControllerBase).IsAssignableFrom(type)) + .SelectMany(type => type + .GetMethods(System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.Public | + System.Reflection.BindingFlags.DeclaredOnly) + .Where(method => method + .GetCustomAttributes(inherit: true) + .OfType() + .Any()) + .Select(method => new + { + Controller = type, + Action = method, + Metadata = type.GetCustomAttributes(inherit: true) + .Concat(method.GetCustomAttributes(inherit: true)) + })) + .Where(candidate => !candidate.Metadata.Any(attribute => + attribute is Microsoft.AspNetCore.Authorization.IAuthorizeData or + Microsoft.AspNetCore.Authorization.IAllowAnonymous)) + .Select(candidate => $"{candidate.Controller.FullName}.{candidate.Action.Name}") + .Order(StringComparer.Ordinal) + .ToArray(); + + Assert.True( + violations.Length == 0, + $"Controller actions without explicit authorization metadata were found:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}"); + } + [Fact] public void Business_code_does_not_bypass_tenant_query_boundaries() { @@ -150,4 +294,23 @@ public sealed class ArchitectureBoundaryTests return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root was not found."); } + + private static void AssertNoForbiddenSymbols( + string root, + IEnumerable files, + IReadOnlyCollection forbidden, + string failureMessage) + { + var violations = files + .SelectMany(path => File.ReadLines(path) + .Select((line, index) => new { path, line, lineNumber = index + 1 })) + .Where(candidate => forbidden.Any(symbol => + candidate.line.Contains(symbol, StringComparison.Ordinal))) + .Select(candidate => $"{Path.GetRelativePath(root, candidate.path)}:{candidate.lineNumber}") + .ToArray(); + + Assert.True( + violations.Length == 0, + $"{failureMessage}:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}"); + } } diff --git a/Tiku.IntegrationTests/PersistenceModelTests.cs b/Tiku.IntegrationTests/PersistenceModelTests.cs index 3cd60c0..8b66c5c 100644 --- a/Tiku.IntegrationTests/PersistenceModelTests.cs +++ b/Tiku.IntegrationTests/PersistenceModelTests.cs @@ -74,7 +74,6 @@ public sealed class PersistenceModelTests Assert.Contains("auth_login_events", tableNames); Assert.Contains("auth_sessions", tableNames); Assert.Contains("sms_send_rate_limits", tableNames); - Assert.Contains("tenant_role_templates", tableNames); Assert.Contains("tenant_classes", tableNames); Assert.Contains("tenant_class_members", tableNames); Assert.Contains("tenant_student_notes", tableNames); @@ -510,8 +509,6 @@ public sealed class PersistenceModelTests [InlineData(typeof(SmsVerificationCode), nameof(SmsVerificationCode.Metadata), "'{}'::jsonb")] [InlineData(typeof(AuthLoginEvent), nameof(AuthLoginEvent.Metadata), "'{}'::jsonb")] [InlineData(typeof(AuthSession), nameof(AuthSession.Metadata), "'{}'::jsonb")] - [InlineData(typeof(TenantRoleTemplate), nameof(TenantRoleTemplate.Permissions), "'{}'::jsonb")] - [InlineData(typeof(TenantRoleTemplate), nameof(TenantRoleTemplate.DataScope), "'{}'::jsonb")] [InlineData(typeof(TenantClass), nameof(TenantClass.Metadata), "'{}'::jsonb")] [InlineData(typeof(TenantClassMember), nameof(TenantClassMember.Metadata), "'{}'::jsonb")] [InlineData(typeof(TenantStudentNote), nameof(TenantStudentNote.Metadata), "'{}'::jsonb")] diff --git a/Tiku.UnitTests/Auth/AuthServiceTests.cs b/Tiku.UnitTests/Auth/AuthServiceTests.cs index 9430582..8067809 100644 --- a/Tiku.UnitTests/Auth/AuthServiceTests.cs +++ b/Tiku.UnitTests/Auth/AuthServiceTests.cs @@ -1,10 +1,14 @@ -using System.Text.Json; +using System.Security.Cryptography; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; using Tiku.Application.Auth; using Tiku.Application.Security; using Tiku.Application.Tenancy; using Tiku.Domain.Identity; +using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; @@ -13,495 +17,264 @@ namespace Tiku.UnitTests.Auth; public sealed class AuthServiceTests { - private static readonly JwtOptions JwtOptions = new() - { - Issuer = "tiku-unit-tests", - Audience = "tiku-api-unit-tests", - SigningKey = "unit-test-signing-key-that-is-long-enough" - }; - [Fact] - public void Password_hasher_verifies_own_hash() + public void Identity_password_hasher_uses_current_identity_format() { - var hasher = new PasswordHasher(); + var user = new User(); + var hasher = new PasswordHasher(Options.Create(new PasswordHasherOptions + { + IterationCount = 210_000 + })); + var hash = hasher.HashPassword(user, "passw0rd!"); - var hash = hasher.Hash("passw0rd!"); - - Assert.True(hasher.Verify("passw0rd!", hash)); - Assert.False(hasher.Verify("wrong", hash)); + Assert.Equal(PasswordVerificationResult.Success, hasher.VerifyHashedPassword(user, hash, "passw0rd!")); + Assert.Equal(PasswordVerificationResult.Failed, hasher.VerifyHashedPassword(user, hash, "wrong")); } [Fact] - public async Task Password_login_creates_session_and_success_event() + public async Task Password_login_issues_tenant_session_for_regular_member() { - await using var context = CreateContext(); - var hasher = new PasswordHasher(); - var seed = await SeedUserAsync(context, hasher.Hash("passw0rd!")); - var service = CreateAuthService(context); + await using var fixture = await AuthFixture.CreateAsync(); - var result = await service.LoginWithPasswordAsync(new PasswordLoginRequest( - seed.TenantId, - seed.Phone, - "passw0rd!", - "127.0.0.1", - "unit-test")); + var result = await fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest( + AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, AuthFixture.Password, "127.0.0.1", "unit-test")); - Assert.Equal(seed.UserId, result.UserId); - Assert.False(string.IsNullOrWhiteSpace(result.Tokens.AccessToken)); - Assert.False(string.IsNullOrWhiteSpace(result.Tokens.RefreshToken)); - Assert.Single(context.AuthSessions); - Assert.Contains(context.AuthLoginEvents, entity => entity.Result == AuthLoginResult.Success); + Assert.Equal(AuthenticationStatus.Authenticated, result.Status); + Assert.Equal(fixture.UserId, result.User!.UserId); + Assert.Equal(AuthRealm.Tenant, result.User.Realm); + Assert.False(string.IsNullOrWhiteSpace(result.User.Tokens.AccessToken)); + Assert.Single(await fixture.DbContext.AuthSessions.ToArrayAsync()); } [Fact] - public async Task Wrong_password_records_failed_event() + public async Task Five_wrong_passwords_lock_the_identity_account() { - await using var context = CreateContext(); - var hasher = new PasswordHasher(); - var seed = await SeedUserAsync(context, hasher.Hash("passw0rd!")); - var service = CreateAuthService(context); + await using var fixture = await AuthFixture.CreateAsync(); - await Assert.ThrowsAsync(() => - service.LoginWithPasswordAsync(new PasswordLoginRequest( - seed.TenantId, - seed.Phone, - "wrong", - null, - null))); - - Assert.Empty(context.AuthSessions); - Assert.Contains(context.AuthLoginEvents, entity => - entity.Result == AuthLoginResult.Failed && - entity.FailureCode == "invalid_credentials"); - } - - [Fact] - public async Task Sms_login_consumes_code_and_creates_session() - { - await using var context = CreateContext(); - var seed = await SeedUserAsync(context, new PasswordHasher().Hash("passw0rd!")); - context.SmsVerificationCodes.Add(new SmsVerificationCode + for (var attempt = 0; attempt < 5; attempt++) { - TenantId = seed.TenantId, - Phone = seed.Phone, - Purpose = SmsPurpose.Login, - CodeHash = SmsCodeHashing.Hash(seed.TenantId, seed.Phone, SmsPurpose.Login, "123456"), - Status = SmsVerificationStatus.Sent, - ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5) - }); - await context.SaveChangesAsync(); - var service = CreateAuthService(context); - - var result = await service.LoginWithSmsAsync(new SmsLoginRequest( - seed.TenantId, - seed.Phone, - "123456", - null, - null)); - - Assert.Equal(seed.UserId, result.UserId); - Assert.Single(context.AuthSessions); - Assert.Contains(context.SmsVerificationCodes, entity => entity.ConsumedAt is not null); - } - - [Fact] - public async Task Invalid_sms_code_records_failed_event() - { - await using var context = CreateContext(); - var seed = await SeedUserAsync(context, new PasswordHasher().Hash("passw0rd!")); - context.SmsVerificationCodes.Add(new SmsVerificationCode - { - TenantId = seed.TenantId, - Phone = seed.Phone, - Purpose = SmsPurpose.Login, - CodeHash = SmsCodeHashing.Hash(seed.TenantId, seed.Phone, SmsPurpose.Login, "123456"), - Status = SmsVerificationStatus.Sent, - ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5) - }); - await context.SaveChangesAsync(); - var service = CreateAuthService(context); - - await Assert.ThrowsAsync(() => - service.LoginWithSmsAsync(new SmsLoginRequest( - seed.TenantId, - seed.Phone, - "999999", - null, - null))); - - Assert.Empty(context.AuthSessions); - Assert.Contains(context.AuthLoginEvents, entity => - entity.Result == AuthLoginResult.Failed && - entity.FailureCode == "invalid_sms_code"); - } - - [Fact] - public async Task Sms_send_failure_records_failed_code_and_does_not_leave_usable_verification() - { - await using var context = CreateContext(); - var seed = await SeedUserAsync(context, new PasswordHasher().Hash("passw0rd!")); - var smsService = new SmsVerificationService(context, new FailingSmsProvider()); - - var exception = await Assert.ThrowsAsync(() => - smsService.CreateCodeAsync(new SendSmsCodeRequest( - seed.TenantId, - seed.Phone, - SmsPurpose.Login, - null, - null))); - - Assert.Equal("sms_provider_send_failed", exception.Code); - var verification = Assert.Single(context.SmsVerificationCodes); - Assert.Equal(SmsVerificationStatus.Failed, verification.Status); - Assert.Equal("failed", verification.Provider); - - await Assert.ThrowsAsync(() => - smsService.VerifyCodeAsync( - seed.TenantId, - seed.Phone, - SmsPurpose.Login, - "123456")); - } - - [Fact] - public async Task Failed_sms_code_is_never_accepted_even_when_hash_matches() - { - await using var context = CreateContext(); - var seed = await SeedUserAsync(context, new PasswordHasher().Hash("passw0rd!")); - context.SmsVerificationCodes.Add(new SmsVerificationCode - { - TenantId = seed.TenantId, - Phone = seed.Phone, - Purpose = SmsPurpose.Login, - CodeHash = SmsCodeHashing.Hash(seed.TenantId, seed.Phone, SmsPurpose.Login, "123456"), - Status = SmsVerificationStatus.Failed, - ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5) - }); - await context.SaveChangesAsync(); - var smsService = new SmsVerificationService(context, new FakeSmsProvider()); - - await Assert.ThrowsAsync(() => - smsService.VerifyCodeAsync( - seed.TenantId, - seed.Phone, - SmsPurpose.Login, - "123456")); - } - - - [Fact] - public async Task Revoked_refresh_token_cannot_be_refreshed() - { - await using var context = CreateContext(); - var hasher = new PasswordHasher(); - var seed = await SeedUserAsync(context, hasher.Hash("passw0rd!")); - var service = CreateAuthService(context); - var login = await service.LoginWithPasswordAsync(new PasswordLoginRequest( - seed.TenantId, - seed.Phone, - "passw0rd!", - null, - null)); - - await service.LogoutAsync(new LogoutSessionRequest(login.Tokens.RefreshToken)); - - await Assert.ThrowsAsync(() => - service.RefreshAsync(new RefreshSessionRequest( - login.Tokens.RefreshToken, - null, - null))); - } - - [Fact] - public async Task Wechat_miniapp_login_creates_user_identity_membership_and_session() - { - await using var context = CreateContext(); - var tenantId = await SeedTenantWithWechatProviderAsync(context, "wechat-miniapp"); - var service = CreateAuthService( - context, - new FakeWechatOAuthClient( - MiniAppIdentity: new WechatIdentity( - "mini-open-id", - "union-id", - null, - null, - "session-key", - """{"openid":"mini-open-id","unionid":"union-id","session_key":"session-key"}"""))); - - var result = await service.LoginWithWechatMiniAppAsync(new WechatLoginRequest( - tenantId, - "wx-code", - null, - null)); - - Assert.Equal(tenantId, result.Tenant.TenantId); - Assert.Single(context.Users); - Assert.Contains(context.UserIdentities, identity => - identity.Provider == "wechat_miniapp" && - identity.ProviderSubject == "wx-app-id:mini-open-id" && - identity.OpenId == "mini-open-id" && - identity.UnionId == "union-id"); - Assert.Contains(context.TenantMemberships, membership => - membership.TenantId == tenantId && - membership.UserId == result.UserId && - membership.Status == MembershipStatus.Active); - Assert.Single(context.AuthSessions); - } - - [Fact] - public async Task Wechat_union_id_reuses_existing_user_across_providers() - { - await using var context = CreateContext(); - var tenantId = await SeedTenantWithWechatProviderAsync(context, "wechat-miniapp"); - context.TenantExternalProviders.Add(new TenantExternalProvider - { - TenantId = tenantId, - Provider = "wechat_web", - Capability = TenantExternalProviderCapability.Identity, - Status = TenantExternalProviderStatus.Active, - SecretRef = "tenant_secrets:identity:wechat_web:default", - ConfigPublic = WechatProviderConfig() - }); - var user = new User - { - Id = Guid.NewGuid(), - Name = "Existing" - }; - context.Users.Add(user); - context.UserIdentities.Add(new UserIdentity - { - UserId = user.Id, - Provider = "wechat_web", - ProviderSubject = "wx-app-id:web-open-id", - OpenId = "web-open-id", - UnionId = "same-union" - }); - await context.SaveChangesAsync(); - var service = CreateAuthService( - context, - new FakeWechatOAuthClient( - MiniAppIdentity: new WechatIdentity( - "mini-open-id", - "same-union", - null, - null, - "session-key", - """{"openid":"mini-open-id","unionid":"same-union","session_key":"session-key"}"""))); - - var result = await service.LoginWithWechatMiniAppAsync(new WechatLoginRequest( - tenantId, - "wx-code", - null, - null)); - - Assert.Equal(user.Id, result.UserId); - Assert.Single(context.Users); - Assert.Equal(2, context.UserIdentities.Count()); - } - - private static TikuDbContext CreateContext() - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - - return new TikuDbContext(options); - } - - private static IAuthService CreateAuthService( - TikuDbContext context, - IWechatOAuthClient? wechatOAuthClient = null) - { - var tokenService = new TokenService(Options.Create(JwtOptions)); - var sessionService = new SessionService(context, tokenService, Options.Create(JwtOptions)); - var smsService = new SmsVerificationService(context, new FakeSmsProvider()); - - return new AuthService( - context, - new PasswordHasher(), - smsService, - sessionService, - wechatOAuthClient ?? new FakeWechatOAuthClient(), - new FakeProviderConfigService(context)); - } - - private static async Task SeedTenantWithWechatProviderAsync( - TikuDbContext context, - string provider) - { - var tenant = new Tenant - { - Id = Guid.NewGuid(), - Slug = Guid.NewGuid().ToString("N"), - Name = "Wechat Tenant" - }; - context.Tenants.Add(tenant); - context.TenantExternalProviders.Add(new TenantExternalProvider - { - TenantId = tenant.Id, - Provider = provider.Replace("-", "_", StringComparison.Ordinal), - Capability = TenantExternalProviderCapability.Identity, - Status = TenantExternalProviderStatus.Active, - SecretRef = $"tenant_secrets:identity:{provider}:default", - ConfigPublic = WechatProviderConfig() - }); - await context.SaveChangesAsync(); - - return tenant.Id; - } - - private static JsonElement WechatProviderConfig() - { - return JsonSerializer.SerializeToElement(new - { - appId = "wx-app-id" - }); - } - - private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedUserAsync( - TikuDbContext context, - string passwordHash) - { - var tenant = new Tenant - { - Id = Guid.NewGuid(), - Slug = Guid.NewGuid().ToString("N"), - Name = "Test Tenant" - }; - var user = new User - { - Id = Guid.NewGuid(), - Phone = "13800000000", - Name = "Test User" - }; - var membership = new TenantMembership - { - TenantId = tenant.Id, - UserId = user.Id, - Role = TenantRole.Student, - Status = MembershipStatus.Active - }; - var identity = new UserIdentity - { - UserId = user.Id, - Provider = "password", - ProviderSubject = user.Phone, - Phone = user.Phone, - SecretPayload = CreateSecretPayload(passwordHash) - }; - - context.Tenants.Add(tenant); - context.Users.Add(user); - context.TenantMemberships.Add(membership); - context.UserIdentities.Add(identity); - await context.SaveChangesAsync(); - - return (tenant.Id, user.Id, user.Phone); - } - - private static JsonElement CreateSecretPayload(string passwordHash) - { - using var document = JsonDocument.Parse( - $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); - return document.RootElement.Clone(); - } - - private sealed class FakeProviderConfigService(TikuDbContext context) : ITenantExternalProviderConfigService - { - public Task GetActiveProviderAsync( - Guid tenantId, - TenantExternalProviderCapability capability, - string? provider = null, - CancellationToken cancellationToken = default) - { - var normalizedProvider = provider?.Replace("-", "_", StringComparison.Ordinal); - var item = context.TenantExternalProviders - .AsEnumerable() - .Where(entity => - entity.TenantId == tenantId && - entity.Capability == capability && - entity.Status == TenantExternalProviderStatus.Active) - .Where(entity => string.IsNullOrWhiteSpace(normalizedProvider) || entity.Provider == normalizedProvider) - .OrderBy(entity => entity.Priority) - .FirstOrDefault() - ?? throw new TenantExternalProviderException( - "Tenant external provider is not configured.", - "tenant_external_provider_not_configured"); - - return Task.FromResult(new TenantExternalProviderAccount( - item.TenantId, - item.Capability, - item.Provider, - item.Status, - item.DisplayName, - item.ConfigPublic, - JsonSerializer.SerializeToElement(new { appSecret = "wx-app-secret" }), - item.SecretRef, - item.Priority, - item.Metadata)); + await Assert.ThrowsAsync(() => + fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest( + AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, "wrong-password", null, null))); } - public Task> GetProvidersAsync( - Guid tenantId, - TenantExternalProviderCapability? capability = null, - string? provider = null, - int? limit = null, - CancellationToken cancellationToken = default) => - throw new NotSupportedException(); + var user = await fixture.UserManager.FindByIdAsync(fixture.UserId.ToString()); + Assert.True(await fixture.UserManager.IsLockedOutAsync(user!)); + Assert.NotNull(user!.LockoutEnd); + var events = await fixture.DbContext.AuthLoginEvents + .OrderBy(item => item.CreatedAt) + .ToArrayAsync(); + Assert.Equal(5, events.Length); + Assert.Equal(AuthLoginResult.Blocked, events[^1].Result); + Assert.Equal("account_locked", events[^1].FailureCode); + } - public Task UpsertProviderAsync( - Guid tenantId, - UpsertTenantExternalProviderCommand command, - CancellationToken cancellationToken = default) => + [Fact] + public async Task Backend_permission_requires_one_time_mfa_enrollment_challenge() + { + await using var fixture = await AuthFixture.CreateAsync(includeBackendPermission: true); + + var result = await fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest( + AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, AuthFixture.Password, null, null)); + + Assert.Equal(AuthenticationStatus.MfaEnrollmentRequired, result.Status); + Assert.Null(result.User); + Assert.False(string.IsNullOrWhiteSpace(result.ChallengeToken)); + Assert.Empty(await fixture.DbContext.AuthSessions.ToArrayAsync()); + Assert.Single(await fixture.DbContext.AuthChallenges.ToArrayAsync()); + } + + [Fact] + public async Task Incomplete_authenticator_setup_still_requires_enrollment() + { + await using var fixture = await AuthFixture.CreateAsync(includeBackendPermission: true); + var user = await fixture.UserManager.FindByIdAsync(fixture.UserId.ToString()); + Assert.True((await fixture.UserManager.ResetAuthenticatorKeyAsync(user!)).Succeeded); + Assert.False(user!.TwoFactorEnabled); + + var result = await fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest( + AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, AuthFixture.Password, null, null)); + + Assert.Equal(AuthenticationStatus.MfaEnrollmentRequired, result.Status); + } + + [Fact] + public async Task Mfa_setup_audit_captures_request_origin() + { + await using var fixture = await AuthFixture.CreateAsync(includeBackendPermission: true); + var login = await fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest( + AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, AuthFixture.Password, null, null)); + + await fixture.AuthService.SetupTotpAsync(new MfaChallengeRequest( + login.ChallengeToken!, null, "127.0.0.9", "mfa-audit-test")); + + var audit = await fixture.DbContext.AuditLogs.SingleAsync(item => + item.Action == "auth.mfa.enrollment_setup"); + Assert.Equal("127.0.0.9", audit.IpAddress); + Assert.Equal("mfa-audit-test", audit.UserAgent); + } + + private sealed class AuthFixture : IAsyncDisposable + { + public const string Password = "passw0rd!123"; + public const string Phone = "13800000000"; + private readonly ServiceProvider provider; + private readonly AsyncServiceScope scope; + + private AuthFixture(ServiceProvider provider, AsyncServiceScope scope) + { + this.provider = provider; + this.scope = scope; + DbContext = scope.ServiceProvider.GetRequiredService(); + UserManager = scope.ServiceProvider.GetRequiredService>(); + AuthService = scope.ServiceProvider.GetRequiredService(); + } + + public TikuDbContext DbContext { get; } + public UserManager UserManager { get; } + public IAuthService AuthService { get; } + public Guid TenantId { get; private set; } + public Guid UserId { get; private set; } + + public static async Task CreateAsync(bool includeBackendPermission = false) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDataProtection(); + services.AddAuthentication(); + services.AddDbContext(options => + options.UseInMemoryDatabase(Guid.NewGuid().ToString("N"))); + services.AddIdentityCore(options => + { + options.Password.RequiredLength = 10; + options.Password.RequireDigit = true; + options.Password.RequireLowercase = true; + options.Password.RequireUppercase = false; + options.Password.RequireNonAlphanumeric = false; + options.Lockout.MaxFailedAccessAttempts = 5; + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); + }) + .AddEntityFrameworkStores() + .AddSignInManager() + .AddDefaultTokenProviders(); + services.Configure(options => options.IterationCount = 210_000); + services.Configure(options => + { + options.Issuer = "tiku-unit-tests"; + options.Audience = "tiku-unit-tests"; + options.KeyId = "unit-test-rsa"; + }); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + var provider = services.BuildServiceProvider(); + var scope = provider.CreateAsyncScope(); + var fixture = new AuthFixture(provider, scope); + await fixture.SeedAsync(includeBackendPermission); + return fixture; + } + + private async Task SeedAsync(bool includeBackendPermission) + { + var tenant = new Tenant { Id = Guid.NewGuid(), Slug = Guid.NewGuid().ToString("N"), Name = "Test" }; + var user = new User { UserName = Phone, Phone = Phone, PhoneNumber = Phone, Name = "Test User" }; + Assert.True((await UserManager.CreateAsync(user, Password)).Succeeded); + TenantId = tenant.Id; + UserId = user.Id; + DbContext.Tenants.Add(tenant); + DbContext.TenantMemberships.Add(new TenantMembership + { + TenantId = tenant.Id, + UserId = user.Id, + Role = TenantRole.Student, + Status = MembershipStatus.Active + }); + + if (includeBackendPermission) + { + var role = new TenantBackendRole + { + TenantId = tenant.Id, + Code = "teacher", + Name = "Teacher", + Status = BackendRoleStatus.Active + }; + DbContext.TenantBackendRoles.Add(role); + DbContext.BackendPermissions.Add(new BackendPermission + { + Code = BackendPermissions.TenantDashboardView, + Name = "Dashboard", + Area = BackendPermissionArea.Tenant, + Module = "dashboard" + }); + DbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole + { + TenantId = tenant.Id, + UserId = user.Id, + RoleId = role.Id + }); + DbContext.TenantBackendRolePermissions.Add(new TenantBackendRolePermission + { + TenantId = tenant.Id, + RoleId = role.Id, + PermissionCode = BackendPermissions.TenantDashboardView + }); + } + + await DbContext.SaveChangesAsync(); + } + + public async ValueTask DisposeAsync() + { + await scope.DisposeAsync(); + await provider.DisposeAsync(); + } + } + + private sealed class TestJwtKeyRing : IJwtKeyRing, IDisposable + { + private readonly RSA rsa = RSA.Create(2048); + public TestJwtKeyRing() + { + var key = new RsaSecurityKey(rsa) { KeyId = "unit-test-rsa" }; + SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256); + ValidationKeys = [key]; + } + + public SigningCredentials SigningCredentials { get; } + public IReadOnlyCollection ValidationKeys { get; } + public void Dispose() => rsa.Dispose(); + } + + private sealed class RejectingSmsVerificationService : ISmsVerificationService + { + public Task CreateCodeAsync(SendSmsCodeRequest request, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + public Task VerifyCodeAsync(Guid tenantId, string phone, SmsPurpose purpose, string code, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } - private sealed class FakeSmsProvider : ISmsProvider + private sealed class RejectingWechatClient : IWechatOAuthClient { - public Task SendAsync( - SmsProviderSendRequest request, - CancellationToken cancellationToken = default) => - Task.FromResult(new SmsProviderSendResult("fake", "accepted")); + public Task ExchangeWebCodeAsync(WechatProviderOptions options, string code, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + public Task ExchangeMiniAppCodeAsync(WechatProviderOptions options, string code, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); } - private sealed class FailingSmsProvider : ISmsProvider + private sealed class RejectingProviderConfigService : ITenantExternalProviderConfigService { - public Task SendAsync( - SmsProviderSendRequest request, - CancellationToken cancellationToken = default) => - throw new InvalidOperationException("provider unavailable"); - } - - private sealed class FakeWechatOAuthClient( - WechatIdentity? WebIdentity = null, - WechatIdentity? MiniAppIdentity = null) : IWechatOAuthClient - { - public Task ExchangeWebCodeAsync( - WechatProviderOptions options, - string code, - CancellationToken cancellationToken = default) - { - return Task.FromResult(WebIdentity ?? new WechatIdentity( - "web-open-id", - "union-id", - "Wechat User", - "https://example.test/avatar.png", - null, - """{"openid":"web-open-id","unionid":"union-id"}""")); - } - - public Task ExchangeMiniAppCodeAsync( - WechatProviderOptions options, - string code, - CancellationToken cancellationToken = default) - { - return Task.FromResult(MiniAppIdentity ?? new WechatIdentity( - "mini-open-id", - "union-id", - null, - null, - "session-key", - """{"openid":"mini-open-id","unionid":"union-id","session_key":"session-key"}""")); - } + public Task GetActiveProviderAsync(Guid tenantId, TenantExternalProviderCapability capability, string? provider = null, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + public Task> GetProvidersAsync(Guid tenantId, TenantExternalProviderCapability? capability = null, string? provider = null, int? limit = null, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + public Task UpsertProviderAsync(Guid tenantId, UpsertTenantExternalProviderCommand command, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); } } diff --git a/Tiku.UnitTests/Auth/SmsVerificationServiceTests.cs b/Tiku.UnitTests/Auth/SmsVerificationServiceTests.cs new file mode 100644 index 0000000..3151a8b --- /dev/null +++ b/Tiku.UnitTests/Auth/SmsVerificationServiceTests.cs @@ -0,0 +1,231 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Tiku.Application.Auth; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Auth; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.UnitTests.Auth; + +public sealed class SmsVerificationServiceTests +{ + private const string Pepper = "unit-test-sms-code-pepper-32-characters"; + + [Fact] + public void Hash_uses_the_server_pepper() + { + var tenantId = Guid.NewGuid(); + + var first = SmsCodeHashing.Hash(tenantId, "13800000000", SmsPurpose.Login, "123456", Pepper); + var second = SmsCodeHashing.Hash( + tenantId, + "13800000000", + SmsPurpose.Login, + "123456", + "another-unit-test-pepper-32-characters"); + + Assert.NotEqual(first, second); + Assert.Equal(64, first.Length); + } + + [Fact] + public async Task Create_code_generates_six_digits_and_consumes_all_available_rate_limit_dimensions() + { + await using var context = CreateContext(); + var tenantId = await SeedTenantAsync(context); + var provider = new CapturingSmsProvider(); + var service = CreateService(context, provider); + + await service.CreateCodeAsync(new SendSmsCodeRequest( + tenantId, + "13800000000", + SmsPurpose.Login, + "127.0.0.1", + "test-device")); + + Assert.Matches("^[0-9]{6}$", Assert.Single(provider.Requests).Code); + Assert.Equal(4, context.SmsSendRateLimits.Count()); + Assert.Contains(context.SmsSendRateLimits, item => item.Dimension == SmsRateLimitDimension.Tenant); + Assert.Contains(context.SmsSendRateLimits, item => item.Dimension == SmsRateLimitDimension.Phone); + Assert.Contains(context.SmsSendRateLimits, item => item.Dimension == SmsRateLimitDimension.Ip); + Assert.Contains(context.SmsSendRateLimits, item => item.Dimension == SmsRateLimitDimension.Device); + var sendEvent = Assert.Single(context.AuthLoginEvents); + Assert.Equal(AuthLoginResult.Sent, sendEvent.Result); + Assert.Equal("sms", sendEvent.Provider); + } + + [Fact] + public async Task Fifth_invalid_attempt_blocks_code_and_correct_code_is_then_rejected() + { + await using var context = CreateContext(); + var tenantId = await SeedTenantAsync(context); + var verification = await SeedCodeAsync(context, tenantId, "123456"); + var service = CreateService(context); + + for (var attempt = 0; attempt < 5; attempt++) + { + await Assert.ThrowsAsync(() => + service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "999999")); + } + + context.ChangeTracker.Clear(); + var blocked = await context.SmsVerificationCodes.FindAsync(verification.Id); + Assert.NotNull(blocked); + Assert.Equal(5, blocked.Attempts); + Assert.Equal(SmsVerificationStatus.Blocked, blocked.Status); + await Assert.ThrowsAsync(() => + service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "123456")); + } + + [Fact] + public async Task Expired_code_is_transitioned_to_expired() + { + await using var context = CreateContext(); + var tenantId = await SeedTenantAsync(context); + var verification = await SeedCodeAsync( + context, + tenantId, + "123456", + DateTimeOffset.UtcNow.AddSeconds(-1)); + var service = CreateService(context); + + await Assert.ThrowsAsync(() => + service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "123456")); + + context.ChangeTracker.Clear(); + Assert.Equal( + SmsVerificationStatus.Expired, + (await context.SmsVerificationCodes.FindAsync(verification.Id))!.Status); + } + + [Fact] + public async Task Successful_code_can_only_be_consumed_once() + { + await using var context = CreateContext(); + var tenantId = await SeedTenantAsync(context); + var verification = await SeedCodeAsync(context, tenantId, "123456"); + var service = CreateService(context); + + await service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "123456"); + await Assert.ThrowsAsync(() => + service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "123456")); + + context.ChangeTracker.Clear(); + var consumed = await context.SmsVerificationCodes.FindAsync(verification.Id); + Assert.Equal(SmsVerificationStatus.Verified, consumed!.Status); + Assert.NotNull(consumed.ConsumedAt); + } + + [Fact] + public async Task Phone_limit_applies_across_ip_and_device_changes() + { + await using var context = CreateContext(); + var tenantId = await SeedTenantAsync(context); + var options = new SmsSecurityOptions + { + CodePepper = Pepper, + TenantRequestsPerHour = 100, + PhoneRequestsPerHour = 1, + IpRequestsPerHour = 20, + DeviceRequestsPerHour = 10 + }; + var service = CreateService(context, options: options); + + await service.CreateCodeAsync(new SendSmsCodeRequest( + tenantId, + "13800000000", + SmsPurpose.Login, + "127.0.0.1", + "device-one")); + + await Assert.ThrowsAsync(() => + service.CreateCodeAsync(new SendSmsCodeRequest( + tenantId, + "13800000000", + SmsPurpose.Login, + "127.0.0.2", + "device-two"))); + } + + private static SmsVerificationService CreateService( + TikuDbContext context, + ISmsProvider? provider = null, + SmsSecurityOptions? options = null) + { + return new SmsVerificationService( + context, + provider ?? new CapturingSmsProvider(), + Options.Create(options ?? ValidOptions())); + } + + private static SmsSecurityOptions ValidOptions() + { + return new SmsSecurityOptions + { + CodePepper = Pepper, + TenantRequestsPerHour = 100, + PhoneRequestsPerHour = 5, + IpRequestsPerHour = 20, + DeviceRequestsPerHour = 10 + }; + } + + private static TikuDbContext CreateContext() + { + return new TikuDbContext( + new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options); + } + + private static async Task SeedTenantAsync(TikuDbContext context) + { + var tenant = new Tenant + { + Id = Guid.NewGuid(), + Slug = Guid.NewGuid().ToString("N"), + Name = "SMS Test Tenant" + }; + context.Tenants.Add(tenant); + await context.SaveChangesAsync(); + return tenant.Id; + } + + private static async Task SeedCodeAsync( + TikuDbContext context, + Guid tenantId, + string code, + DateTimeOffset? expiresAt = null) + { + var verification = new SmsVerificationCode + { + TenantId = tenantId, + Phone = "13800000000", + Purpose = SmsPurpose.Login, + CodeHash = SmsCodeHashing.Hash( + tenantId, + "13800000000", + SmsPurpose.Login, + code, + Pepper), + Status = SmsVerificationStatus.Sent, + ExpiresAt = expiresAt ?? DateTimeOffset.UtcNow.AddMinutes(5) + }; + context.SmsVerificationCodes.Add(verification); + await context.SaveChangesAsync(); + return verification; + } + + private sealed class CapturingSmsProvider : ISmsProvider + { + public List Requests { get; } = []; + + public Task SendAsync( + SmsProviderSendRequest request, + CancellationToken cancellationToken = default) + { + Requests.Add(request); + return Task.FromResult(new SmsProviderSendResult("test", "sent", "message-id")); + } + } +} diff --git a/Tiku.UnitTests/Bootstrap/PlatformAdminBootstrapperTests.cs b/Tiku.UnitTests/Bootstrap/PlatformAdminBootstrapperTests.cs new file mode 100644 index 0000000..03e0560 --- /dev/null +++ b/Tiku.UnitTests/Bootstrap/PlatformAdminBootstrapperTests.cs @@ -0,0 +1,104 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Tiku.Application.Security; +using Tiku.Domain.Identity; +using Tiku.Infrastructure.Bootstrap; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.UnitTests.Bootstrap; + +public sealed class PlatformAdminBootstrapperTests +{ + private const string TemporaryPassword = "Temporary9Password"; + + [Fact] + public async Task Bootstrap_creates_forced_enrollment_super_admin_and_audit() + { + await using var provider = CreateProvider(); + await using var scope = provider.CreateAsyncScope(); + var bootstrapper = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + + var result = await bootstrapper.BootstrapAsync(new PlatformAdminBootstrapOptions( + "admin@example.com", + TemporaryPassword, + "Initial Administrator")); + + var context = scope.ServiceProvider.GetRequiredService(); + var user = await context.Users.SingleAsync(item => item.Id == result.UserId); + Assert.True(user.ForcePasswordChange); + Assert.False(user.TwoFactorEnabled); + Assert.True(user.EmailConfirmed); + Assert.Equal(UserStatus.Active, user.Status); + var role = await context.PlatformBackendRoles.SingleAsync(item => item.Id == result.RoleId); + Assert.Equal(PlatformAdminBootstrapper.SuperAdminRoleCode, role.Code); + Assert.True(role.IsSystem); + Assert.Equal(BackendPermissions.Platform.Count, await context.PlatformBackendRolePermissions.CountAsync()); + Assert.True(await context.PlatformBackendUserRoles.AnyAsync(item => item.UserId == user.Id && item.RoleId == role.Id)); + Assert.True(await context.AuditLogs.AnyAsync(item => + item.ActorUserId == user.Id && item.Action == "platform.bootstrap_admin.created")); + + var userManager = scope.ServiceProvider.GetRequiredService>(); + Assert.True(await userManager.CheckPasswordAsync(user, TemporaryPassword)); + } + + [Fact] + public async Task Bootstrap_rejects_a_second_platform_administrator_without_mutating_data() + { + await using var provider = CreateProvider(); + await using var scope = provider.CreateAsyncScope(); + var bootstrapper = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + await bootstrapper.BootstrapAsync(new PlatformAdminBootstrapOptions("first@example.com", TemporaryPassword)); + + var exception = await Assert.ThrowsAsync(() => + bootstrapper.BootstrapAsync(new PlatformAdminBootstrapOptions("second@example.com", TemporaryPassword))); + + Assert.Equal("platform_admin_already_exists", exception.Code); + var context = scope.ServiceProvider.GetRequiredService(); + Assert.Single(context.PlatformBackendUserRoles); + Assert.Single(context.PlatformBackendRoles); + Assert.Single(context.AuditLogs); + Assert.Single(context.Users); + } + + [Fact] + public async Task Bootstrap_rejects_an_existing_user_email() + { + await using var provider = CreateProvider(); + await using var scope = provider.CreateAsyncScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var createResult = await userManager.CreateAsync(new User + { + Email = "existing@example.com", + UserName = "existing@example.com" + }, TemporaryPassword); + Assert.True(createResult.Succeeded); + var bootstrapper = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + + var exception = await Assert.ThrowsAsync(() => + bootstrapper.BootstrapAsync(new PlatformAdminBootstrapOptions("existing@example.com", TemporaryPassword))); + + Assert.Equal("bootstrap_user_already_exists", exception.Code); + } + + private static ServiceProvider CreateProvider() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDbContext(options => + options.UseInMemoryDatabase(Guid.NewGuid().ToString())); + services.AddIdentityCore(options => + { + options.Password.RequiredLength = 10; + options.Password.RequireDigit = true; + options.Password.RequireLowercase = true; + options.Password.RequireUppercase = false; + options.Password.RequireNonAlphanumeric = false; + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + services.AddDataProtection().UseEphemeralDataProtectionProvider(); + return services.BuildServiceProvider(); + } +} diff --git a/Tiku.UnitTests/Security/CurrentDataScopeTests.cs b/Tiku.UnitTests/Security/CurrentDataScopeTests.cs new file mode 100644 index 0000000..409c588 --- /dev/null +++ b/Tiku.UnitTests/Security/CurrentDataScopeTests.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using Tiku.Application.Security; + +namespace Tiku.UnitTests.Security; + +public sealed class CurrentDataScopeTests +{ + [Fact] + public void Merge_EmptyOrInvalidScopes_DefaultsToSelf() + { + var result = CurrentDataScope.Merge( + [ + JsonSerializer.SerializeToElement(new { }), + JsonSerializer.SerializeToElement("invalid") + ]); + + Assert.Equal(DataScopeMode.Self, result.Mode); + Assert.True(result.IncludesSelf); + Assert.Empty(result.RegionIds); + Assert.Empty(result.ClassIds); + } + + [Fact] + public void Merge_RestrictedRoles_UnionsResourceIdsAndSelfAccess() + { + var firstRegion = Guid.NewGuid(); + var secondRegion = Guid.NewGuid(); + var classId = Guid.NewGuid(); + + var result = CurrentDataScope.Merge( + [ + JsonSerializer.SerializeToElement(new + { + mode = "Restricted", + regionIds = new[] { firstRegion }, + classIds = new[] { classId } + }), + JsonSerializer.SerializeToElement(new + { + mode = "Restricted", + regionIds = new[] { secondRegion }, + includesSelf = true + }) + ]); + + Assert.Equal(DataScopeMode.Restricted, result.Mode); + Assert.True(result.IncludesSelf); + Assert.True(result.RegionIds.SetEquals([firstRegion, secondRegion])); + Assert.True(result.ClassIds.SetEquals([classId])); + } + + [Fact] + public void Merge_AllScope_OverridesRestrictedScopes() + { + var result = CurrentDataScope.Merge( + [ + JsonSerializer.SerializeToElement(new { mode = "Restricted", regionIds = new[] { Guid.NewGuid() } }), + JsonSerializer.SerializeToElement(new { type = "All" }) + ]); + + Assert.Equal(DataScopeMode.All, result.Mode); + Assert.True(result.IncludesSelf); + Assert.Empty(result.RegionIds); + Assert.Empty(result.ClassIds); + } + + [Fact] + public void PermissionCatalog_UsesUniqueRealmScopedCodes() + { + Assert.All(BackendPermissions.Tenant, code => Assert.StartsWith("tenant:", code, StringComparison.Ordinal)); + Assert.All(BackendPermissions.Platform, code => Assert.StartsWith("platform:", code, StringComparison.Ordinal)); + Assert.Empty(BackendPermissions.Tenant.Intersect(BackendPermissions.Platform, StringComparer.Ordinal)); + } + + [Fact] + public void AllowsResource_UsesOwnerRegionAndClassWithoutCrossScopeFallback() + { + var userId = Guid.NewGuid(); + var regionId = Guid.NewGuid(); + var classId = Guid.NewGuid(); + var scope = new CurrentDataScope( + DataScopeMode.Restricted, + new HashSet { regionId }, + new HashSet { classId }, + false); + + Assert.True(scope.AllowsResource(userId, regionId: regionId)); + Assert.True(scope.AllowsResource(userId, classId: classId)); + Assert.False(scope.AllowsResource(userId, ownerUserId: userId)); + Assert.False(scope.AllowsResource(userId, regionId: Guid.NewGuid(), classId: Guid.NewGuid())); + } +} diff --git a/Tiku.UnitTests/Security/DataProtectionKeyRingOptionsTests.cs b/Tiku.UnitTests/Security/DataProtectionKeyRingOptionsTests.cs new file mode 100644 index 0000000..1e68ec7 --- /dev/null +++ b/Tiku.UnitTests/Security/DataProtectionKeyRingOptionsTests.cs @@ -0,0 +1,94 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Tiku.Infrastructure.Security; + +namespace Tiku.UnitTests.Security; + +public sealed class DataProtectionKeyRingOptionsTests +{ + [Fact] + public void Development_allows_an_unencrypted_key_ring() + { + var options = new DataProtectionKeyRingOptions(); + + Assert.True(DataProtectionKeyRingOptions.BeValid(options, requireCertificate: false)); + Assert.Null(options.LoadCertificate(requireCertificate: false)); + } + + [Fact] + public void Production_requires_a_certificate_path() + { + var options = new DataProtectionKeyRingOptions(); + + Assert.False(DataProtectionKeyRingOptions.BeValid(options, requireCertificate: true)); + var exception = Assert.Throws(() => + options.LoadCertificate(requireCertificate: true)); + Assert.Contains("required outside Development", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void Application_name_is_always_required() + { + var options = new DataProtectionKeyRingOptions + { + ApplicationName = " ", + CertificatePath = "/configured/key-ring.pfx" + }; + + Assert.False(DataProtectionKeyRingOptions.BeValid(options, requireCertificate: false)); + Assert.False(DataProtectionKeyRingOptions.BeValid(options, requireCertificate: true)); + } + + [Fact] + public void Configured_certificate_file_must_be_loadable() + { + var options = new DataProtectionKeyRingOptions + { + CertificatePath = Path.Combine( + Path.GetTempPath(), + $"missing-data-protection-{Guid.NewGuid():N}.pfx") + }; + + Assert.True(DataProtectionKeyRingOptions.BeValid(options, requireCertificate: true)); + var exception = Assert.Throws(() => + options.LoadCertificate(requireCertificate: true)); + Assert.Contains("could not be loaded", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void Password_protected_pkcs12_certificate_with_private_key_is_loaded() + { + const string password = "unit-test-certificate-password"; + var certificatePath = Path.Combine( + Path.GetTempPath(), + $"data-protection-{Guid.NewGuid():N}.pfx"); + + try + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + "CN=Tiku Data Protection Unit Test", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + using var certificate = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddMinutes(-1), + DateTimeOffset.UtcNow.AddDays(1)); + File.WriteAllBytes(certificatePath, certificate.Export(X509ContentType.Pfx, password)); + + var options = new DataProtectionKeyRingOptions + { + CertificatePath = certificatePath, + CertificatePassword = password + }; + + using var loaded = options.LoadCertificate(requireCertificate: true); + Assert.NotNull(loaded); + Assert.True(loaded.HasPrivateKey); + } + finally + { + File.Delete(certificatePath); + } + } +} diff --git a/docs/architecture/authentication-authorization-security.md b/docs/architecture/authentication-authorization-security.md new file mode 100644 index 0000000..473edfc --- /dev/null +++ b/docs/architecture/authentication-authorization-security.md @@ -0,0 +1,449 @@ +# TIKU SaaS 认证、授权与 Host 安全策略 + +本文档描述 TIKU Backend 当前生效的安全架构,是认证、后台授权、租户隔离、Host 解析、Session、MFA 和短信验证码实现的统一约定。新增接口或修改登录流程时,应以本文档和自动化测试为准,不能只依赖前端菜单、JWT 字符串或历史 `TenantRole` 约定。 + +## 1. 安全目标与基本原则 + +系统同时存在两个互相隔离的授权域: + +- `tenant`:租户业务域,必须绑定一个 Active 租户和一个 Active `TenantMembership`。 +- `platform`:平台运营域,不绑定租户,只能从配置的 Platform Host 进入。 + +核心原则: + +1. Host、JWT scope、tenant claim、数据库 Session 和请求租户上下文必须一致。 +2. JWT 只证明一次已认证会话,不承载可直接授权的角色或权限。 +3. 后台权限每次从数据库角色绑定解析,菜单只负责 UI 展示,不负责 API 授权。 +4. 数据权限必须进入 SQL;无法可靠映射 owner、region 或 class 的资源采用 `All`-only fail-closed,不猜测数据归属。 +5. 用户、成员、租户、后台角色、后台权限、SecurityStamp 或 Session 任一失效,旧 token 都不能继续扩大访问权。 +6. 所有 Controller 默认要求认证,公开接口必须显式标记 `[AllowAnonymous]`。 + +整体边界如下: + +```text +浏览器 / App + | + | HTTPS + Host + Bearer/refresh/challenge + v +可信反向代理 + | + | 仅 TrustedProxyAddresses 可以提供 Forwarded Headers + v +TenantResolutionMiddleware + | + +--> Platform Host --------> platform realm(不得出现 TenantId) + | + +--> Active Tenant Host ---> tenant realm(锁定 Host 对应 TenantId) + | + +--> Unknown Host ---------> 非豁免路径 404 + v +JWT + 数据库 AuthSession 校验 + v +IAuthorizationHandler + ICurrentAccessContext + v +EF tenant filter + DataScope SQL + PostgreSQL 约束 +``` + +## 2. 账号安全底座 + +账号由 ASP.NET Core Identity 管理,`User` 继承 `IdentityUser`,Identity 与业务实体共用 `TikuDbContext`。系统不使用 ASP.NET 全局 Role 表,租户与平台后台角色由独立 SaaS RBAC 表维护。 + +当前固定参数: + +- 密码最少 10 位。 +- Identity PBKDF2 迭代次数为 210,000。 +- 连续 5 次密码失败后锁定 15 分钟。 +- 用户 Active 状态在每次 Session 校验时检查;强制改密、退出全部设备和其他账号安全事件同时通过 SecurityStamp 使旧 Session 失效。 +- TOTP、恢复码、Authenticator Key 使用 Identity 标准能力。 +- 微信等外部身份只保留 provider subject、openid、unionid 等映射,不保存 `session_key` 或原始 secret。 + +Identity 的 Data Protection key 持久化到 PostgreSQL。Development 可以不使用证书;非 Development 环境必须提供包含私钥的 PKCS#12 证书保护 key ring,否则 API 启动失败。 + +## 3. JWT 与数据库 Session + +### 3.1 Access token + +Access token 使用 RSA SHA-256 非对称签名,Header 必须包含可识别的 `kid`。生产配置包含当前私钥;轮换期间把仍需验证的旧公钥放入 `PublicKeys`。未知 `kid`、错误签名、弱于 2048 位的 RSA key、把当前 `kid` 重复放入旧公钥集合等配置都会被拒绝。 + +Access token 固定 15 分钟,并包含: + +| Claim | 含义 | +| --- | --- | +| `sub` | Identity User ID | +| `sid` | 当前 `AuthSession` ID | +| `jti` | 当前 access token 的唯一 ID | +| `iat` | 签发时间 | +| `iss` / `aud` / `exp` | issuer、audience 和过期时间 | +| `scope` | `tenant` 或 `platform` | +| `tid` | 仅 tenant token 必须包含;platform token 禁止包含 | +| `amr=mfa` | 当前 Session 已完成 MFA 时包含 | + +JWT 不包含用于 API 授权的 role 或 permission claim。 + +### 3.2 AuthSession + +`auth_sessions` 是 access/refresh 的服务端事实来源,关键字段包括: + +- `realm`、可空 `tenant_id`、`user_id`; +- `token_family_id`、`parent_session_id`、`replaced_by_session_id`; +- refresh token hash、SecurityStamp、MFA 状态; +- expires/revoked 时间与 revoked reason。 + +数据库 check constraint 保证 tenant Session 必须有 `tenant_id`,platform Session 不得有 `tenant_id`。业务代码只能通过 `IAuthSessionStore` 访问 Session。 + +每次 Bearer token 验证都必须同时确认: + +```text +RSA 签名/kid/iss/aud/exp + | + v +sub + sid + jti + iat + scope/tid 结构正确 + | + v +Host realm 与 scope/tid 一致 + | + v +AuthSession 存在、未撤销、未过期 + | + v +Session.user/tenant/realm/MFA 与 JWT 一致 + | + v +User Active + SecurityStamp 一致 + | + v +tenant: Tenant Active + Membership Active +platform: 仍有有效平台后台权限 + | + v +进入 Authorization Handler +``` + +Session 校验没有配置旁路;不能通过关闭选项把 JWT 降级为纯无状态 token。 + +### 3.3 Refresh、logout 与重放 + +Refresh token 格式为: + +```text +v2.{t|p}.{tenantId|-}.{sessionId}.{64-byte-random-secret} +``` + +数据库只保存完整 refresh token 的 SHA-256 hash,明文只返回客户端一次。 + +刷新在事务内完成: + +```text +旧 refresh token + | + v +读取并验证当前 Session/用户/realm/tenant/SecurityStamp + | + v +原子设置 revoked=rotated + replacedBySessionId + | + v +创建同 family 的子 Session,返回新 access/refresh +``` + +并发刷新只允许一个请求成功。已轮换 token 被再次使用时视为重放,整个 token family 被撤销并写入审计。 + +- `POST /api/auth/logout`:撤销 refresh token 所属 family。 +- `POST /api/auth/logout-all`:更新 SecurityStamp,并撤销用户全部 Session。 +- 成员禁用、租户暂停、用户禁用、后台权限撤销后,旧 access/refresh 均不能继续取得对应后台能力。 + +## 4. Host 与 realm 安全策略 + +Host 不是普通路由参数,而是认证上下文的一部分。Host 在 Authentication 之前由 `TenantResolutionMiddleware` 解析。 + +### 4.1 Host 类型 + +| 请求入口 | 租户上下文 | 允许的认证域 | 结果 | +| --- | --- | --- | --- | +| 配置的 Platform Host | 默认无租户 | platform;部分白名单路径可显式提供 tenantCode 进入 tenant | 继续处理 | +| Active 租户自定义 Host | 固定为该 Host 对应租户 | tenant | 继续处理 | +| 租户 Host + 不同 tenantCode/header | Host 与输入冲突 | 无 | 403 | +| Platform Host + platform realm + tenantCode | 非法混合 | 无 | 400 | +| 非 Platform、未绑定租户的未知 Host | 无 | 无 | 非豁免路径 404 | +| 未知 Host 上的 platform 登录/refresh/logout/MFA challenge | 无 | 无 | 默认先由 Host 解析返回 404;即使路径被配置为豁免,平台 Host 二次校验仍返回 400 | + +默认 Platform Host 是 `localhost` 和 `127.0.0.1`,生产必须通过 `Tenancy:Resolution:PlatformHosts` 配置正式平台域名。 + +### 4.2 租户 Host 流程演示 + +假设 `school-a.example.com` 已绑定 Tenant A: + +```text +GET https://school-a.example.com/api/me +Host: school-a.example.com +Authorization: Bearer + +Host ----查询----> Tenant A (Active) +token scope ------> tenant +token tid --------> Tenant A +session tenant ---> Tenant A + +四者一致:继续授权 +``` + +如果同一请求携带 Tenant B token: + +```text +Host -------------> Tenant A +token tid --------> Tenant B + X 不一致 +结果 -------------> 403 tenant_context_conflict +``` + +在租户自定义 Host 上,`x-tenant-code`、query `tenantCode` 或 body 中的 tenant ID 都不能切换到另一个租户。 + +### 4.3 Platform Host 流程演示 + +平台管理员登录: + +```text +POST https://admin.example.com/api/auth/login/password +{ + "realm": "platform", + "identifier": "admin@example.com", + "password": "..." +} + +Host 在 PlatformHosts ----是----> tenant context 必须为空 +tenantCode ----------不得提供 +有效平台角色权限 ----必须存在 +后台权限 ------------要求 TOTP/强改密流程 +``` + +platform token 只能在 Platform Host 使用: + +```text +platform token + admin.example.com -> 允许继续 +platform token + school-a.example.com -> 403 +platform token + unknown.example.com -> 拒绝 +``` + +### 4.4 在 Platform Host 访问 tenant realm + +统一平台 Host 上的部分公共/认证入口允许使用 `x-tenant-code` 或 query `tenantCode` 解析租户。允许的路径前缀由 `TenantCodePathPrefixes` 控制,默认包括 auth、tenant、catalog、assets、scoreline、referral 和支付通知等入口。 + +示例: + +```text +POST http://localhost/api/auth/login/password +x-tenant-code: school-a +{ + "realm": "tenant", + "tenantCode": "school-a", + "identifier": "13800000000", + "password": "..." +} + +Platform Host + 白名单路径 + tenantCode + | + v +解析 Active Tenant A + | + v +后续 token tid / Session tenant / request tenant 必须都是 Tenant A +``` + +普通业务路径不能借 `x-tenant-code` 任意切换租户。 + +### 4.5 Forwarded Host 与可信代理 + +API 可以读取标准 Forwarded Headers,并限制 `ForwardLimit=1`。`TrustedProxyAddresses` 非空时只信任其中配置的代理;按照 ASP.NET Core Forwarded Headers 的语义,KnownProxies/KnownNetworks 同时为空会接受任意转发源,因此生产环境必须配置至少一个可信代理地址,且不能把 API 暴露为可绕过网关的公网入口。生产网关必须: + +- 覆盖客户端传入的 `X-Forwarded-Host`、`X-Forwarded-For`、`X-Forwarded-Proto`; +- 只向 API 转发一个经过验证的外部 Host; +- 把网关地址加入 `TrustedProxyAddresses`; +- 禁止 API 直接暴露到可绕过网关的公网入口。 + +没有可信代理配置时,不应假设任意客户端提供的 `X-Forwarded-Host` 会被系统信任。 + +## 5. 登录状态、强制改密与 MFA + +所有登录方式统一返回以下四种状态之一: + +- `authenticated` +- `mfa_required` +- `mfa_enrollment_required` +- `password_change_required` + +拥有任一 tenant/platform 后台权限的账号必须完成 TOTP。登录不会在 MFA 前签发业务 token,只返回 5 分钟、一次性 challenge。 + +```text +账号密码/短信/微信验证成功 + | + +--> ForcePasswordChange ------> password_change_required + | + +--> 有后台权限 + 未配置 TOTP -> mfa_enrollment_required + | + +--> 有后台权限 + 已配置 TOTP -> mfa_required + | + +--> 无后台权限 --------------> authenticated +``` + +TOTP 接口: + +- `POST /api/auth/mfa/totp/setup` +- `POST /api/auth/mfa/totp/confirm` +- `POST /api/auth/mfa/totp/verify` + +恢复码仅在首次确认 TOTP 时返回一次;每个恢复码只能兑换一次,重放会失败并记录审计。平台 challenge 的 setup/confirm/verify 也必须继续使用 Platform Host。 + +## 6. tenant/platform RBAC + +授权由 `ICurrentAccessContext` 从数据库解析: + +```text +当前 User + +-- tenant realm --> Active Membership + | +-- TenantBackendUserRole + | +-- Active TenantBackendRole + | +-- RolePermission + | +-- tenant:* permission + | + +-- platform realm -> PlatformBackendUserRole + +-- Active PlatformBackendRole + +-- RolePermission + +-- platform:* permission +``` + +后台授权不读取 JWT role claim、`User.PrimaryRole` 或 `TenantMembership.Role`。`TenantMembership` 只表达租户成员状态和业务身份。Tenant Owner 会绑定不可删除的 `tenant_owner` 系统后台角色;平台超级管理员只由 `platform_super_admin` 系统角色绑定产生。 + +当前基础权限点: + +```text +tenant:dashboard:view platform:dashboard:view +tenant:staff:manage platform:tenant:manage +tenant:role:manage platform:staff:manage +tenant:student:manage platform:role:manage +tenant:content:manage platform:question-bank:manage +tenant:settings:manage platform:audit:view +tenant:provider:manage +tenant:commerce:operate +tenant:crm:manage +tenant:commission:manage +tenant:job:manage +``` + +主要 Authorization Requirement: + +- `CurrentTenantMemberRequirement` +- `TenantPermissionRequirement(code)` +- `PlatformPermissionRequirement(code)` +- `MfaRequirement` +- `TenantResourceAccessRequirement` + +全局 fallback policy 要求认证;后台权限 policy 同时要求数据库 permission 和 MFA。拒绝访问会写统一审计。 + +### 菜单不是授权 + +tenant/platform UI bootstrap 只返回当前数据库有效权限对应的 active menu: + +```text +数据库有效 permissions ---> 过滤 active menus ---> 前端显示 + | + +-------------------------------> API Authorization Handler 再验证 +``` + +隐藏菜单不能代替 API 授权;手工调用 URL 仍会经过 policy。 + +## 7. DataScope + +角色 DataScope 合并规则: + +- `All`:允许访问当前租户内该模块全部资源,优先级最高。 +- `Restricted(regionIds, classIds)`:多个角色的 region/class 取并集,可选包含 Self。 +- `Self`:只允许 owner/当前用户关联资源。 + +资源列表、详情和写操作必须使用同一范围。越权详情或写入统一按未找到处理,返回 404,避免泄露资源是否存在。 + +```text +多个有效角色 + | + +--> 任一 All --------------------> All + | + +--> Restricted A + Restricted B -> region/class 并集 + | + +--> 只有 Self -------------------> Self +``` + +当前学生、班级、现代内容管理,以及具备 owner/region 关系的订单、支付、退款和 DirectContent 资源已在 SQL 中应用范围。支付配置、激活码、积分、优惠券、对账、CRM webhook/config 等缺少可靠 owner/region/class 外键的资源只允许 `All`,Restricted/Self 账号会 fail-closed,不能退化为仅按 tenant 查询。 + +## 8. 短信验证码安全 + +短信发送入口为 `POST /api/auth/sms/send`,仅支持 tenant realm,响应 `202` 且不返回验证码。 + +安全策略: + +- 使用 `RandomNumberGenerator.GetInt32` 生成 6 位验证码。 +- 使用服务端 pepper 的 HMAC-SHA256 保存验证码摘要。 +- 同一验证码最多失败 5 次,第 5 次原子标记 `Blocked`。 +- 正确验证码只能原子消费一次;并发请求只有一个成功。 +- 持久化限制 tenant、phone、IP、device 四个维度。 +- HTTP 命名限流再按 phone + IP 分区。 +- pepper 至少 32 个字符,缺失时启动校验失败。 + +默认额度: + +| 维度 | 默认值 | +| --- | --- | +| tenant | 100 次/小时 | +| phone | 5 次/小时 | +| IP | 20 次/小时 | +| device | 10 次/小时 | +| 验证失败 | 5 次后 Blocked | + +## 9. 审计与错误响应 + +统一审计覆盖: + +- 登录成功、失败、锁定; +- MFA enrollment、验证、恢复码; +- Session family 撤销、logout-all、refresh 重放; +- 角色、权限、菜单、用户角色绑定; +- 平台管理员 bootstrap; +- 已认证用户的授权拒绝。 + +API 使用 ProblemDetails。常见结果: + +| 状态 | 场景 | +| --- | --- | +| 400 | realm/tenantCode/Host 契约错误、无效输入 | +| 401 | 未认证、token/Session 无效 | +| 403 | 已认证但权限不足,或 Host 与 token tenant 冲突 | +| 404 | 未知租户 Host、资源不存在或 DataScope 越权 | +| 429 | 密码、短信、MFA 或全局限流 | + +## 10. 生产配置清单 + +上线前至少确认: + +1. `Tenancy:Resolution:PlatformHosts` 只包含正式平台域名。 +2. `Tenancy:Resolution:TrustedProxyAddresses` 只包含实际网关地址。 +3. JWT `Issuer`、`Audience`、当前 `KeyId`、RSA 私钥和旧公钥集合已配置。 +4. Access token 仍固定为 15 分钟,不能关闭数据库 Session 校验。 +5. `TIKU_SMS_CODE_PEPPER` 使用独立高熵值,不使用开发默认值。 +6. `TIKU_DATA_PROTECTION_CERTIFICATE_PATH` 指向包含私钥的 PKCS#12 文件,并配置密码。 +7. CORS 只允许明确 Origin;浏览器 cookie/BFF 不在当前 token JSON 契约内。 +8. API/Worker 不自动迁移数据库;部署流程显式运行 `Tiku.DbMigrator`。 +9. 首次部署使用一次性 `--bootstrap-platform-admin`,完成强制改密和 TOTP 后销毁临时密码。 +10. 网关阻断未知 Host,并禁止绕过可信代理直连 API。 + +## 11. 新接口安全检查 + +新增或修改接口时必须回答: + +- 它属于 tenant 还是 platform realm? +- 是否显式 `[AllowAnonymous]`;若不是,使用哪个 permission policy? +- Host、tenant context、JWT `tid` 和 Session 是否能形成一致闭环? +- 是否需要 MFA?后台 permission policy 默认需要。 +- 资源如何映射 owner、region、class?列表和写入是否使用相同 SQL 范围? +- 越权是否返回 404? +- 是否写审计? +- 是否需要 account+IP、phone+IP 或持久化多维限流? +- 是否错误地读取 JWT role、PrimaryRole、TenantRole 或前端菜单做授权? + +如果资源没有可靠的数据范围关联,先限制为 `All`,再通过明确的 schema 变更补足 owner/region/class 外键;禁止用字符串 ID 或 JSON 内容猜测权限范围。