feat: harden SaaS authentication and authorization
This commit is contained in:
@@ -13,6 +13,8 @@
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
@@ -24,6 +26,7 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="10.0.10" />
|
||||
<PackageVersion Include="AlibabaCloud.OSS.V2" Version="0.2.0" />
|
||||
<PackageVersion Include="AlibabaCloud.SDK.Dysmsapi20170525" Version="4.4.0" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||
|
||||
32
README.md
32
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 注册;检测到已有平台管理员时命令会拒绝重复引导。不要把临时密码写入仓库配置或命令行参数。
|
||||
|
||||
@@ -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;
|
||||
/// </summary>
|
||||
public sealed class PasswordLoginDto
|
||||
{
|
||||
[Required]
|
||||
public AuthRealm? Realm { get; set; }
|
||||
/// <summary>
|
||||
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略。
|
||||
/// </summary>
|
||||
@@ -17,18 +20,21 @@ public sealed class PasswordLoginDto
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 手机号,建议前端提交规范化后的中国大陆手机号。
|
||||
/// 账号标识。tenant 可使用手机号,platform 可使用邮箱或用户名。
|
||||
/// </summary>
|
||||
[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; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户密码。
|
||||
/// </summary>
|
||||
[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
|
||||
/// </summary>
|
||||
public sealed class SmsLoginDto
|
||||
{
|
||||
[Required]
|
||||
public AuthRealm? Realm { get; set; }
|
||||
/// <summary>
|
||||
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略。
|
||||
/// </summary>
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OAuth code 登录请求。
|
||||
/// </summary>
|
||||
public sealed class OAuthCodeDto
|
||||
{
|
||||
[Required]
|
||||
public AuthRealm? Realm { get; set; }
|
||||
/// <summary>
|
||||
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略。
|
||||
/// </summary>
|
||||
@@ -135,10 +161,12 @@ public sealed class AuthenticatedUserDto
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
public AuthRealm Realm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前登录租户成员摘要。
|
||||
/// </summary>
|
||||
public TenantMembershipSummary Tenant { get; init; } = default!;
|
||||
public TenantMembershipSummary? Tenant { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 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<string> 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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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> tenantResolutionOptions) : ControllerBase
|
||||
{
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
|
||||
[HttpPost("sms/send")]
|
||||
[ProducesResponseType<SmsSendResult>(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status429TooManyRequests)]
|
||||
public async Task<ActionResult<SmsSendResult>> 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<AuthenticatedUserDto>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<AuthenticationResultDto>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult<AuthenticatedUserDto>> LoginWithPassword(
|
||||
public async Task<ActionResult<AuthenticationResultDto>> 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<AuthenticatedUserDto>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<AuthenticationResultDto>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult<AuthenticatedUserDto>> LoginWithSms(
|
||||
public async Task<ActionResult<AuthenticationResultDto>> 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<AuthenticatedUserDto>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<AuthenticationResultDto>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status503ServiceUnavailable)]
|
||||
public async Task<ActionResult<AuthenticatedUserDto>> LoginWithWechatWeb(
|
||||
public async Task<ActionResult<AuthenticationResultDto>> 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<AuthenticatedUserDto>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<AuthenticationResultDto>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status503ServiceUnavailable)]
|
||||
public async Task<ActionResult<AuthenticatedUserDto>> LoginWithWechatMiniApp(
|
||||
public async Task<ActionResult<AuthenticationResultDto>> 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<IActionResult> 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<ActionResult<MfaSetupResult>> 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<ActionResult<MfaConfirmDto>> 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<ActionResult<AuthenticationResultDto>> 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<ActionResult<AuthenticationResultDto>> 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<Guid> ResolveTenantIdAsync(string? tenantCode, CancellationToken cancellationToken)
|
||||
private async Task<Guid?> 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<BackofficeUiBootstrap>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackofficeUiBootstrap>> 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<BackofficeBootstrap>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackofficeBootstrap>> 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<BackofficeUiBootstrap>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackofficeUiBootstrap>> 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<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackofficeRoleItem>> 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<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackofficeRoleItem>> 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<IActionResult> 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<BackofficeBootstrap>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackofficeBootstrap>> 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<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackofficeRoleItem>> 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<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackofficeRoleItem>> 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<IActionResult> 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<BackofficeActor> 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<BackofficeActor> 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -95,7 +95,7 @@ public sealed class ReferralController(
|
||||
}
|
||||
|
||||
[HttpGet("stats")]
|
||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
||||
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
|
||||
[EndpointSummary("查询推荐人个人统计")]
|
||||
[ProducesResponseType<ReferralStatsItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ReferralStatsItem>> Stats(
|
||||
@@ -109,7 +109,7 @@ public sealed class ReferralController(
|
||||
}
|
||||
|
||||
[HttpGet("sales-stats")]
|
||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
||||
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
|
||||
[EndpointSummary("查询销售推荐统计排行")]
|
||||
[ProducesResponseType<ReferralList<ReferralStatsItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ReferralList<ReferralStatsItem>>> SalesStats(
|
||||
@@ -123,7 +123,7 @@ public sealed class ReferralController(
|
||||
}
|
||||
|
||||
[HttpGet("conversion-report")]
|
||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
||||
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
|
||||
[EndpointSummary("查询推荐转化报告")]
|
||||
[ProducesResponseType<ReferralConversionReport>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ReferralConversionReport>> ConversionReport(
|
||||
@@ -137,7 +137,7 @@ public sealed class ReferralController(
|
||||
}
|
||||
|
||||
[HttpGet("sales-clients")]
|
||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
||||
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
|
||||
[EndpointSummary("查询推荐人名下客户")]
|
||||
[ProducesResponseType<ReferralList<ReferralLeadItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ReferralList<ReferralLeadItem>>> SalesClients(
|
||||
@@ -151,7 +151,7 @@ public sealed class ReferralController(
|
||||
}
|
||||
|
||||
[HttpPost("manual-bind")]
|
||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
||||
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
|
||||
[EndpointSummary("人工调整学生推荐归属")]
|
||||
[ProducesResponseType<ReferralBindResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ReferralBindResult>> ManualBind(
|
||||
@@ -165,7 +165,7 @@ public sealed class ReferralController(
|
||||
}
|
||||
|
||||
[HttpGet("team")]
|
||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
||||
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
|
||||
[EndpointSummary("查询推荐团队成员")]
|
||||
[ProducesResponseType<ReferralList<ReferralTeamItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ReferralList<ReferralTeamItem>>> Team(
|
||||
@@ -179,7 +179,7 @@ public sealed class ReferralController(
|
||||
}
|
||||
|
||||
[HttpPut("team")]
|
||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
||||
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
|
||||
[EndpointSummary("新增或更新推荐团队关系")]
|
||||
[ProducesResponseType<ReferralTeamItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ReferralTeamItem>> UpsertTeam(
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ public sealed class TaxonomyController(
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
||||
[Authorize(Policy = BackendPermissions.TenantContentManage)]
|
||||
public Task<TaxonomyNodeItem> Create(
|
||||
CreateTaxonomyNodeDto request,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -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<TenantAdminClassList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantAdminClassList>> GetClasses(
|
||||
@@ -29,6 +29,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpPut("classes")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("新增或更新租户班级")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantAdminClassItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantAdminClassItem>>> UpsertClass(
|
||||
@@ -39,6 +40,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpPost("classes/disable")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("停用租户班级")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantAdminClassItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantAdminClassItem>>> DisableClass(
|
||||
@@ -49,6 +51,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("classes/members")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("查询班级成员")]
|
||||
[ProducesResponseType<CatalogList<TenantAdminClassMemberItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<TenantAdminClassMemberItem>>> GetClassMembers(
|
||||
@@ -59,6 +62,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpPut("classes/members")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("新增或更新班级成员")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantAdminClassMemberItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantAdminClassMemberItem>>> UpsertClassMember(
|
||||
@@ -69,6 +73,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpPost("classes/members/remove")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("移除班级成员")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantAdminClassMemberItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantAdminClassMemberItem>>> RemoveClassMember(
|
||||
@@ -79,6 +84,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("students")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("查询租户学生")]
|
||||
[ProducesResponseType<TenantAdminStudentList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantAdminStudentList>> GetStudents(
|
||||
@@ -89,6 +95,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpPut("students")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("新增或更新租户学生档案")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantAdminStudentItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantAdminStudentItem>>> UpsertStudent(
|
||||
@@ -99,6 +106,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpPost("students/status")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("更新租户学生状态")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantAdminStudentStatusItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantAdminStudentStatusItem>>> UpdateStudentStatus(
|
||||
@@ -109,6 +117,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("student-notes")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("查询学生备注")]
|
||||
[ProducesResponseType<CatalogList<TenantAdminStudentNoteItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<TenantAdminStudentNoteItem>>> GetStudentNotes(
|
||||
@@ -119,6 +128,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpPut("student-notes")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("新增或更新学生备注")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantAdminStudentNoteItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantAdminStudentNoteItem>>> UpsertStudentNote(
|
||||
@@ -129,6 +139,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("student-followups")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("查询学生跟进")]
|
||||
[ProducesResponseType<CatalogList<TenantAdminStudentFollowupItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<TenantAdminStudentFollowupItem>>> GetStudentFollowups(
|
||||
@@ -139,6 +150,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpPut("student-followups")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("新增或更新学生跟进")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantAdminStudentFollowupItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantAdminStudentFollowupItem>>> UpsertStudentFollowup(
|
||||
@@ -149,6 +161,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("members")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStaffManage)]
|
||||
[EndpointSummary("查询租户成员")]
|
||||
[ProducesResponseType<CatalogList<TenantAdminMemberItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<TenantAdminMemberItem>>> GetMembers(
|
||||
@@ -159,6 +172,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpPut("members")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStaffManage)]
|
||||
[EndpointSummary("新增或更新租户成员")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantAdminMemberItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantAdminMemberItem>>> UpsertMember(
|
||||
@@ -169,6 +183,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpPost("members/disable")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStaffManage)]
|
||||
[EndpointSummary("停用租户成员并撤销会话")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantAdminMemberItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantAdminMemberItem>>> DisableMember(
|
||||
@@ -179,6 +194,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("audit-logs")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStaffManage)]
|
||||
[EndpointSummary("查询租户审计日志")]
|
||||
[ProducesResponseType<CatalogList<TenantAdminAuditLogItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<TenantAdminAuditLogItem>>> GetAuditLogs(
|
||||
@@ -188,45 +204,8 @@ public sealed class TenantAdminDirectController(
|
||||
return Ok(await tenantAdminService.GetAuditLogsAsync(ResolveActor(), query.ToFilter(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("permissions")]
|
||||
[EndpointSummary("查询租户后台权限矩阵")]
|
||||
[ProducesResponseType<TenantAdminPermissionMatrix>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantAdminPermissionMatrix>> GetPermissions(CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.GetPermissionMatrixAsync(ResolveActor(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("role-templates")]
|
||||
[EndpointSummary("查询租户角色模板")]
|
||||
[ProducesResponseType<CatalogList<TenantAdminRoleTemplateItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<TenantAdminRoleTemplateItem>>> GetRoleTemplates(
|
||||
[FromQuery] TenantAdminRoleTemplateQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.GetRoleTemplatesAsync(ResolveActor(), query.ToFilter(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("role-templates")]
|
||||
[EndpointSummary("新增或更新租户角色模板")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantAdminRoleTemplateItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantAdminRoleTemplateItem>>> UpsertRoleTemplate(
|
||||
UpsertTenantAdminRoleTemplateDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.UpsertRoleTemplateAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("role-templates/disable")]
|
||||
[EndpointSummary("停用租户角色模板")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantAdminRoleTemplateItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantAdminRoleTemplateItem>>> DisableRoleTemplate(
|
||||
DisableTenantAdminRoleTemplateDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.DisableRoleTemplateAsync(ResolveActor(), request.RoleTemplateId, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("branding")]
|
||||
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
|
||||
[EndpointSummary("更新租户品牌信息")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantBrandingItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantBrandingItem>>> UpsertBranding(
|
||||
@@ -237,6 +216,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpPut("settings")]
|
||||
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
|
||||
[EndpointSummary("更新租户公开设置与功能开关")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantSettingsItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantSettingsItem>>> UpsertSettings(
|
||||
@@ -247,6 +227,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("theme-templates")]
|
||||
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
|
||||
[EndpointSummary("查询可用租户主题模板")]
|
||||
[ProducesResponseType<CatalogList<TenantThemeTemplateItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<TenantThemeTemplateItem>>> GetThemeTemplates(CancellationToken cancellationToken)
|
||||
@@ -255,6 +236,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("theme")]
|
||||
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
|
||||
[EndpointSummary("查询租户当前主题与草稿")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantThemeItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantThemeItem>>> GetTheme(CancellationToken cancellationToken)
|
||||
@@ -263,6 +245,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpPost("theme/preview")]
|
||||
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
|
||||
[EndpointSummary("生成租户主题草稿")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantThemeItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantThemeItem>>> PreviewTheme(
|
||||
@@ -273,6 +256,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpPost("theme/publish")]
|
||||
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
|
||||
[EndpointSummary("发布租户主题")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantThemeItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantThemeItem>>> PublishTheme(
|
||||
@@ -283,6 +267,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("domains")]
|
||||
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
|
||||
[EndpointSummary("查询租户域名")]
|
||||
[ProducesResponseType<CatalogList<TenantDomainItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<TenantDomainItem>>> GetDomains(CancellationToken cancellationToken)
|
||||
@@ -291,6 +276,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpPost("domains")]
|
||||
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
|
||||
[EndpointSummary("添加租户域名")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantDomainItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantDomainItem>>> CreateDomain(
|
||||
@@ -301,6 +287,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("auth-providers")]
|
||||
[Authorize(Policy = BackendPermissions.TenantProviderManage)]
|
||||
[EndpointSummary("查询租户登录 Provider 公开配置")]
|
||||
[ProducesResponseType<CatalogList<TenantIdentityProviderItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<TenantIdentityProviderItem>>> GetAuthProviders(CancellationToken cancellationToken)
|
||||
@@ -309,6 +296,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpPut("auth-providers")]
|
||||
[Authorize(Policy = BackendPermissions.TenantProviderManage)]
|
||||
[EndpointSummary("新增或更新租户登录 Provider")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantIdentityProviderItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantIdentityProviderItem>>> UpsertAuthProvider(
|
||||
@@ -319,6 +307,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("badges")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("查询租户勋章")]
|
||||
[ProducesResponseType<CatalogList<TenantAdminBadgeItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<TenantAdminBadgeItem>>> GetBadges(
|
||||
@@ -329,6 +318,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpPut("badges")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("新增或更新租户勋章")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantAdminBadgeItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantAdminBadgeItem>>> UpsertBadge(
|
||||
@@ -339,6 +329,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("badge-grants")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("查询勋章发放记录")]
|
||||
[ProducesResponseType<CatalogList<TenantAdminBadgeGrantItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<TenantAdminBadgeGrantItem>>> GetBadgeGrants(
|
||||
@@ -349,6 +340,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpPost("badge-grants")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("向租户成员发放勋章")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantAdminBadgeGrantItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantAdminBadgeGrantItem>>> GrantBadge(
|
||||
@@ -359,6 +351,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("notifications")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("查询用户站内通知")]
|
||||
[ProducesResponseType<CatalogList<TenantAdminNotificationItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<TenantAdminNotificationItem>>> GetNotifications(
|
||||
@@ -369,6 +362,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpPut("notifications")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("新增或更新用户站内通知")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantAdminNotificationItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantAdminNotificationItem>>> UpsertNotification(
|
||||
@@ -379,6 +373,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("feedbacks")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("查询用户反馈")]
|
||||
[ProducesResponseType<CatalogList<TenantAdminFeedbackItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<TenantAdminFeedbackItem>>> GetFeedbacks(
|
||||
@@ -389,6 +384,7 @@ public sealed class TenantAdminDirectController(
|
||||
}
|
||||
|
||||
[HttpPost("feedbacks/status")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("处理用户反馈")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantAdminFeedbackItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantAdminFeedbackItem>>> 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<TenantRole>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IReadOnlyCollection<TenantPaymentProviderItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyCollection<TenantPaymentProviderItem>>> PaymentAccounts(
|
||||
@@ -30,6 +31,7 @@ public sealed class TenantCommerceController(
|
||||
}
|
||||
|
||||
[HttpPut("payment-accounts")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("新增或更新租户支付账号")]
|
||||
[ProducesResponseType<TenantPaymentProviderItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantPaymentProviderItem>> UpsertPaymentAccount(
|
||||
@@ -43,6 +45,7 @@ public sealed class TenantCommerceController(
|
||||
}
|
||||
|
||||
[HttpPut("secrets")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("写入或轮换租户密钥")]
|
||||
[ProducesResponseType<TenantSecretItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantSecretItem>> UpsertSecret(
|
||||
@@ -82,6 +85,7 @@ public sealed class TenantCommerceController(
|
||||
}
|
||||
|
||||
[HttpPost("code-batches")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("创建兑换码批次")]
|
||||
[ProducesResponseType<CodeBatchItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CodeBatchItem>> CreateCodeBatch(
|
||||
@@ -95,6 +99,7 @@ public sealed class TenantCommerceController(
|
||||
}
|
||||
|
||||
[HttpGet("activation-codes")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("查询兑换码")]
|
||||
[ProducesResponseType<ActivationCodeList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ActivationCodeList>> ActivationCodes(
|
||||
@@ -108,6 +113,7 @@ public sealed class TenantCommerceController(
|
||||
}
|
||||
|
||||
[HttpPost("activation-codes/redeem")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("后台核销兑换码")]
|
||||
[ProducesResponseType<ActivationCodeItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ActivationCodeItem>> RedeemActivationCode(
|
||||
@@ -121,6 +127,7 @@ public sealed class TenantCommerceController(
|
||||
}
|
||||
|
||||
[HttpGet("point-activity-tasks")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("查询积分活动任务")]
|
||||
[ProducesResponseType<TenantPointTaskList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantPointTaskList>> PointTasks(
|
||||
@@ -134,6 +141,7 @@ public sealed class TenantCommerceController(
|
||||
}
|
||||
|
||||
[HttpPut("point-activity-tasks")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("新增或更新积分活动任务")]
|
||||
[ProducesResponseType<object>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<object>> UpsertPointTask(
|
||||
@@ -147,6 +155,7 @@ public sealed class TenantCommerceController(
|
||||
}
|
||||
|
||||
[HttpGet("point-activity-claims")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("查询积分任务领取记录")]
|
||||
[ProducesResponseType<TenantPointClaimList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantPointClaimList>> PointClaims(
|
||||
@@ -160,6 +169,7 @@ public sealed class TenantCommerceController(
|
||||
}
|
||||
|
||||
[HttpGet("point-exchange-items")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("查询积分兑换项")]
|
||||
[ProducesResponseType<TenantPointExchangeItemList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantPointExchangeItemList>> PointExchangeItems(
|
||||
@@ -173,6 +183,7 @@ public sealed class TenantCommerceController(
|
||||
}
|
||||
|
||||
[HttpPut("point-exchange-items")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("新增或更新积分兑换项")]
|
||||
[ProducesResponseType<object>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<object>> UpsertPointExchangeItem(
|
||||
@@ -186,6 +197,7 @@ public sealed class TenantCommerceController(
|
||||
}
|
||||
|
||||
[HttpGet("point-exchange-orders")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("查询积分兑换订单")]
|
||||
[ProducesResponseType<TenantPointExchangeOrderList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantPointExchangeOrderList>> PointExchangeOrders(
|
||||
@@ -199,6 +211,7 @@ public sealed class TenantCommerceController(
|
||||
}
|
||||
|
||||
[HttpPost("point-exchange-orders/status")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("更新积分兑换订单状态")]
|
||||
[ProducesResponseType<object>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<object>> UpdatePointExchangeOrderStatus(
|
||||
@@ -212,6 +225,7 @@ public sealed class TenantCommerceController(
|
||||
}
|
||||
|
||||
[HttpGet("coupons")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("查询租户优惠券")]
|
||||
[ProducesResponseType<TenantCouponList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantCouponList>> Coupons(
|
||||
@@ -225,6 +239,7 @@ public sealed class TenantCommerceController(
|
||||
}
|
||||
|
||||
[HttpPut("coupons")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("新增或更新租户优惠券")]
|
||||
[ProducesResponseType<object>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<object>> UpsertCoupon(
|
||||
@@ -238,6 +253,7 @@ public sealed class TenantCommerceController(
|
||||
}
|
||||
|
||||
[HttpGet("coupons/redemptions")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("查询优惠券领取和核销记录")]
|
||||
[ProducesResponseType<TenantCouponRedemptionList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantCouponRedemptionList>> CouponRedemptions(
|
||||
@@ -251,6 +267,7 @@ public sealed class TenantCommerceController(
|
||||
}
|
||||
|
||||
[HttpGet("coupons/report")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("查询优惠券基础报表")]
|
||||
[ProducesResponseType<TenantCouponReport>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantCouponReport>> CouponReport(
|
||||
@@ -316,6 +333,7 @@ public sealed class TenantCommerceController(
|
||||
}
|
||||
|
||||
[HttpGet("reconciliation/batches")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("查询对账批次")]
|
||||
[ProducesResponseType<TenantReconciliationBatchList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantReconciliationBatchList>> ReconciliationBatches(
|
||||
@@ -329,6 +347,7 @@ public sealed class TenantCommerceController(
|
||||
}
|
||||
|
||||
[HttpPost("reconciliation/batches")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("创建对账批次")]
|
||||
[ProducesResponseType<CommerceReconciliationBatch>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CommerceReconciliationBatch>> CreateReconciliationBatch(
|
||||
@@ -342,6 +361,7 @@ public sealed class TenantCommerceController(
|
||||
}
|
||||
|
||||
[HttpGet("reconciliation/issues")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("查询对账异常")]
|
||||
[ProducesResponseType<TenantReconciliationIssueList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantReconciliationIssueList>> ReconciliationIssues(
|
||||
@@ -355,6 +375,7 @@ public sealed class TenantCommerceController(
|
||||
}
|
||||
|
||||
[HttpPost("reconciliation/issues/status")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("更新对账异常状态")]
|
||||
[ProducesResponseType<CommerceReconciliationIssue>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CommerceReconciliationIssue>> UpdateReconciliationIssue(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<ContentManagementResult<QuestionManagementItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<QuestionManagementItem>>> CreateQuestion(
|
||||
@@ -30,6 +31,7 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpPatch("questions")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[EndpointSummary("更新题目并可选择创建新版本")]
|
||||
[ProducesResponseType<ContentManagementResult<QuestionManagementItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<QuestionManagementItem>>> UpdateQuestion(
|
||||
@@ -60,6 +62,7 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("vocabulary-words")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[EndpointSummary("查询管理侧词汇")]
|
||||
[ProducesResponseType<CatalogList<VocabularyWord>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<VocabularyWord>>> GetVocabularyWords(
|
||||
@@ -70,6 +73,7 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpPut("vocabulary-words")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[EndpointSummary("新增或更新词汇")]
|
||||
[ProducesResponseType<ContentManagementResult<VocabularyWord>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<VocabularyWord>>> UpsertVocabularyWord(
|
||||
@@ -100,6 +104,7 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("handbook-chapters")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[EndpointSummary("查询管理侧知识手册章节")]
|
||||
[ProducesResponseType<CatalogList<HandbookChapter>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<HandbookChapter>>> GetHandbookChapters(
|
||||
@@ -110,6 +115,7 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpPut("handbook-chapters")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[EndpointSummary("新增或更新知识手册章节")]
|
||||
[ProducesResponseType<ContentManagementResult<HandbookChapter>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<HandbookChapter>>> UpsertHandbookChapter(
|
||||
@@ -120,6 +126,7 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("handbook-entries")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[EndpointSummary("查询管理侧知识手册条目")]
|
||||
[ProducesResponseType<CatalogList<HandbookEntry>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<HandbookEntry>>> GetHandbookEntries(
|
||||
@@ -130,6 +137,7 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpPut("handbook-entries")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[EndpointSummary("新增或更新知识手册条目")]
|
||||
[ProducesResponseType<ContentManagementResult<HandbookEntry>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<HandbookEntry>>> UpsertHandbookEntry(
|
||||
@@ -240,6 +248,7 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("videos")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[EndpointSummary("查询租户视频解析")]
|
||||
[ProducesResponseType<CatalogList<VideoManagementItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<VideoManagementItem>>> GetVideos(
|
||||
@@ -250,6 +259,7 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpPut("videos")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[EndpointSummary("新增或更新视频解析")]
|
||||
[ProducesResponseType<ContentManagementResult<VideoManagementItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<VideoManagementItem>>> UpsertVideo(
|
||||
@@ -260,6 +270,7 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpPost("question-videos")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[EndpointSummary("绑定题目与解析视频")]
|
||||
[ProducesResponseType<ContentManagementResult<QuestionVideoManagementItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<QuestionVideoManagementItem>>> BindQuestionVideo(
|
||||
@@ -270,6 +281,7 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("operations/{kind}")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[EndpointSummary("查询运营内容")]
|
||||
[ProducesResponseType<CatalogList<OperationContentItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<OperationContentItem>>> GetOperationContent(
|
||||
@@ -281,6 +293,7 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpPut("operations/{kind}")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[EndpointSummary("新增或更新运营内容")]
|
||||
[ProducesResponseType<ContentManagementResult<OperationContentItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<OperationContentItem>>> UpsertOperationContent(
|
||||
@@ -292,6 +305,7 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpPost("imports/preview/{importType}")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[EndpointSummary("预览内容导入数据")]
|
||||
[ProducesResponseType<SimpleImportResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SimpleImportResult>> PreviewImport(
|
||||
@@ -303,6 +317,7 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpPost("imports/{importType}")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[EndpointSummary("执行同步内容导入")]
|
||||
[ProducesResponseType<SimpleImportResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SimpleImportResult>> ExecuteImport(
|
||||
@@ -314,6 +329,7 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("imports/issues")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[EndpointSummary("查询内容导入问题明细")]
|
||||
[ProducesResponseType<CatalogList<ContentImportIssueModel>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<ContentImportIssueModel>>> GetImportIssues(
|
||||
@@ -324,6 +340,7 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpPost("imports/post-check")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[EndpointSummary("执行内容导入后完整性检查")]
|
||||
[ProducesResponseType<ImportPostCheckResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ImportPostCheckResult>> RunImportPostCheck(
|
||||
@@ -334,6 +351,7 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("imports/post-check")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[EndpointSummary("查询内容导入后检查状态")]
|
||||
[ProducesResponseType<ImportPostCheckResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ImportPostCheckResult>> GetImportPostCheck(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
/// <param name="TenantSlug">租户编码。</param>
|
||||
/// <param name="Status">租户状态。</param>
|
||||
/// <param name="Role">当前用户在租户内的角色。</param>
|
||||
/// <param name="Permissions">当前用户在租户内的权限扩展。</param>
|
||||
public sealed record CurrentTenantResponse(
|
||||
Guid TenantId,
|
||||
string TenantName,
|
||||
string TenantSlug,
|
||||
TenantStatus Status,
|
||||
TenantRole Role,
|
||||
JsonElement Permissions);
|
||||
TenantRole Role);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
106
Tiku.Api/Middleware/AuthRateLimitPartitionMiddleware.cs
Normal file
106
Tiku.Api/Middleware/AuthRateLimitPartitionMiddleware.cs
Normal file
@@ -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<EnableRateLimitingAttribute>()?
|
||||
.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}";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
33
Tiku.Api/Options/AuthRateLimitOptions.cs
Normal file
33
Tiku.Api/Options/AuthRateLimitOptions.cs
Normal file
@@ -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";
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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<ApiRateLimitOptions>() ?? new ApiRateLimitOptions();
|
||||
if (rateLimitOptions.Enabled)
|
||||
builder.Services.AddOptions<AuthRateLimitOptions>()
|
||||
.Bind(builder.Configuration.GetSection(AuthRateLimitOptions.SectionName))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
var authRateLimitOptions = builder.Configuration
|
||||
.GetSection(AuthRateLimitOptions.SectionName)
|
||||
.Get<AuthRateLimitOptions>() ?? 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, string>(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<DataProtectionKeyRingOptions>()
|
||||
.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<DataProtectionKeyRingOptions>() ?? 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<TikuDbContext>();
|
||||
var dataProtectionCertificate = dataProtectionOptions.LoadCertificate(requireProtectedDataProtectionKeys);
|
||||
if (dataProtectionCertificate is not null)
|
||||
{
|
||||
dataProtection.ProtectKeysWithCertificate(dataProtectionCertificate);
|
||||
}
|
||||
|
||||
builder.Services.Configure<ObjectStorageOptions>(
|
||||
builder.Configuration.GetSection(ObjectStorageOptions.SectionName));
|
||||
builder.Services.Configure<AliyunOssOptions>(
|
||||
@@ -215,6 +304,18 @@ try
|
||||
"Production tenant secret encryption cannot use the development master key.")
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddOptions<SmsSecurityOptions>()
|
||||
.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<JwtOptions>()
|
||||
.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<Microsoft.Extensions.Options.IOptions<TenantResolutionOptions>>().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<ITenantContext>();
|
||||
var tenantInitializer = context.HttpContext.RequestServices
|
||||
.GetRequiredService<ITenantContextInitializer>();
|
||||
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<IAuthSessionStore>();
|
||||
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<TikuDbContext>();
|
||||
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<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme)
|
||||
.Configure<IJwtKeyRing>((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<Microsoft.AspNetCore.Authorization.IAuthorizationMiddlewareResultHandler,
|
||||
AuditingAuthorizationMiddlewareResultHandler>();
|
||||
|
||||
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<TenantResolutionMiddleware>();
|
||||
app.UseAuthentication();
|
||||
if (rateLimitOptions.Enabled)
|
||||
{
|
||||
app.UseRateLimiter();
|
||||
}
|
||||
app.UseMiddleware<AuthRateLimitPartitionMiddleware>();
|
||||
app.UseRateLimiter();
|
||||
|
||||
app.UseMiddleware<CurrentPrincipalMiddleware>();
|
||||
app.UseAuthorization();
|
||||
|
||||
288
Tiku.Api/Security/AccessAuthorizationRequirements.cs
Normal file
288
Tiku.Api/Security/AccessAuthorizationRequirements.cs
Normal file
@@ -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<CurrentTenantMemberRequirement>
|
||||
{
|
||||
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<TenantPermissionRequirement>
|
||||
{
|
||||
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<CurrentPlatformAccessRequirement>
|
||||
{
|
||||
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<TenantResourceAccessRequirement, TenantResourceAuthorizationResource>
|
||||
{
|
||||
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<PlatformPermissionRequirement>
|
||||
{
|
||||
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<MfaRequirement>
|
||||
{
|
||||
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<AllDataScopeRequirement>
|
||||
{
|
||||
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<IAuthorizationHandler, CurrentAccessAuthorizationHandler>();
|
||||
services.AddScoped<IAuthorizationHandler, CurrentPlatformAccessAuthorizationHandler>();
|
||||
services.AddScoped<IAuthorizationHandler, TenantPermissionAuthorizationHandler>();
|
||||
services.AddScoped<IAuthorizationHandler, PlatformPermissionAuthorizationHandler>();
|
||||
services.AddSingleton<IAuthorizationHandler, MfaAuthorizationHandler>();
|
||||
services.AddScoped<IAuthorizationHandler, AllDataScopeAuthorizationHandler>();
|
||||
services.AddScoped<IAuthorizationHandler, TenantResourceAccessAuthorizationHandler>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<AuditingAuthorizationMiddlewareResultHandler> 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<TikuDbContext>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<string> 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));
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "*"
|
||||
|
||||
@@ -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(
|
||||
/// <param name="Phone">手机号。</param>
|
||||
/// <param name="Email">邮箱。</param>
|
||||
/// <param name="Name">用户显示名称。</param>
|
||||
/// <param name="Realm">当前令牌的 tenant 或 platform 授权域。</param>
|
||||
/// <param name="Tenant">当前登录租户成员摘要。</param>
|
||||
/// <param name="Tokens">认证令牌对。</param>
|
||||
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<string> 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);
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -2,19 +2,19 @@ namespace Tiku.Application.Auth;
|
||||
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<AuthenticatedUser> LoginWithPasswordAsync(
|
||||
Task<AuthenticationResult> LoginWithPasswordAsync(
|
||||
PasswordLoginRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AuthenticatedUser> LoginWithSmsAsync(
|
||||
Task<AuthenticationResult> LoginWithSmsAsync(
|
||||
SmsLoginRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AuthenticatedUser> LoginWithWechatWebAsync(
|
||||
Task<AuthenticationResult> LoginWithWechatWebAsync(
|
||||
WechatLoginRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AuthenticatedUser> LoginWithWechatMiniAppAsync(
|
||||
Task<AuthenticationResult> 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<MfaSetupResult> SetupTotpAsync(
|
||||
MfaChallengeRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<MfaConfirmResult> ConfirmTotpAsync(
|
||||
MfaChallengeRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AuthenticationResult> VerifyTotpAsync(
|
||||
MfaChallengeRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AuthenticationResult> ChangeRequiredPasswordAsync(
|
||||
PasswordChangeChallengeRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
49
Tiku.Application/Auth/IAuthSessionStore.cs
Normal file
49
Tiku.Application/Auth/IAuthSessionStore.cs
Normal file
@@ -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<AuthTokenPair> IssueAsync(
|
||||
AuthSessionIssueRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AuthTokenPair> RotateAsync(
|
||||
string refreshToken,
|
||||
string? ipAddress,
|
||||
string? userAgent,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AuthSessionValidationResult?> 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);
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace Tiku.Application.Auth;
|
||||
|
||||
public interface IPasswordHasher
|
||||
{
|
||||
string Hash(string password);
|
||||
bool Verify(string password, string passwordHash);
|
||||
}
|
||||
@@ -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<AuthTokenPair> 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);
|
||||
@@ -9,5 +9,7 @@ public interface ITokenService
|
||||
Guid sessionId,
|
||||
string? phone,
|
||||
string? email,
|
||||
TenantMembership membership);
|
||||
AuthRealm realm,
|
||||
Guid? tenantId,
|
||||
bool mfaSatisfied);
|
||||
}
|
||||
|
||||
23
Tiku.Application/Auth/SmsSecurityOptions.cs
Normal file
23
Tiku.Application/Auth/SmsSecurityOptions.cs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<BackofficePermissionItem> Permissions,
|
||||
IReadOnlyCollection<BackofficeMenuItem> Menus,
|
||||
IReadOnlyCollection<BackofficeRoleItem> Roles);
|
||||
|
||||
public sealed record BackofficeUiBootstrap(
|
||||
IReadOnlyCollection<string> PermissionCodes,
|
||||
IReadOnlyCollection<BackofficeMenuItem> Menus);
|
||||
|
||||
public sealed record BackofficePermissionItem(
|
||||
Guid Id,
|
||||
string Code,
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.Application.Backoffice;
|
||||
|
||||
public interface IBackofficeService
|
||||
{
|
||||
Task<BackofficeUiBootstrap> GetTenantUiBootstrapAsync(
|
||||
CurrentAccessSnapshot access,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<BackofficeUiBootstrap> GetPlatformUiBootstrapAsync(
|
||||
CurrentAccessSnapshot access,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<BackofficeBootstrap> GetTenantBootstrapAsync(
|
||||
BackofficeActor actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
64
Tiku.Application/Security/BackendPermissions.cs
Normal file
64
Tiku.Application/Security/BackendPermissions.cs
Normal file
@@ -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<string> Tenant = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
TenantDashboardView,
|
||||
TenantStaffManage,
|
||||
TenantRoleManage,
|
||||
TenantStudentManage,
|
||||
TenantContentManage,
|
||||
TenantSettingsManage,
|
||||
TenantProviderManage,
|
||||
TenantCommerceOperate,
|
||||
TenantCrmManage,
|
||||
TenantCommissionManage,
|
||||
TenantJobManage
|
||||
};
|
||||
|
||||
public static readonly IReadOnlySet<string> Platform = new HashSet<string>(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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
149
Tiku.Application/Security/ICurrentAccessContext.cs
Normal file
149
Tiku.Application/Security/ICurrentAccessContext.cs
Normal file
@@ -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<Guid> RegionIds,
|
||||
IReadOnlySet<Guid> ClassIds,
|
||||
bool IncludesSelf)
|
||||
{
|
||||
public static CurrentDataScope Self { get; } = new(
|
||||
DataScopeMode.Self,
|
||||
new HashSet<Guid>(),
|
||||
new HashSet<Guid>(),
|
||||
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<JsonElement> roleScopes)
|
||||
{
|
||||
var regionIds = new HashSet<Guid>();
|
||||
var classIds = new HashSet<Guid>();
|
||||
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<Guid>(), new HashSet<Guid>(), 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<Guid>(), new HashSet<Guid>(), 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<Guid> ReadGuids(JsonElement value, string propertyName)
|
||||
{
|
||||
var result = new HashSet<Guid>();
|
||||
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<string> TenantPermissions,
|
||||
IReadOnlySet<string> 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<CurrentAccessSnapshot> GetAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
9
Tiku.Application/Security/IJwtKeyRing.cs
Normal file
9
Tiku.Application/Security/IJwtKeyRing.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Tiku.Application.Security;
|
||||
|
||||
public interface IJwtKeyRing
|
||||
{
|
||||
SigningCredentials SigningCredentials { get; }
|
||||
IReadOnlyCollection<SecurityKey> ValidationKeys { get; }
|
||||
}
|
||||
@@ -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<string, string> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,25 +90,6 @@ public interface ITenantAdminDirectService
|
||||
TenantAdminAuditLogFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TenantAdminPermissionMatrix> GetPermissionMatrixAsync(
|
||||
TenantAdminActor actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CatalogList<TenantAdminRoleTemplateItem>> GetRoleTemplatesAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminRoleTemplateFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ContentManagementResult<TenantAdminRoleTemplateItem>> UpsertRoleTemplateAsync(
|
||||
TenantAdminActor actor,
|
||||
UpsertTenantAdminRoleTemplateCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ContentManagementResult<TenantAdminRoleTemplateItem>> DisableRoleTemplateAsync(
|
||||
TenantAdminActor actor,
|
||||
Guid roleTemplateId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ContentManagementResult<TenantBrandingItem>> UpsertBrandingAsync(
|
||||
TenantAdminActor actor,
|
||||
UpsertTenantBrandingCommand command,
|
||||
|
||||
@@ -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<TenantAdminPermissionCatalogItem> Permissions,
|
||||
IReadOnlyDictionary<string, IReadOnlyCollection<string>> 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,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -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<User> 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<TikuDbContext>();
|
||||
await dbContext.Database.MigrateAsync();
|
||||
|
||||
if (bootstrapOptions is not null)
|
||||
{
|
||||
var bootstrapper = ActivatorUtilities.CreateInstance<PlatformAdminBootstrapper>(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.");
|
||||
}
|
||||
|
||||
@@ -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<Guid>, 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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -6,4 +6,8 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Identity.Stores" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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<User> signInManager,
|
||||
UserManager<User> 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<AuthenticatedUser> LoginWithPasswordAsync(
|
||||
public async Task<AuthenticationResult> 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<AuthenticatedUser> LoginWithSmsAsync(
|
||||
public async Task<AuthenticationResult> 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<AuthenticatedUser> LoginWithWechatWebAsync(
|
||||
public Task<AuthenticationResult> LoginWithWechatWebAsync(
|
||||
WechatLoginRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -138,7 +152,7 @@ public sealed class AuthService(
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<AuthenticatedUser> LoginWithWechatMiniAppAsync(
|
||||
public Task<AuthenticationResult> 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<AuthenticatedUser> 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<MfaSetupResult> 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<MfaConfirmResult> 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<AuthenticationResult> 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<AuthenticationResult> 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<AuthenticationResult> 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<AuthChallenge> 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<AuthenticationResult> 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<AuthenticationResult> 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<AuthenticatedUser> LoginWithWechatAsync(
|
||||
private async Task<AuthenticationResult> LoginWithWechatAsync(
|
||||
WechatLoginRequest request,
|
||||
string provider,
|
||||
IReadOnlyList<string> providerAliases,
|
||||
Func<WechatProviderOptions, string, CancellationToken, Task<WechatIdentity>> 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<AuthenticationResult> 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<bool> 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<JsonElement>(identity.RawJson),
|
||||
updatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
return JsonSerializer.SerializeToElement(payload);
|
||||
}
|
||||
}
|
||||
|
||||
353
Tiku.Infrastructure/Auth/AuthSessionStore.cs
Normal file
353
Tiku.Infrastructure/Auth/AuthSessionStore.cs
Normal file
@@ -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<JwtOptions> 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<AuthTokenPair> 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<AuthTokenPair> 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<AuthSessionValidationResult?> 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<bool> 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<int> 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
59
Tiku.Infrastructure/Auth/JwtKeyRing.cs
Normal file
59
Tiku.Infrastructure/Auth/JwtKeyRing.cs
Normal file
@@ -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<RSA> keys = [];
|
||||
|
||||
public JwtKeyRing(IOptions<JwtOptions> 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<SecurityKey> { 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<SecurityKey> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<JwtOptions> 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<AuthTokenPair> 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SmsSecurityOptions> 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<SmsSendResult> 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<RateLimitSpec> 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<RateLimitSpec> BuildRateLimits(SendSmsCodeRequest request, string phone)
|
||||
{
|
||||
var limits = new List<RateLimitSpec>
|
||||
{
|
||||
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<bool> 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);
|
||||
}
|
||||
|
||||
@@ -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<JwtOptions> options) : ITokenService
|
||||
public sealed class TokenService(IOptions<JwtOptions> options, IJwtKeyRing keyRing) : ITokenService
|
||||
{
|
||||
private readonly JwtOptions options = options.Value;
|
||||
|
||||
@@ -18,17 +16,30 @@ public sealed class TokenService(IOptions<JwtOptions> 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<Claim>
|
||||
{
|
||||
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<JwtOptions> 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);
|
||||
}
|
||||
|
||||
@@ -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<BackofficeUiBootstrap> 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<BackofficeUiBootstrap> 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<BackofficeBootstrap> 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<BackofficeMenuItem[]> LoadEffectiveMenusAsync(
|
||||
BackendPermissionArea area,
|
||||
IReadOnlyCollection<string> 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(
|
||||
|
||||
161
Tiku.Infrastructure/Bootstrap/PlatformAdminBootstrapper.cs
Normal file
161
Tiku.Infrastructure/Bootstrap/PlatformAdminBootstrapper.cs
Normal file
@@ -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<User> userManager)
|
||||
{
|
||||
public const string SuperAdminRoleCode = "platform_super_admin";
|
||||
|
||||
public async Task<PlatformAdminBootstrapResult> 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;
|
||||
}
|
||||
@@ -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<IReadOnlyCollection<TenantPaymentProviderItem>> 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<CurrentDataScope> RequireDataScopeAsync(
|
||||
CommerceAdminActor actor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
return (await currentAccessContext.GetAsync(cancellationToken)).DataScope;
|
||||
}
|
||||
|
||||
private static TenantPaymentProviderItem ToPaymentAccountItem(TenantExternalProviderItem item) =>
|
||||
new(
|
||||
item.Id,
|
||||
|
||||
@@ -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<Subject>(actor.TenantId, command.SubjectId, "subject_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<Category>(actor.TenantId, command.CategoryId, "category_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<QuestionBank>(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<QuestionCollection>(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<ContentEntry>(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<ContentNode>(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<CurrentDataScope> 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<TEntity>(
|
||||
Guid tenantId,
|
||||
Guid? id,
|
||||
|
||||
@@ -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<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<ContentEntry>(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<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<School>(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<Region>(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<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<School>(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<Region>(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<Major>(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<CurrentDataScope> 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<TEntity?> ResolveByIdOrLegacyAsync<TEntity>(
|
||||
DbSet<TEntity> set,
|
||||
Guid tenantId,
|
||||
|
||||
@@ -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<TenantIsolationSaveChangesInterceptor>());
|
||||
});
|
||||
services.AddIdentityCore<User>(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<TikuDbContext>()
|
||||
.AddSignInManager()
|
||||
.AddDefaultTokenProviders();
|
||||
services.Configure<PasswordHasherOptions>(options => options.IterationCount = 210_000);
|
||||
services.AddScoped<ITenantDirectory, TenantDirectory>();
|
||||
services.AddMemoryCache();
|
||||
services.AddScoped<ITenantFrontendConfigService, TenantFrontendConfigService>();
|
||||
@@ -69,9 +87,10 @@ public static class DependencyInjection
|
||||
services.AddScoped<ITenantDomainLifecycleService, TenantDomainLifecycleService>();
|
||||
services.AddOptions<DomainLifecycleOptions>();
|
||||
services.AddSingleton<ITenantExecutionScope, TenantExecutionScope>();
|
||||
services.AddScoped<IPasswordHasher, PasswordHasher>();
|
||||
services.AddSingleton<IJwtKeyRing, JwtKeyRing>();
|
||||
services.AddScoped<ITokenService, TokenService>();
|
||||
services.AddScoped<ISessionService, SessionService>();
|
||||
services.AddScoped<AuthSessionStore>();
|
||||
services.AddScoped<IAuthSessionStore>(provider => provider.GetRequiredService<AuthSessionStore>());
|
||||
services.AddScoped<ISmsProvider, AliyunSmsProvider>();
|
||||
services.AddScoped<ISmsVerificationService, SmsVerificationService>();
|
||||
services.AddScoped<IWechatOAuthClient, WechatOAuthClient>();
|
||||
@@ -94,6 +113,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<ILearningActivityService, LearningActivityService>();
|
||||
services.AddScoped<ITenantAdminDirectService, TenantAdminDirectService>();
|
||||
services.AddScoped<IBackofficeService, BackofficeService>();
|
||||
services.AddScoped<ICurrentAccessContext, CurrentAccessContext>();
|
||||
services.AddScoped<IOperationAuditService, OperationAuditService>();
|
||||
services.AddScoped<IBackgroundJobService, BackgroundJobService>();
|
||||
services.AddScoped<ICommerceService, CommerceService>();
|
||||
|
||||
@@ -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<CommissionSettingsItem> 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<Dictionary<string, JsonElement>>(settings.Config.GetRawText()) ?? []
|
||||
: [];
|
||||
var memberRates = config.TryGetValue("memberRates", out var existingRates) && existingRates.ValueKind == JsonValueKind.Object
|
||||
? JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(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<JsonElement> 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<TenantCommissionSetting> 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)
|
||||
|
||||
@@ -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<string> 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");
|
||||
}
|
||||
|
||||
@@ -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<string> 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<ReferralTrackResult> 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,
|
||||
|
||||
@@ -9,22 +9,30 @@ internal sealed class UserConfiguration : IEntityTypeConfiguration<User>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<User> 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<UserI
|
||||
builder.Property(entity => 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<User>()
|
||||
.WithMany()
|
||||
|
||||
@@ -43,7 +43,6 @@ internal sealed class TenantMembershipConfiguration : IEntityTypeConfiguration<T
|
||||
|
||||
builder.Property(entity => 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<T
|
||||
.WithMany()
|
||||
.HasForeignKey(entity => entity.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<TenantRoleTemplate>()
|
||||
.WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.RoleTemplateId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ internal sealed class SmsVerificationCodeConfiguration : IEntityTypeConfiguratio
|
||||
public void Configure(EntityTypeBuilder<SmsVerificationCode> 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<Aut
|
||||
|
||||
builder.HasOne<Tenant>().WithMany()
|
||||
.HasForeignKey(entity => entity.TenantId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
builder.HasOne<User>().WithMany()
|
||||
.HasForeignKey(entity => entity.UserId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
@@ -107,18 +106,30 @@ internal sealed class AuthSessionConfiguration : IEntityTypeConfiguration<AuthSe
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AuthSession> 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<Tenant>().WithMany()
|
||||
.HasForeignKey(entity => entity.TenantId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne<User>().WithMany()
|
||||
.HasForeignKey(entity => entity.UserId)
|
||||
@@ -126,6 +137,29 @@ internal sealed class AuthSessionConfiguration : IEntityTypeConfiguration<AuthSe
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AuthChallengeConfiguration : IEntityTypeConfiguration<AuthChallenge>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AuthChallenge> 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<User>().WithMany().HasForeignKey(entity => entity.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<Tenant>().WithMany().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SmsSendRateLimitConfiguration : IEntityTypeConfiguration<SmsSendRateLimit>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SmsSendRateLimit> builder)
|
||||
@@ -154,32 +188,6 @@ internal sealed class SmsSendRateLimitConfiguration : IEntityTypeConfiguration<S
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TenantRoleTemplateConfiguration : IEntityTypeConfiguration<TenantRoleTemplate>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TenantRoleTemplate> 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<User>().WithMany()
|
||||
.HasForeignKey(entity => entity.CreatedBy)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
builder.HasOne<User>().WithMany()
|
||||
.HasForeignKey(entity => entity.UpdatedBy)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TenantClassConfiguration : IEntityTypeConfiguration<TenantClass>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TenantClass> builder)
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
@@ -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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("FriendlyName")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("friendly_name");
|
||||
|
||||
b.Property<string>("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<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("claim_type");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("claim_value");
|
||||
|
||||
b.Property<Guid>("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<System.Guid>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("login_provider");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("provider_key");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("provider_display_name");
|
||||
|
||||
b.Property<Guid>("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<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("login_provider");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
@@ -7918,11 +8022,21 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("access_failed_count");
|
||||
|
||||
b.Property<string>("AvatarUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)")
|
||||
.HasColumnName("avatar_url");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("concurrency_stamp");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
@@ -7934,6 +8048,14 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("citext")
|
||||
.HasColumnName("email");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("email_confirmed");
|
||||
|
||||
b.Property<bool>("ForcePasswordChange")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("force_password_change");
|
||||
|
||||
b.Property<DateTimeOffset?>("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<string>("LegacyPasswordHash")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasColumnName("legacy_password_hash");
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("lockout_enabled");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("lockout_end");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<bool>("PasswordMigrationRequired")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("password_migration_required");
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(320)
|
||||
.HasColumnType("character varying(320)")
|
||||
.HasColumnName("normalized_email");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("normalized_user_name");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("password_hash");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("phone");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("phone_number");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("phone_number_confirmed");
|
||||
|
||||
b.Property<string>("PrimaryRole")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
@@ -7978,36 +8123,50 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("score");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("security_stamp");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("two_factor_enabled");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Username")
|
||||
b.Property<string>("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<JsonElement>("SecretPayload")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("secret_payload")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset?>("ConsumedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("consumed_at");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
b.Property<string>("IpAddress")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("ip_address");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("provider");
|
||||
|
||||
b.Property<string>("Purpose")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("purpose");
|
||||
|
||||
b.Property<string>("Realm")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("realm");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("security_stamp");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("token_hash");
|
||||
|
||||
b.Property<string>("UserAgent")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("user_agent");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
@@ -12755,20 +12997,53 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnName("metadata")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<bool>("MfaSatisfied")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("mfa_satisfied");
|
||||
|
||||
b.Property<Guid?>("ParentSessionId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("parent_session_id");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("provider");
|
||||
|
||||
b.Property<string>("Realm")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("realm");
|
||||
|
||||
b.Property<Guid?>("ReplacedBySessionId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("replaced_by_session_id");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("revoked_at");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
b.Property<string>("RevokedReason")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("revoked_reason");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("security_stamp");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<Guid>("TokenFamilyId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("token_family_id");
|
||||
|
||||
b.Property<string>("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<JsonElement>("Permissions")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("permissions")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("role");
|
||||
|
||||
b.Property<Guid?>("RoleTemplateId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("role_template_id");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("BaseRole")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("base_role");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("code");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("created_by");
|
||||
|
||||
b.Property<JsonElement>("DataScope")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("data_scope")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<JsonElement>("FieldPermissions")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("field_permissions")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<bool>("IsSystem")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_system");
|
||||
|
||||
b.Property<JsonElement>("MenuPermissions")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("menu_permissions")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<JsonElement>("ModulePermissions")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("module_permissions")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<JsonElement>("Permissions")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("permissions")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<Guid?>("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<Guid>("Id")
|
||||
@@ -14156,6 +14301,36 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("tenant_student_notes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", 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<System.Guid>", 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<System.Guid>", 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 =>
|
||||
@@ -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<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
friendly_name = table.Column<string>(type: "text", nullable: true),
|
||||
xml = table.Column<string>(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<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
legacy_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
username = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
email = table.Column<string>(type: "citext", maxLength: 320, nullable: true),
|
||||
phone = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
avatar_url = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
primary_role = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
score = table.Column<int>(type: "integer", nullable: false),
|
||||
last_seen_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
legacy_password_hash = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
|
||||
password_migration_required = table.Column<bool>(type: "boolean", nullable: false),
|
||||
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
force_password_change = table.Column<bool>(type: "boolean", nullable: false),
|
||||
raw_profile = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
user_name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
normalized_user_name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
email = table.Column<string>(type: "citext", maxLength: 320, nullable: true),
|
||||
normalized_email = table.Column<string>(type: "character varying(320)", maxLength: 320, nullable: true),
|
||||
email_confirmed = table.Column<bool>(type: "boolean", nullable: false),
|
||||
password_hash = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||
security_stamp = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
concurrency_stamp = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
phone_number = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||
phone_number_confirmed = table.Column<bool>(type: "boolean", nullable: false),
|
||||
two_factor_enabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||
lockout_end = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
lockout_enabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||
access_failed_count = table.Column<int>(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<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
claim_type = table.Column<string>(type: "text", nullable: true),
|
||||
claim_value = table.Column<string>(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<string>(type: "character varying(255)", maxLength: 255, nullable: true),
|
||||
phone = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||
email = table.Column<string>(type: "citext", maxLength: 320, nullable: true),
|
||||
secret_payload = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(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<string>(type: "text", nullable: false),
|
||||
provider_key = table.Column<string>(type: "text", nullable: false),
|
||||
provider_display_name = table.Column<string>(type: "text", nullable: true),
|
||||
user_id = table.Column<Guid>(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<Guid>(type: "uuid", nullable: false),
|
||||
login_provider = table.Column<string>(type: "text", nullable: false),
|
||||
name = table.Column<string>(type: "text", nullable: false),
|
||||
value = table.Column<string>(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<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
realm = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
purpose = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
token_hash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
security_stamp = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
provider = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
expires_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
consumed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
ip_address = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
user_agent = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||
created_at = table.Column<DateTimeOffset>(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<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
realm = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
token_family_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
parent_session_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
replaced_by_session_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
token_hash = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
security_stamp = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
mfa_satisfied = table.Column<bool>(type: "boolean", nullable: false),
|
||||
provider = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
expires_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
revoked_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
revoked_reason = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
ip_address = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
user_agent = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(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<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
description = table.Column<string>(type: "text", nullable: true),
|
||||
base_role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
permissions = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
menu_permissions = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
module_permissions = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
field_permissions = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
data_scope = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
is_system = table.Column<bool>(type: "boolean", nullable: false),
|
||||
sort_order = table.Column<int>(type: "integer", nullable: false),
|
||||
created_by = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
updated_by = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
legacy_role = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(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<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
role_template_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
permissions = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
legacy_role = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(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");
|
||||
|
||||
@@ -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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("FriendlyName")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("friendly_name");
|
||||
|
||||
b.Property<string>("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<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("claim_type");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("claim_value");
|
||||
|
||||
b.Property<Guid>("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<System.Guid>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("login_provider");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("provider_key");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("provider_display_name");
|
||||
|
||||
b.Property<Guid>("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<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("login_provider");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
@@ -7915,11 +8019,21 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("access_failed_count");
|
||||
|
||||
b.Property<string>("AvatarUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)")
|
||||
.HasColumnName("avatar_url");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("concurrency_stamp");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
@@ -7931,6 +8045,14 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("citext")
|
||||
.HasColumnName("email");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("email_confirmed");
|
||||
|
||||
b.Property<bool>("ForcePasswordChange")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("force_password_change");
|
||||
|
||||
b.Property<DateTimeOffset?>("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<string>("LegacyPasswordHash")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasColumnName("legacy_password_hash");
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("lockout_enabled");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("lockout_end");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<bool>("PasswordMigrationRequired")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("password_migration_required");
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(320)
|
||||
.HasColumnType("character varying(320)")
|
||||
.HasColumnName("normalized_email");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("normalized_user_name");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("password_hash");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("phone");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("phone_number");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("phone_number_confirmed");
|
||||
|
||||
b.Property<string>("PrimaryRole")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
@@ -7975,36 +8120,50 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("score");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("security_stamp");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("two_factor_enabled");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Username")
|
||||
b.Property<string>("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<JsonElement>("SecretPayload")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("secret_payload")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset?>("ConsumedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("consumed_at");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
b.Property<string>("IpAddress")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("ip_address");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("provider");
|
||||
|
||||
b.Property<string>("Purpose")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("purpose");
|
||||
|
||||
b.Property<string>("Realm")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("realm");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("security_stamp");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("token_hash");
|
||||
|
||||
b.Property<string>("UserAgent")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("user_agent");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
@@ -12752,20 +12994,53 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnName("metadata")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<bool>("MfaSatisfied")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("mfa_satisfied");
|
||||
|
||||
b.Property<Guid?>("ParentSessionId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("parent_session_id");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("provider");
|
||||
|
||||
b.Property<string>("Realm")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("realm");
|
||||
|
||||
b.Property<Guid?>("ReplacedBySessionId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("replaced_by_session_id");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("revoked_at");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
b.Property<string>("RevokedReason")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("revoked_reason");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("security_stamp");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<Guid>("TokenFamilyId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("token_family_id");
|
||||
|
||||
b.Property<string>("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<JsonElement>("Permissions")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("permissions")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("role");
|
||||
|
||||
b.Property<Guid?>("RoleTemplateId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("role_template_id");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("BaseRole")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("base_role");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("code");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("created_by");
|
||||
|
||||
b.Property<JsonElement>("DataScope")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("data_scope")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<JsonElement>("FieldPermissions")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("field_permissions")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<bool>("IsSystem")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_system");
|
||||
|
||||
b.Property<JsonElement>("MenuPermissions")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("menu_permissions")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<JsonElement>("ModulePermissions")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("module_permissions")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<JsonElement>("Permissions")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("permissions")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<Guid?>("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<Guid>("Id")
|
||||
@@ -14153,6 +14298,36 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("tenant_student_notes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", 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<System.Guid>", 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<System.Guid>", 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 =>
|
||||
|
||||
@@ -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<TikuDbContext> options,
|
||||
ITenantContext tenantContext) : DbContext(options)
|
||||
ITenantContext tenantContext) : IdentityUserContext<User, Guid>(options), IDataProtectionKeyContext
|
||||
{
|
||||
public TikuDbContext(DbContextOptions<TikuDbContext> options)
|
||||
: this(options, CreateToolingTenantContext())
|
||||
@@ -37,7 +40,8 @@ public sealed class TikuDbContext(
|
||||
return context;
|
||||
}
|
||||
public DbSet<Tenant> Tenants => Set<Tenant>();
|
||||
public DbSet<User> Users => Set<User>();
|
||||
public new DbSet<User> Users => Set<User>();
|
||||
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
|
||||
public DbSet<UserIdentity> UserIdentities => Set<UserIdentity>();
|
||||
public DbSet<TenantMembership> TenantMemberships => Set<TenantMembership>();
|
||||
public DbSet<TenantDomain> TenantDomains => Set<TenantDomain>();
|
||||
@@ -49,8 +53,8 @@ public sealed class TikuDbContext(
|
||||
public DbSet<SmsVerificationCode> SmsVerificationCodes => Set<SmsVerificationCode>();
|
||||
public DbSet<AuthLoginEvent> AuthLoginEvents => Set<AuthLoginEvent>();
|
||||
public DbSet<AuthSession> AuthSessions => Set<AuthSession>();
|
||||
public DbSet<AuthChallenge> AuthChallenges => Set<AuthChallenge>();
|
||||
public DbSet<SmsSendRateLimit> SmsSendRateLimits => Set<SmsSendRateLimit>();
|
||||
public DbSet<TenantRoleTemplate> TenantRoleTemplates => Set<TenantRoleTemplate>();
|
||||
public DbSet<TenantClass> TenantClasses => Set<TenantClass>();
|
||||
public DbSet<TenantClassMember> TenantClassMembers => Set<TenantClassMember>();
|
||||
public DbSet<TenantStudentNote> TenantStudentNotes => Set<TenantStudentNote>();
|
||||
@@ -186,9 +190,14 @@ public sealed class TikuDbContext(
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
modelBuilder.Entity<IdentityUserClaim<Guid>>().ToTable("user_claims");
|
||||
modelBuilder.Entity<IdentityUserLogin<Guid>>().ToTable("user_logins");
|
||||
modelBuilder.Entity<IdentityUserToken<Guid>>().ToTable("user_tokens");
|
||||
modelBuilder.HasPostgresExtension("citext");
|
||||
modelBuilder.HasPostgresExtension("ltree");
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(TikuDbContext).Assembly);
|
||||
modelBuilder.Entity<DataProtectionKey>().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<User>().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<IHasTimestamps>())
|
||||
{
|
||||
if (entry.State == EntityState.Added)
|
||||
|
||||
@@ -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,
|
||||
|
||||
141
Tiku.Infrastructure/Security/CurrentAccessContext.cs
Normal file
141
Tiku.Infrastructure/Security/CurrentAccessContext.cs
Normal file
@@ -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<CurrentAccessSnapshot>? snapshotTask;
|
||||
|
||||
public Task<CurrentAccessSnapshot> 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<CurrentAccessSnapshot> 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<string>(StringComparer.Ordinal),
|
||||
new HashSet<string>(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<string>(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<string>(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<string>(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<HashSet<string>> 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<string>(StringComparer.Ordinal),
|
||||
new HashSet<string>(StringComparer.Ordinal),
|
||||
CurrentDataScope.Self);
|
||||
}
|
||||
}
|
||||
62
Tiku.Infrastructure/Security/DataProtectionKeyRingOptions.cs
Normal file
62
Tiku.Infrastructure/Security/DataProtectionKeyRingOptions.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
47
Tiku.Infrastructure/Security/DataScopeQueryableExtensions.cs
Normal file
47
Tiku.Infrastructure/Security/DataScopeQueryableExtensions.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using System.Linq.Expressions;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Security;
|
||||
|
||||
internal static class DataScopeQueryableExtensions
|
||||
{
|
||||
public static IQueryable<TEntity> ApplyDataScope<TEntity>(
|
||||
this IQueryable<TEntity> query,
|
||||
CurrentDataScope scope,
|
||||
Expression<Func<TEntity, bool>>? selfPredicate,
|
||||
Expression<Func<TEntity, bool>>? restrictedPredicate)
|
||||
{
|
||||
if (scope.Mode == DataScopeMode.All)
|
||||
{
|
||||
return query;
|
||||
}
|
||||
|
||||
Expression<Func<TEntity, bool>>? 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<Func<TEntity, bool>> OrElse<TEntity>(
|
||||
Expression<Func<TEntity, bool>> left,
|
||||
Expression<Func<TEntity, bool>> 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<Func<TEntity, bool>>(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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,8 @@
|
||||
<PackageReference Include="AlipaySDKNet.Standard" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
|
||||
@@ -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<ApiProgramMarker>
|
||||
IDomainGatewayProvisioner? domainGatewayProvisioner = null,
|
||||
ISmsProvider? smsProvider = null,
|
||||
IReadOnlyDictionary<string, string?>? configurationOverrides = null) : WebApplicationFactory<ApiProgramMarker>
|
||||
{
|
||||
private readonly PostgresTestDatabase database = PostgresTestDatabase.Create();
|
||||
|
||||
protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
var values = new Dictionary<string, string?>
|
||||
{
|
||||
["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<TenantIsolationSaveChangesInterceptor>());
|
||||
});
|
||||
services.RemoveAll<IJwtKeyRing>();
|
||||
services.AddSingleton<IJwtKeyRing, TestJwtKeyRing>();
|
||||
|
||||
if (wechatOAuthClient is not null)
|
||||
{
|
||||
@@ -85,6 +110,12 @@ public sealed class ApiTestFactory(
|
||||
services.RemoveAll<IDomainGatewayProvisioner>();
|
||||
services.AddSingleton(domainGatewayProvisioner);
|
||||
}
|
||||
|
||||
if (smsProvider is not null)
|
||||
{
|
||||
services.RemoveAll<ISmsProvider>();
|
||||
services.AddSingleton(smsProvider);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -96,6 +127,91 @@ public sealed class ApiTestFactory(
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
dbContext.AddRange(entities);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var backendMembers = entities
|
||||
.OfType<TenantMembership>()
|
||||
.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<Guid> 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<object>
|
||||
{
|
||||
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<ITenantContextInitializer>()
|
||||
.InitializeSystem(resolvedTenantId, "Integration test session lookup");
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
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)
|
||||
|
||||
@@ -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<JsonDocument> 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;
|
||||
|
||||
@@ -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<JsonDocument> 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; }
|
||||
|
||||
@@ -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<TikuDbContext>();
|
||||
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<string, string?>
|
||||
{
|
||||
["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<TikuDbContext>();
|
||||
var smsOptions = scope.ServiceProvider.GetRequiredService<IOptions<SmsSecurityOptions>>().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<SmsProviderSendResult> SendAsync(
|
||||
SmsProviderSendRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
SendCount++;
|
||||
Code = request.Code;
|
||||
return Task.FromResult(new SmsProviderSendResult("test", "sent", "sms-message-id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
169
Tiku.IntegrationTests/Api/AuthMfaLifecycleTests.cs
Normal file
169
Tiku.IntegrationTests/Api/AuthMfaLifecycleTests.cs
Normal file
@@ -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<TikuDbContext>();
|
||||
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<HttpPostAttribute>();
|
||||
|
||||
Assert.NotNull(attribute);
|
||||
Assert.Equal(route, attribute.Template);
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument> 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);
|
||||
}
|
||||
}
|
||||
188
Tiku.IntegrationTests/Api/AuthRateLimitPolicyTests.cs
Normal file
188
Tiku.IntegrationTests/Api/AuthRateLimitPolicyTests.cs
Normal file
@@ -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<string, string?>
|
||||
{
|
||||
[$"{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<AuthRateLimitOptions>();
|
||||
|
||||
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<ValidationResult>();
|
||||
|
||||
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<EnableRateLimitingAttribute>();
|
||||
|
||||
Assert.NotNull(attribute);
|
||||
Assert.Equal(expectedPolicy, attribute.PolicyName);
|
||||
}
|
||||
|
||||
private static async Task<string> 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<string>(partition);
|
||||
}
|
||||
}
|
||||
287
Tiku.IntegrationTests/Api/AuthSessionLifecycleTests.cs
Normal file
287
Tiku.IntegrationTests/Api/AuthSessionLifecycleTests.cs
Normal file
@@ -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<IAuthSessionStore>()
|
||||
.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<TikuDbContext>();
|
||||
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<SessionRevokedException>(() =>
|
||||
scope.ServiceProvider.GetRequiredService<IAuthSessionStore>()
|
||||
.RotateAsync(original.RefreshToken, null, null));
|
||||
}
|
||||
|
||||
using (var scope = factory.CreateSystemScope("Verify refresh family revocation"))
|
||||
{
|
||||
var store = scope.ServiceProvider.GetRequiredService<IAuthSessionStore>();
|
||||
var validation = await store.ValidateAccessSessionAsync(
|
||||
rotatedLocator.SessionId,
|
||||
seed.UserId,
|
||||
AuthRealm.Tenant,
|
||||
seed.TenantId);
|
||||
Assert.Null(validation);
|
||||
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
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<IAuthSessionStore>(),
|
||||
original.RefreshToken);
|
||||
var second = TryRotateAsync(
|
||||
secondScope.ServiceProvider.GetRequiredService<IAuthSessionStore>(),
|
||||
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<TikuDbContext>();
|
||||
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<TikuDbContext>();
|
||||
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<IAuthSessionStore>()
|
||||
.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<TikuDbContext>();
|
||||
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<IAuthSessionStore>()
|
||||
.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<TikuDbContext>();
|
||||
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<IAuthSessionStore>();
|
||||
Assert.Null(await store.ValidateAccessSessionAsync(
|
||||
locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId));
|
||||
await Assert.ThrowsAsync<SessionRevokedException>(() =>
|
||||
store.RotateAsync(tokens.RefreshToken, null, null));
|
||||
}
|
||||
|
||||
using (var scope = factory.CreateSystemScope("Verify revoked backend family"))
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
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<IAuthSessionStore>()
|
||||
.TryParseRefreshToken(refreshToken, out locator);
|
||||
}
|
||||
|
||||
private static async Task<AuthTokenPair?> TryRotateAsync(IAuthSessionStore store, string refreshToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await store.RotateAsync(refreshToken, null, null);
|
||||
}
|
||||
catch (SessionRevokedException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<AuthTokenPair> IssueAsync(
|
||||
ApiTestFactory factory,
|
||||
SessionSeed seed,
|
||||
bool mfaSatisfied = false)
|
||||
{
|
||||
using var scope = factory.CreateSystemScope("Issue authentication session");
|
||||
return await scope.ServiceProvider.GetRequiredService<IAuthSessionStore>().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<SessionSeed> 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);
|
||||
}
|
||||
183
Tiku.IntegrationTests/Api/AuthenticationTestClientExtensions.cs
Normal file
183
Tiku.IntegrationTests/Api/AuthenticationTestClientExtensions.cs
Normal file
@@ -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<string, string> AuthenticatorKeys = new(StringComparer.Ordinal);
|
||||
|
||||
public static async Task<TestAuthenticationTokens> 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<TestAuthenticationTokens> 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<JsonDocument> 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<byte> 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;
|
||||
}
|
||||
}
|
||||
91
Tiku.IntegrationTests/Api/BackofficeUiBootstrapTests.cs
Normal file
91
Tiku.IntegrationTests/Api/BackofficeUiBootstrapTests.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<object>
|
||||
{
|
||||
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<Claim> 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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Guid> 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<TikuDbContext>();
|
||||
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<JsonDocument> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user