forked from gongxuegit/tiku-backend.net
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.OpenApi" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" 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.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" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<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.Hosting" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Http" 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.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.OSS.V2" Version="0.2.0" />
|
||||||
<PackageVersion Include="AlibabaCloud.SDK.Dysmsapi20170525" Version="4.4.0" />
|
<PackageVersion Include="AlibabaCloud.SDK.Dysmsapi20170525" Version="4.4.0" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
<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-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-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)
|
- [`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 负责权限策略。
|
- ASP.NET Authorization 负责权限策略。
|
||||||
- JWT + 数据库 `auth_sessions` 负责 access/refresh/session 闭环。
|
- JWT + 数据库 `auth_sessions` 负责 access/refresh/session 闭环。
|
||||||
- `ICurrentUser` / 只读 `ITenantContext` 统一当前用户和请求租户上下文。
|
- `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 组合约束共同阻断跨租户读写。
|
- EF Core Query Filter、写入拦截器和 PostgreSQL 组合约束共同阻断跨租户读写。
|
||||||
- PostgreSQL FK / unique / check / index 负责数据完整性底线。
|
- PostgreSQL FK / unique / check / index 负责数据完整性底线。
|
||||||
- 审计事件表记录关键行为。
|
- 审计事件表记录关键行为。
|
||||||
@@ -147,7 +148,7 @@ Tiku.IntegrationTests # API / EF 模型集成测试
|
|||||||
数据库模型迁移已经完成到 greenfield 初始 schema:
|
数据库模型迁移已经完成到 greenfield 初始 schema:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.cs
|
Tiku.Infrastructure/Persistence/Migrations/20260728031410_InitialSchema.cs
|
||||||
```
|
```
|
||||||
|
|
||||||
当前 EF 模型覆盖:
|
当前 EF 模型覆盖:
|
||||||
@@ -171,12 +172,14 @@ Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.cs
|
|||||||
|
|
||||||
已完成:
|
已完成:
|
||||||
|
|
||||||
- JWT Bearer 认证。
|
- ASP.NET Core Identity 密码、锁定、SecurityStamp、强制改密、TOTP 和恢复码。
|
||||||
- 数据库 Session 校验;登出/撤销后旧 token 会被拒绝。
|
- 带 `kid` 的 RSA JWT Bearer 认证和旧公钥轮换验证。
|
||||||
- 手机号 + 密码登录。
|
- tenant/platform 双 realm 与 Host、tenant claim、数据库 Session 联合校验。
|
||||||
- 短信验证码登录。
|
- Session family 原子 refresh、重放撤销、logout 和 logout-all。
|
||||||
- 微信网页 OAuth 登录。
|
- 手机号/邮箱/用户名 + 密码登录、短信验证码登录和安全短信发送入口。
|
||||||
- 微信小程序登录。
|
- 微信网页 OAuth 和微信小程序登录,外部身份不保存 `session_key`。
|
||||||
|
- 数据库 tenant/platform RBAC、MFA policy、资源型授权和 DataScope SQL。
|
||||||
|
- 按有效权限生成 tenant/platform UI 菜单 bootstrap;菜单不作为 API 授权依据。
|
||||||
- 租户级身份 Provider 配置解析。
|
- 租户级身份 Provider 配置解析。
|
||||||
- 当前用户 `/api/me`。
|
- 当前用户 `/api/me`。
|
||||||
- 当前租户 `/api/tenants/current`。
|
- 当前租户 `/api/tenants/current`。
|
||||||
@@ -184,6 +187,8 @@ Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.cs
|
|||||||
- Host 解析的前端运行时配置 `/api/runtime/bootstrap`。
|
- Host 解析的前端运行时配置 `/api/runtime/bootstrap`。
|
||||||
- 统一异常响应和请求日志。
|
- 统一异常响应和请求日志。
|
||||||
|
|
||||||
|
完整安全策略、Host 判定矩阵和登录/Session 文字流程图见 [`docs/architecture/authentication-authorization-security.md`](docs/architecture/authentication-authorization-security.md)。
|
||||||
|
|
||||||
### 已迁移 API
|
### 已迁移 API
|
||||||
|
|
||||||
2026-07-27 运行时 OpenAPI 基线包含 192 个路径、237 个操作。已完成的业务/API 闭环包括:
|
2026-07-27 运行时 OpenAPI 基线包含 192 个路径、237 个操作。已完成的业务/API 闭环包括:
|
||||||
@@ -250,3 +255,14 @@ dotnet ef migrations script \
|
|||||||
--project Tiku.Infrastructure \
|
--project Tiku.Infrastructure \
|
||||||
--startup-project Tiku.DbMigrator
|
--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;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using Tiku.Application.Auth;
|
using Tiku.Application.Auth;
|
||||||
|
using Tiku.Domain.Tenancy;
|
||||||
|
|
||||||
namespace Tiku.Api.Contracts;
|
namespace Tiku.Api.Contracts;
|
||||||
|
|
||||||
@@ -9,6 +10,8 @@ namespace Tiku.Api.Contracts;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PasswordLoginDto
|
public sealed class PasswordLoginDto
|
||||||
{
|
{
|
||||||
|
[Required]
|
||||||
|
public AuthRealm? Realm { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略。
|
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -17,18 +20,21 @@ public sealed class PasswordLoginDto
|
|||||||
public string? TenantCode { get; set; }
|
public string? TenantCode { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 手机号,建议前端提交规范化后的中国大陆手机号。
|
/// 账号标识。tenant 可使用手机号,platform 可使用邮箱或用户名。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Required]
|
[StringLength(320)]
|
||||||
|
[Description("账号标识。tenant 可使用手机号,platform 可使用邮箱或用户名。")]
|
||||||
|
public string? Identifier { get; set; }
|
||||||
|
|
||||||
[StringLength(32)]
|
[StringLength(32)]
|
||||||
[Description("手机号,建议前端提交规范化后的中国大陆手机号。")]
|
[Description("兼容手机号字段;新客户端应使用 identifier。")]
|
||||||
public string Phone { get; set; } = string.Empty;
|
public string? Phone { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 用户密码。
|
/// 用户密码。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Required]
|
[Required]
|
||||||
[StringLength(128, MinimumLength = 6)]
|
[StringLength(128, MinimumLength = 10)]
|
||||||
[Description("用户密码。")]
|
[Description("用户密码。")]
|
||||||
public string Password { get; set; } = string.Empty;
|
public string Password { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
@@ -38,6 +44,8 @@ public sealed class PasswordLoginDto
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SmsLoginDto
|
public sealed class SmsLoginDto
|
||||||
{
|
{
|
||||||
|
[Required]
|
||||||
|
public AuthRealm? Realm { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略。
|
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -62,11 +70,29 @@ public sealed class SmsLoginDto
|
|||||||
public string Code { get; set; } = string.Empty;
|
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>
|
/// <summary>
|
||||||
/// OAuth code 登录请求。
|
/// OAuth code 登录请求。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class OAuthCodeDto
|
public sealed class OAuthCodeDto
|
||||||
{
|
{
|
||||||
|
[Required]
|
||||||
|
public AuthRealm? Realm { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略。
|
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -135,10 +161,12 @@ public sealed class AuthenticatedUserDto
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Name { get; init; }
|
public string? Name { get; init; }
|
||||||
|
|
||||||
|
public AuthRealm Realm { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 当前登录租户成员摘要。
|
/// 当前登录租户成员摘要。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public TenantMembershipSummary Tenant { get; init; } = default!;
|
public TenantMembershipSummary? Tenant { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// access token 和 refresh token。
|
/// access token 和 refresh token。
|
||||||
@@ -153,8 +181,52 @@ public sealed class AuthenticatedUserDto
|
|||||||
Phone = user.Phone,
|
Phone = user.Phone,
|
||||||
Email = user.Email,
|
Email = user.Email,
|
||||||
Name = user.Name,
|
Name = user.Name,
|
||||||
|
Realm = user.Realm,
|
||||||
Tenant = user.Tenant,
|
Tenant = user.Tenant,
|
||||||
Tokens = user.Tokens
|
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 sealed class TenantAdminBadgeQueryDto
|
||||||
{
|
{
|
||||||
public string? Category { get; set; }
|
public string? Category { get; set; }
|
||||||
@@ -324,13 +311,11 @@ public sealed class UpsertTenantAdminMemberDto
|
|||||||
public TenantAdminUserLookupDto User { get; set; } = new();
|
public TenantAdminUserLookupDto User { get; set; } = new();
|
||||||
public string? Role { get; set; }
|
public string? Role { get; set; }
|
||||||
public string? Status { 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 string? PrimaryRole { get; set; }
|
||||||
|
|
||||||
public UpsertTenantAdminMemberCommand ToCommand()
|
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 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 sealed class UpsertTenantBrandingDto
|
||||||
{
|
{
|
||||||
public required string BrandName { get; set; }
|
public required string BrandName { get; set; }
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using Tiku.Application.Auth;
|
using Tiku.Application.Auth;
|
||||||
using Tiku.Api.Contracts;
|
using Tiku.Api.Contracts;
|
||||||
|
using Tiku.Api.Options;
|
||||||
using Tiku.Application.Security;
|
using Tiku.Application.Security;
|
||||||
using Tiku.Application.Tenancy;
|
using Tiku.Application.Tenancy;
|
||||||
using Tiku.Infrastructure.Content;
|
using Tiku.Infrastructure.Content;
|
||||||
|
using Tiku.Domain.Tenancy;
|
||||||
|
|
||||||
namespace Tiku.Api.Controllers;
|
namespace Tiku.Api.Controllers;
|
||||||
|
|
||||||
@@ -13,97 +17,144 @@ namespace Tiku.Api.Controllers;
|
|||||||
[Produces("application/json")]
|
[Produces("application/json")]
|
||||||
public sealed class AuthController(
|
public sealed class AuthController(
|
||||||
IAuthService authService,
|
IAuthService authService,
|
||||||
ISessionService sessionService,
|
ISmsVerificationService smsVerificationService,
|
||||||
|
IAuthSessionStore sessionStore,
|
||||||
ITenantContext tenantContext,
|
ITenantContext tenantContext,
|
||||||
ITenantContextInitializer tenantContextInitializer,
|
ITenantContextInitializer tenantContextInitializer,
|
||||||
ITenantDirectory tenantDirectory) : ControllerBase
|
ITenantDirectory tenantDirectory,
|
||||||
|
ICurrentUser currentUser,
|
||||||
|
IOptions<TenantResolutionOptions> tenantResolutionOptions) : ControllerBase
|
||||||
{
|
{
|
||||||
[AllowAnonymous]
|
[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")]
|
[HttpPost("login/password")]
|
||||||
[EndpointSummary("手机号密码登录")]
|
[EndpointSummary("手机号密码登录")]
|
||||||
[EndpointDescription("使用本地手机号和密码登录,签发 JWT access token 与数据库 refresh/session。")]
|
[EndpointDescription("使用本地手机号和密码登录,签发 JWT access token 与数据库 refresh/session。")]
|
||||||
[ProducesResponseType<AuthenticatedUserDto>(StatusCodes.Status200OK)]
|
[ProducesResponseType<AuthenticationResultDto>(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
|
||||||
public async Task<ActionResult<AuthenticatedUserDto>> LoginWithPassword(
|
public async Task<ActionResult<AuthenticationResultDto>> LoginWithPassword(
|
||||||
[FromBody] PasswordLoginDto request,
|
[FromBody] PasswordLoginDto request,
|
||||||
CancellationToken cancellationToken)
|
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(
|
var result = await authService.LoginWithPasswordAsync(
|
||||||
new PasswordLoginRequest(
|
new PasswordLoginRequest(
|
||||||
await ResolveTenantIdAsync(request.TenantCode, cancellationToken),
|
realm,
|
||||||
request.Phone,
|
await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||||||
|
identifier,
|
||||||
request.Password,
|
request.Password,
|
||||||
GetIpAddress(),
|
GetIpAddress(),
|
||||||
Request.Headers.UserAgent.ToString()),
|
Request.Headers.UserAgent.ToString()),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
return Ok(AuthenticatedUserDto.FromApplication(result));
|
return Ok(AuthenticationResultDto.FromApplication(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost("login/sms")]
|
[HttpPost("login/sms")]
|
||||||
[EndpointSummary("短信验证码登录")]
|
[EndpointSummary("短信验证码登录")]
|
||||||
[EndpointDescription("校验已发送的登录用途短信验证码,成功后签发 JWT access token 与数据库 refresh/session。")]
|
[EndpointDescription("校验已发送的登录用途短信验证码,成功后签发 JWT access token 与数据库 refresh/session。")]
|
||||||
[ProducesResponseType<AuthenticatedUserDto>(StatusCodes.Status200OK)]
|
[ProducesResponseType<AuthenticationResultDto>(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
|
||||||
public async Task<ActionResult<AuthenticatedUserDto>> LoginWithSms(
|
public async Task<ActionResult<AuthenticationResultDto>> LoginWithSms(
|
||||||
[FromBody] SmsLoginDto request,
|
[FromBody] SmsLoginDto request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var realm = request.Realm!.Value;
|
||||||
var result = await authService.LoginWithSmsAsync(
|
var result = await authService.LoginWithSmsAsync(
|
||||||
new SmsLoginRequest(
|
new SmsLoginRequest(
|
||||||
await ResolveTenantIdAsync(request.TenantCode, cancellationToken),
|
realm,
|
||||||
|
await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||||||
request.Phone,
|
request.Phone,
|
||||||
request.Code,
|
request.Code,
|
||||||
GetIpAddress(),
|
GetIpAddress(),
|
||||||
Request.Headers.UserAgent.ToString()),
|
Request.Headers.UserAgent.ToString()),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
return Ok(AuthenticatedUserDto.FromApplication(result));
|
return Ok(AuthenticationResultDto.FromApplication(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost("oauth/wechat")]
|
[HttpPost("oauth/wechat")]
|
||||||
[EndpointSummary("微信网页 OAuth 登录")]
|
[EndpointSummary("微信网页 OAuth 登录")]
|
||||||
[EndpointDescription("使用微信网页授权 code 换取 openid/unionid,upsert 用户身份并创建应用会话。")]
|
[EndpointDescription("使用微信网页授权 code 换取 openid/unionid,upsert 用户身份并创建应用会话。")]
|
||||||
[ProducesResponseType<AuthenticatedUserDto>(StatusCodes.Status200OK)]
|
[ProducesResponseType<AuthenticationResultDto>(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
|
||||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status503ServiceUnavailable)]
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status503ServiceUnavailable)]
|
||||||
public async Task<ActionResult<AuthenticatedUserDto>> LoginWithWechatWeb(
|
public async Task<ActionResult<AuthenticationResultDto>> LoginWithWechatWeb(
|
||||||
[FromBody] OAuthCodeDto request,
|
[FromBody] OAuthCodeDto request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var realm = request.Realm!.Value;
|
||||||
var result = await authService.LoginWithWechatWebAsync(
|
var result = await authService.LoginWithWechatWebAsync(
|
||||||
new WechatLoginRequest(
|
new WechatLoginRequest(
|
||||||
await ResolveTenantIdAsync(request.TenantCode, cancellationToken),
|
realm,
|
||||||
|
await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||||||
request.Code,
|
request.Code,
|
||||||
GetIpAddress(),
|
GetIpAddress(),
|
||||||
Request.Headers.UserAgent.ToString()),
|
Request.Headers.UserAgent.ToString()),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
return Ok(AuthenticatedUserDto.FromApplication(result));
|
return Ok(AuthenticationResultDto.FromApplication(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost("oauth/wechat-miniapp")]
|
[HttpPost("oauth/wechat-miniapp")]
|
||||||
[EndpointSummary("微信小程序登录")]
|
[EndpointSummary("微信小程序登录")]
|
||||||
[EndpointDescription("使用小程序 wx.login 返回的 code 换取 openid/session_key,upsert 用户身份并创建应用会话。")]
|
[EndpointDescription("使用小程序 wx.login 返回的 code 换取 openid/session_key,upsert 用户身份并创建应用会话。")]
|
||||||
[ProducesResponseType<AuthenticatedUserDto>(StatusCodes.Status200OK)]
|
[ProducesResponseType<AuthenticationResultDto>(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
|
||||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status503ServiceUnavailable)]
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status503ServiceUnavailable)]
|
||||||
public async Task<ActionResult<AuthenticatedUserDto>> LoginWithWechatMiniApp(
|
public async Task<ActionResult<AuthenticationResultDto>> LoginWithWechatMiniApp(
|
||||||
[FromBody] OAuthCodeDto request,
|
[FromBody] OAuthCodeDto request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var realm = request.Realm!.Value;
|
||||||
var result = await authService.LoginWithWechatMiniAppAsync(
|
var result = await authService.LoginWithWechatMiniAppAsync(
|
||||||
new WechatLoginRequest(
|
new WechatLoginRequest(
|
||||||
await ResolveTenantIdAsync(request.TenantCode, cancellationToken),
|
realm,
|
||||||
|
await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||||||
request.Code,
|
request.Code,
|
||||||
GetIpAddress(),
|
GetIpAddress(),
|
||||||
Request.Headers.UserAgent.ToString()),
|
Request.Headers.UserAgent.ToString()),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
return Ok(AuthenticatedUserDto.FromApplication(result));
|
return Ok(AuthenticationResultDto.FromApplication(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
@@ -142,14 +193,135 @@ public sealed class AuthController(
|
|||||||
return NoContent();
|
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)
|
private void ResolveRefreshTokenTenant(string refreshToken)
|
||||||
{
|
{
|
||||||
if (!sessionService.TryParseRefreshToken(refreshToken, out var locator))
|
if (!sessionStore.TryParseRefreshToken(refreshToken, out var locator))
|
||||||
{
|
{
|
||||||
return;
|
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()
|
private string? GetIpAddress()
|
||||||
@@ -157,8 +329,22 @@ public sealed class AuthController(
|
|||||||
return HttpContext.Connection.RemoteIpAddress?.ToString();
|
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 (tenantContext.TenantId.HasValue)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(tenantCode) &&
|
if (!string.IsNullOrWhiteSpace(tenantCode) &&
|
||||||
@@ -189,4 +375,17 @@ public sealed class AuthController(
|
|||||||
TenantResolutionSource.TenantCode);
|
TenantResolutionSource.TenantCode);
|
||||||
return tenant.TenantId;
|
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]
|
[ApiController]
|
||||||
[Route("api/backoffice/tenant/jobs")]
|
[Route("api/backoffice/tenant/jobs")]
|
||||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
[Authorize(Policy = BackendPermissions.TenantJobManage)]
|
||||||
public sealed class BackgroundJobsController(
|
public sealed class BackgroundJobsController(
|
||||||
IBackgroundJobService backgroundJobService,
|
IBackgroundJobService backgroundJobService,
|
||||||
ITenantContext tenantContext) : ControllerBase
|
ITenantContext tenantContext) : ControllerBase
|
||||||
|
|||||||
@@ -10,113 +10,117 @@ namespace Tiku.Api.Controllers;
|
|||||||
[Route("api/backoffice")]
|
[Route("api/backoffice")]
|
||||||
public sealed class BackofficeController(
|
public sealed class BackofficeController(
|
||||||
IBackofficeService backofficeService,
|
IBackofficeService backofficeService,
|
||||||
ICurrentUser currentUser,
|
ICurrentAccessContext currentAccessContext) : ControllerBase
|
||||||
ITenantContext tenantContext) : 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")]
|
[HttpGet("tenant/bootstrap")]
|
||||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
|
||||||
[ProducesResponseType<BackofficeBootstrap>(StatusCodes.Status200OK)]
|
[ProducesResponseType<BackofficeBootstrap>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<BackofficeBootstrap>> GetTenantBootstrap(CancellationToken cancellationToken)
|
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")]
|
[HttpPost("tenant/roles")]
|
||||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
|
||||||
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<BackofficeRoleItem>> UpsertTenantRole(
|
public async Task<ActionResult<BackofficeRoleItem>> UpsertTenantRole(
|
||||||
UpsertBackofficeRoleDto request,
|
UpsertBackofficeRoleDto request,
|
||||||
CancellationToken cancellationToken)
|
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")]
|
[HttpPut("tenant/roles/{roleId:guid}/bindings")]
|
||||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
|
||||||
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<BackofficeRoleItem>> ReplaceTenantRoleBindings(
|
public async Task<ActionResult<BackofficeRoleItem>> ReplaceTenantRoleBindings(
|
||||||
Guid roleId,
|
Guid roleId,
|
||||||
ReplaceRoleBindingsDto request,
|
ReplaceRoleBindingsDto request,
|
||||||
CancellationToken cancellationToken)
|
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")]
|
[HttpPut("tenant/users/{userId:guid}/roles")]
|
||||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
public async Task<IActionResult> ReplaceTenantUserRoles(
|
public async Task<IActionResult> ReplaceTenantUserRoles(
|
||||||
Guid userId,
|
Guid userId,
|
||||||
ReplaceUserRolesDto request,
|
ReplaceUserRolesDto request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await backofficeService.ReplaceTenantUserRolesAsync(ResolveTenantActor(), request.ToCommand(userId), cancellationToken);
|
await backofficeService.ReplaceTenantUserRolesAsync(await ResolveTenantActorAsync(cancellationToken), request.ToCommand(userId), cancellationToken);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("platform/bootstrap")]
|
[HttpGet("platform/bootstrap")]
|
||||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
|
||||||
[ProducesResponseType<BackofficeBootstrap>(StatusCodes.Status200OK)]
|
[ProducesResponseType<BackofficeBootstrap>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<BackofficeBootstrap>> GetPlatformBootstrap(CancellationToken cancellationToken)
|
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")]
|
[HttpPost("platform/roles")]
|
||||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
|
||||||
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<BackofficeRoleItem>> UpsertPlatformRole(
|
public async Task<ActionResult<BackofficeRoleItem>> UpsertPlatformRole(
|
||||||
UpsertBackofficeRoleDto request,
|
UpsertBackofficeRoleDto request,
|
||||||
CancellationToken cancellationToken)
|
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")]
|
[HttpPut("platform/roles/{roleId:guid}/bindings")]
|
||||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
|
||||||
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<BackofficeRoleItem>> ReplacePlatformRoleBindings(
|
public async Task<ActionResult<BackofficeRoleItem>> ReplacePlatformRoleBindings(
|
||||||
Guid roleId,
|
Guid roleId,
|
||||||
ReplaceRoleBindingsDto request,
|
ReplaceRoleBindingsDto request,
|
||||||
CancellationToken cancellationToken)
|
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")]
|
[HttpPut("platform/users/{userId:guid}/roles")]
|
||||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
public async Task<IActionResult> ReplacePlatformUserRoles(
|
public async Task<IActionResult> ReplacePlatformUserRoles(
|
||||||
Guid userId,
|
Guid userId,
|
||||||
ReplaceUserRolesDto request,
|
ReplaceUserRolesDto request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await backofficeService.ReplacePlatformUserRolesAsync(ResolvePlatformActor(), request.ToCommand(userId), cancellationToken);
|
await backofficeService.ReplacePlatformUserRolesAsync(await ResolvePlatformActorAsync(cancellationToken), request.ToCommand(userId), cancellationToken);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
private BackofficeActor ResolveTenantActor()
|
private async Task<BackofficeActor> ResolveTenantActorAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (currentUser.UserId is not { } userId || tenantContext.TenantId is not { } tenantId)
|
return BackofficeActor.FromTenantAccess(await currentAccessContext.GetAsync(cancellationToken));
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Tenant backoffice actor was not resolved.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return new BackofficeActor(userId, tenantId, IsPlatformAdmin());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private BackofficeActor ResolvePlatformActor()
|
private async Task<BackofficeActor> ResolvePlatformActorAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (currentUser.UserId is not { } userId)
|
return BackofficeActor.FromPlatformAccess(await currentAccessContext.GetAsync(cancellationToken));
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ using Tiku.Application.Security;
|
|||||||
namespace Tiku.Api.Controllers;
|
namespace Tiku.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
[Authorize(Policy = BackendPermissions.TenantCommissionManage)]
|
||||||
[Produces("application/json")]
|
[Produces("application/json")]
|
||||||
[Route("api/commission")]
|
[Route("api/commission")]
|
||||||
public sealed class CommissionController(
|
public sealed class CommissionController(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ using Tiku.Application.Security;
|
|||||||
namespace Tiku.Api.Controllers;
|
namespace Tiku.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
|
||||||
[Produces("application/json")]
|
[Produces("application/json")]
|
||||||
[Route("api/crm")]
|
[Route("api/crm")]
|
||||||
public sealed class CrmController(
|
public sealed class CrmController(
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ public sealed class ReferralController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("stats")]
|
[HttpGet("stats")]
|
||||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
|
||||||
[EndpointSummary("查询推荐人个人统计")]
|
[EndpointSummary("查询推荐人个人统计")]
|
||||||
[ProducesResponseType<ReferralStatsItem>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ReferralStatsItem>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ReferralStatsItem>> Stats(
|
public async Task<ActionResult<ReferralStatsItem>> Stats(
|
||||||
@@ -109,7 +109,7 @@ public sealed class ReferralController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("sales-stats")]
|
[HttpGet("sales-stats")]
|
||||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
|
||||||
[EndpointSummary("查询销售推荐统计排行")]
|
[EndpointSummary("查询销售推荐统计排行")]
|
||||||
[ProducesResponseType<ReferralList<ReferralStatsItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ReferralList<ReferralStatsItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ReferralList<ReferralStatsItem>>> SalesStats(
|
public async Task<ActionResult<ReferralList<ReferralStatsItem>>> SalesStats(
|
||||||
@@ -123,7 +123,7 @@ public sealed class ReferralController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("conversion-report")]
|
[HttpGet("conversion-report")]
|
||||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
|
||||||
[EndpointSummary("查询推荐转化报告")]
|
[EndpointSummary("查询推荐转化报告")]
|
||||||
[ProducesResponseType<ReferralConversionReport>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ReferralConversionReport>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ReferralConversionReport>> ConversionReport(
|
public async Task<ActionResult<ReferralConversionReport>> ConversionReport(
|
||||||
@@ -137,7 +137,7 @@ public sealed class ReferralController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("sales-clients")]
|
[HttpGet("sales-clients")]
|
||||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
|
||||||
[EndpointSummary("查询推荐人名下客户")]
|
[EndpointSummary("查询推荐人名下客户")]
|
||||||
[ProducesResponseType<ReferralList<ReferralLeadItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ReferralList<ReferralLeadItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ReferralList<ReferralLeadItem>>> SalesClients(
|
public async Task<ActionResult<ReferralList<ReferralLeadItem>>> SalesClients(
|
||||||
@@ -151,7 +151,7 @@ public sealed class ReferralController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("manual-bind")]
|
[HttpPost("manual-bind")]
|
||||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
|
||||||
[EndpointSummary("人工调整学生推荐归属")]
|
[EndpointSummary("人工调整学生推荐归属")]
|
||||||
[ProducesResponseType<ReferralBindResult>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ReferralBindResult>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ReferralBindResult>> ManualBind(
|
public async Task<ActionResult<ReferralBindResult>> ManualBind(
|
||||||
@@ -165,7 +165,7 @@ public sealed class ReferralController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("team")]
|
[HttpGet("team")]
|
||||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
|
||||||
[EndpointSummary("查询推荐团队成员")]
|
[EndpointSummary("查询推荐团队成员")]
|
||||||
[ProducesResponseType<ReferralList<ReferralTeamItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ReferralList<ReferralTeamItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ReferralList<ReferralTeamItem>>> Team(
|
public async Task<ActionResult<ReferralList<ReferralTeamItem>>> Team(
|
||||||
@@ -179,7 +179,7 @@ public sealed class ReferralController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("team")]
|
[HttpPut("team")]
|
||||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
|
||||||
[EndpointSummary("新增或更新推荐团队关系")]
|
[EndpointSummary("新增或更新推荐团队关系")]
|
||||||
[ProducesResponseType<ReferralTeamItem>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ReferralTeamItem>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ReferralTeamItem>> UpsertTeam(
|
public async Task<ActionResult<ReferralTeamItem>> UpsertTeam(
|
||||||
|
|||||||
@@ -28,8 +28,7 @@ public sealed class SecurityDiagnosticsController(
|
|||||||
{
|
{
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
currentTenant.TenantId,
|
currentTenant.TenantId
|
||||||
currentUser.TenantRole
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,8 +38,7 @@ public sealed class SecurityDiagnosticsController(
|
|||||||
{
|
{
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
currentTenant.TenantId,
|
currentTenant.TenantId
|
||||||
currentUser.TenantRole
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ public sealed class TaxonomyController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
[Authorize(Policy = BackendPermissions.TenantContentManage)]
|
||||||
public Task<TaxonomyNodeItem> Create(
|
public Task<TaxonomyNodeItem> Create(
|
||||||
CreateTaxonomyNodeDto request,
|
CreateTaxonomyNodeDto request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ using Tiku.Domain.Tenancy;
|
|||||||
namespace Tiku.Api.Controllers;
|
namespace Tiku.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
|
||||||
[Produces("application/json")]
|
[Produces("application/json")]
|
||||||
[Route("api/tenant-admin")]
|
[Route("api/tenant-admin")]
|
||||||
public sealed class TenantAdminDirectController(
|
public sealed class TenantAdminDirectController(
|
||||||
@@ -19,6 +18,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
ITenantContext currentTenant) : ControllerBase
|
ITenantContext currentTenant) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("classes")]
|
[HttpGet("classes")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||||
[EndpointSummary("查询租户班级")]
|
[EndpointSummary("查询租户班级")]
|
||||||
[ProducesResponseType<TenantAdminClassList>(StatusCodes.Status200OK)]
|
[ProducesResponseType<TenantAdminClassList>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<TenantAdminClassList>> GetClasses(
|
public async Task<ActionResult<TenantAdminClassList>> GetClasses(
|
||||||
@@ -29,6 +29,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("classes")]
|
[HttpPut("classes")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||||
[EndpointSummary("新增或更新租户班级")]
|
[EndpointSummary("新增或更新租户班级")]
|
||||||
[ProducesResponseType<ContentManagementResult<TenantAdminClassItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<TenantAdminClassItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<TenantAdminClassItem>>> UpsertClass(
|
public async Task<ActionResult<ContentManagementResult<TenantAdminClassItem>>> UpsertClass(
|
||||||
@@ -39,6 +40,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("classes/disable")]
|
[HttpPost("classes/disable")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||||
[EndpointSummary("停用租户班级")]
|
[EndpointSummary("停用租户班级")]
|
||||||
[ProducesResponseType<ContentManagementResult<TenantAdminClassItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<TenantAdminClassItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<TenantAdminClassItem>>> DisableClass(
|
public async Task<ActionResult<ContentManagementResult<TenantAdminClassItem>>> DisableClass(
|
||||||
@@ -49,6 +51,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("classes/members")]
|
[HttpGet("classes/members")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||||
[EndpointSummary("查询班级成员")]
|
[EndpointSummary("查询班级成员")]
|
||||||
[ProducesResponseType<CatalogList<TenantAdminClassMemberItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<CatalogList<TenantAdminClassMemberItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<CatalogList<TenantAdminClassMemberItem>>> GetClassMembers(
|
public async Task<ActionResult<CatalogList<TenantAdminClassMemberItem>>> GetClassMembers(
|
||||||
@@ -59,6 +62,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("classes/members")]
|
[HttpPut("classes/members")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||||
[EndpointSummary("新增或更新班级成员")]
|
[EndpointSummary("新增或更新班级成员")]
|
||||||
[ProducesResponseType<ContentManagementResult<TenantAdminClassMemberItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<TenantAdminClassMemberItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<TenantAdminClassMemberItem>>> UpsertClassMember(
|
public async Task<ActionResult<ContentManagementResult<TenantAdminClassMemberItem>>> UpsertClassMember(
|
||||||
@@ -69,6 +73,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("classes/members/remove")]
|
[HttpPost("classes/members/remove")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||||
[EndpointSummary("移除班级成员")]
|
[EndpointSummary("移除班级成员")]
|
||||||
[ProducesResponseType<ContentManagementResult<TenantAdminClassMemberItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<TenantAdminClassMemberItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<TenantAdminClassMemberItem>>> RemoveClassMember(
|
public async Task<ActionResult<ContentManagementResult<TenantAdminClassMemberItem>>> RemoveClassMember(
|
||||||
@@ -79,6 +84,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("students")]
|
[HttpGet("students")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||||
[EndpointSummary("查询租户学生")]
|
[EndpointSummary("查询租户学生")]
|
||||||
[ProducesResponseType<TenantAdminStudentList>(StatusCodes.Status200OK)]
|
[ProducesResponseType<TenantAdminStudentList>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<TenantAdminStudentList>> GetStudents(
|
public async Task<ActionResult<TenantAdminStudentList>> GetStudents(
|
||||||
@@ -89,6 +95,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("students")]
|
[HttpPut("students")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||||
[EndpointSummary("新增或更新租户学生档案")]
|
[EndpointSummary("新增或更新租户学生档案")]
|
||||||
[ProducesResponseType<ContentManagementResult<TenantAdminStudentItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<TenantAdminStudentItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<TenantAdminStudentItem>>> UpsertStudent(
|
public async Task<ActionResult<ContentManagementResult<TenantAdminStudentItem>>> UpsertStudent(
|
||||||
@@ -99,6 +106,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("students/status")]
|
[HttpPost("students/status")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||||
[EndpointSummary("更新租户学生状态")]
|
[EndpointSummary("更新租户学生状态")]
|
||||||
[ProducesResponseType<ContentManagementResult<TenantAdminStudentStatusItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<TenantAdminStudentStatusItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<TenantAdminStudentStatusItem>>> UpdateStudentStatus(
|
public async Task<ActionResult<ContentManagementResult<TenantAdminStudentStatusItem>>> UpdateStudentStatus(
|
||||||
@@ -109,6 +117,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("student-notes")]
|
[HttpGet("student-notes")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||||
[EndpointSummary("查询学生备注")]
|
[EndpointSummary("查询学生备注")]
|
||||||
[ProducesResponseType<CatalogList<TenantAdminStudentNoteItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<CatalogList<TenantAdminStudentNoteItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<CatalogList<TenantAdminStudentNoteItem>>> GetStudentNotes(
|
public async Task<ActionResult<CatalogList<TenantAdminStudentNoteItem>>> GetStudentNotes(
|
||||||
@@ -119,6 +128,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("student-notes")]
|
[HttpPut("student-notes")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||||
[EndpointSummary("新增或更新学生备注")]
|
[EndpointSummary("新增或更新学生备注")]
|
||||||
[ProducesResponseType<ContentManagementResult<TenantAdminStudentNoteItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<TenantAdminStudentNoteItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<TenantAdminStudentNoteItem>>> UpsertStudentNote(
|
public async Task<ActionResult<ContentManagementResult<TenantAdminStudentNoteItem>>> UpsertStudentNote(
|
||||||
@@ -129,6 +139,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("student-followups")]
|
[HttpGet("student-followups")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||||
[EndpointSummary("查询学生跟进")]
|
[EndpointSummary("查询学生跟进")]
|
||||||
[ProducesResponseType<CatalogList<TenantAdminStudentFollowupItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<CatalogList<TenantAdminStudentFollowupItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<CatalogList<TenantAdminStudentFollowupItem>>> GetStudentFollowups(
|
public async Task<ActionResult<CatalogList<TenantAdminStudentFollowupItem>>> GetStudentFollowups(
|
||||||
@@ -139,6 +150,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("student-followups")]
|
[HttpPut("student-followups")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||||
[EndpointSummary("新增或更新学生跟进")]
|
[EndpointSummary("新增或更新学生跟进")]
|
||||||
[ProducesResponseType<ContentManagementResult<TenantAdminStudentFollowupItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<TenantAdminStudentFollowupItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<TenantAdminStudentFollowupItem>>> UpsertStudentFollowup(
|
public async Task<ActionResult<ContentManagementResult<TenantAdminStudentFollowupItem>>> UpsertStudentFollowup(
|
||||||
@@ -149,6 +161,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("members")]
|
[HttpGet("members")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantStaffManage)]
|
||||||
[EndpointSummary("查询租户成员")]
|
[EndpointSummary("查询租户成员")]
|
||||||
[ProducesResponseType<CatalogList<TenantAdminMemberItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<CatalogList<TenantAdminMemberItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<CatalogList<TenantAdminMemberItem>>> GetMembers(
|
public async Task<ActionResult<CatalogList<TenantAdminMemberItem>>> GetMembers(
|
||||||
@@ -159,6 +172,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("members")]
|
[HttpPut("members")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantStaffManage)]
|
||||||
[EndpointSummary("新增或更新租户成员")]
|
[EndpointSummary("新增或更新租户成员")]
|
||||||
[ProducesResponseType<ContentManagementResult<TenantAdminMemberItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<TenantAdminMemberItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<TenantAdminMemberItem>>> UpsertMember(
|
public async Task<ActionResult<ContentManagementResult<TenantAdminMemberItem>>> UpsertMember(
|
||||||
@@ -169,6 +183,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("members/disable")]
|
[HttpPost("members/disable")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantStaffManage)]
|
||||||
[EndpointSummary("停用租户成员并撤销会话")]
|
[EndpointSummary("停用租户成员并撤销会话")]
|
||||||
[ProducesResponseType<ContentManagementResult<TenantAdminMemberItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<TenantAdminMemberItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<TenantAdminMemberItem>>> DisableMember(
|
public async Task<ActionResult<ContentManagementResult<TenantAdminMemberItem>>> DisableMember(
|
||||||
@@ -179,6 +194,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("audit-logs")]
|
[HttpGet("audit-logs")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantStaffManage)]
|
||||||
[EndpointSummary("查询租户审计日志")]
|
[EndpointSummary("查询租户审计日志")]
|
||||||
[ProducesResponseType<CatalogList<TenantAdminAuditLogItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<CatalogList<TenantAdminAuditLogItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<CatalogList<TenantAdminAuditLogItem>>> GetAuditLogs(
|
public async Task<ActionResult<CatalogList<TenantAdminAuditLogItem>>> GetAuditLogs(
|
||||||
@@ -188,45 +204,8 @@ public sealed class TenantAdminDirectController(
|
|||||||
return Ok(await tenantAdminService.GetAuditLogsAsync(ResolveActor(), query.ToFilter(), cancellationToken));
|
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")]
|
[HttpPut("branding")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
|
||||||
[EndpointSummary("更新租户品牌信息")]
|
[EndpointSummary("更新租户品牌信息")]
|
||||||
[ProducesResponseType<ContentManagementResult<TenantBrandingItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<TenantBrandingItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<TenantBrandingItem>>> UpsertBranding(
|
public async Task<ActionResult<ContentManagementResult<TenantBrandingItem>>> UpsertBranding(
|
||||||
@@ -237,6 +216,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("settings")]
|
[HttpPut("settings")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
|
||||||
[EndpointSummary("更新租户公开设置与功能开关")]
|
[EndpointSummary("更新租户公开设置与功能开关")]
|
||||||
[ProducesResponseType<ContentManagementResult<TenantSettingsItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<TenantSettingsItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<TenantSettingsItem>>> UpsertSettings(
|
public async Task<ActionResult<ContentManagementResult<TenantSettingsItem>>> UpsertSettings(
|
||||||
@@ -247,6 +227,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("theme-templates")]
|
[HttpGet("theme-templates")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
|
||||||
[EndpointSummary("查询可用租户主题模板")]
|
[EndpointSummary("查询可用租户主题模板")]
|
||||||
[ProducesResponseType<CatalogList<TenantThemeTemplateItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<CatalogList<TenantThemeTemplateItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<CatalogList<TenantThemeTemplateItem>>> GetThemeTemplates(CancellationToken cancellationToken)
|
public async Task<ActionResult<CatalogList<TenantThemeTemplateItem>>> GetThemeTemplates(CancellationToken cancellationToken)
|
||||||
@@ -255,6 +236,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("theme")]
|
[HttpGet("theme")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
|
||||||
[EndpointSummary("查询租户当前主题与草稿")]
|
[EndpointSummary("查询租户当前主题与草稿")]
|
||||||
[ProducesResponseType<ContentManagementResult<TenantThemeItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<TenantThemeItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<TenantThemeItem>>> GetTheme(CancellationToken cancellationToken)
|
public async Task<ActionResult<ContentManagementResult<TenantThemeItem>>> GetTheme(CancellationToken cancellationToken)
|
||||||
@@ -263,6 +245,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("theme/preview")]
|
[HttpPost("theme/preview")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
|
||||||
[EndpointSummary("生成租户主题草稿")]
|
[EndpointSummary("生成租户主题草稿")]
|
||||||
[ProducesResponseType<ContentManagementResult<TenantThemeItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<TenantThemeItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<TenantThemeItem>>> PreviewTheme(
|
public async Task<ActionResult<ContentManagementResult<TenantThemeItem>>> PreviewTheme(
|
||||||
@@ -273,6 +256,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("theme/publish")]
|
[HttpPost("theme/publish")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
|
||||||
[EndpointSummary("发布租户主题")]
|
[EndpointSummary("发布租户主题")]
|
||||||
[ProducesResponseType<ContentManagementResult<TenantThemeItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<TenantThemeItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<TenantThemeItem>>> PublishTheme(
|
public async Task<ActionResult<ContentManagementResult<TenantThemeItem>>> PublishTheme(
|
||||||
@@ -283,6 +267,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("domains")]
|
[HttpGet("domains")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
|
||||||
[EndpointSummary("查询租户域名")]
|
[EndpointSummary("查询租户域名")]
|
||||||
[ProducesResponseType<CatalogList<TenantDomainItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<CatalogList<TenantDomainItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<CatalogList<TenantDomainItem>>> GetDomains(CancellationToken cancellationToken)
|
public async Task<ActionResult<CatalogList<TenantDomainItem>>> GetDomains(CancellationToken cancellationToken)
|
||||||
@@ -291,6 +276,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("domains")]
|
[HttpPost("domains")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
|
||||||
[EndpointSummary("添加租户域名")]
|
[EndpointSummary("添加租户域名")]
|
||||||
[ProducesResponseType<ContentManagementResult<TenantDomainItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<TenantDomainItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<TenantDomainItem>>> CreateDomain(
|
public async Task<ActionResult<ContentManagementResult<TenantDomainItem>>> CreateDomain(
|
||||||
@@ -301,6 +287,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("auth-providers")]
|
[HttpGet("auth-providers")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantProviderManage)]
|
||||||
[EndpointSummary("查询租户登录 Provider 公开配置")]
|
[EndpointSummary("查询租户登录 Provider 公开配置")]
|
||||||
[ProducesResponseType<CatalogList<TenantIdentityProviderItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<CatalogList<TenantIdentityProviderItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<CatalogList<TenantIdentityProviderItem>>> GetAuthProviders(CancellationToken cancellationToken)
|
public async Task<ActionResult<CatalogList<TenantIdentityProviderItem>>> GetAuthProviders(CancellationToken cancellationToken)
|
||||||
@@ -309,6 +296,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("auth-providers")]
|
[HttpPut("auth-providers")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantProviderManage)]
|
||||||
[EndpointSummary("新增或更新租户登录 Provider")]
|
[EndpointSummary("新增或更新租户登录 Provider")]
|
||||||
[ProducesResponseType<ContentManagementResult<TenantIdentityProviderItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<TenantIdentityProviderItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<TenantIdentityProviderItem>>> UpsertAuthProvider(
|
public async Task<ActionResult<ContentManagementResult<TenantIdentityProviderItem>>> UpsertAuthProvider(
|
||||||
@@ -319,6 +307,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("badges")]
|
[HttpGet("badges")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||||
[EndpointSummary("查询租户勋章")]
|
[EndpointSummary("查询租户勋章")]
|
||||||
[ProducesResponseType<CatalogList<TenantAdminBadgeItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<CatalogList<TenantAdminBadgeItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<CatalogList<TenantAdminBadgeItem>>> GetBadges(
|
public async Task<ActionResult<CatalogList<TenantAdminBadgeItem>>> GetBadges(
|
||||||
@@ -329,6 +318,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("badges")]
|
[HttpPut("badges")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||||
[EndpointSummary("新增或更新租户勋章")]
|
[EndpointSummary("新增或更新租户勋章")]
|
||||||
[ProducesResponseType<ContentManagementResult<TenantAdminBadgeItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<TenantAdminBadgeItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<TenantAdminBadgeItem>>> UpsertBadge(
|
public async Task<ActionResult<ContentManagementResult<TenantAdminBadgeItem>>> UpsertBadge(
|
||||||
@@ -339,6 +329,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("badge-grants")]
|
[HttpGet("badge-grants")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||||
[EndpointSummary("查询勋章发放记录")]
|
[EndpointSummary("查询勋章发放记录")]
|
||||||
[ProducesResponseType<CatalogList<TenantAdminBadgeGrantItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<CatalogList<TenantAdminBadgeGrantItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<CatalogList<TenantAdminBadgeGrantItem>>> GetBadgeGrants(
|
public async Task<ActionResult<CatalogList<TenantAdminBadgeGrantItem>>> GetBadgeGrants(
|
||||||
@@ -349,6 +340,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("badge-grants")]
|
[HttpPost("badge-grants")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||||
[EndpointSummary("向租户成员发放勋章")]
|
[EndpointSummary("向租户成员发放勋章")]
|
||||||
[ProducesResponseType<ContentManagementResult<TenantAdminBadgeGrantItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<TenantAdminBadgeGrantItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<TenantAdminBadgeGrantItem>>> GrantBadge(
|
public async Task<ActionResult<ContentManagementResult<TenantAdminBadgeGrantItem>>> GrantBadge(
|
||||||
@@ -359,6 +351,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("notifications")]
|
[HttpGet("notifications")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||||
[EndpointSummary("查询用户站内通知")]
|
[EndpointSummary("查询用户站内通知")]
|
||||||
[ProducesResponseType<CatalogList<TenantAdminNotificationItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<CatalogList<TenantAdminNotificationItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<CatalogList<TenantAdminNotificationItem>>> GetNotifications(
|
public async Task<ActionResult<CatalogList<TenantAdminNotificationItem>>> GetNotifications(
|
||||||
@@ -369,6 +362,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("notifications")]
|
[HttpPut("notifications")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||||
[EndpointSummary("新增或更新用户站内通知")]
|
[EndpointSummary("新增或更新用户站内通知")]
|
||||||
[ProducesResponseType<ContentManagementResult<TenantAdminNotificationItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<TenantAdminNotificationItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<TenantAdminNotificationItem>>> UpsertNotification(
|
public async Task<ActionResult<ContentManagementResult<TenantAdminNotificationItem>>> UpsertNotification(
|
||||||
@@ -379,6 +373,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("feedbacks")]
|
[HttpGet("feedbacks")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||||
[EndpointSummary("查询用户反馈")]
|
[EndpointSummary("查询用户反馈")]
|
||||||
[ProducesResponseType<CatalogList<TenantAdminFeedbackItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<CatalogList<TenantAdminFeedbackItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<CatalogList<TenantAdminFeedbackItem>>> GetFeedbacks(
|
public async Task<ActionResult<CatalogList<TenantAdminFeedbackItem>>> GetFeedbacks(
|
||||||
@@ -389,6 +384,7 @@ public sealed class TenantAdminDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("feedbacks/status")]
|
[HttpPost("feedbacks/status")]
|
||||||
|
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||||
[EndpointSummary("处理用户反馈")]
|
[EndpointSummary("处理用户反馈")]
|
||||||
[ProducesResponseType<ContentManagementResult<TenantAdminFeedbackItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<TenantAdminFeedbackItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<TenantAdminFeedbackItem>>> UpdateFeedback(
|
public async Task<ActionResult<ContentManagementResult<TenantAdminFeedbackItem>>> UpdateFeedback(
|
||||||
@@ -400,17 +396,13 @@ public sealed class TenantAdminDirectController(
|
|||||||
|
|
||||||
private TenantAdminActor ResolveActor()
|
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;
|
namespace Tiku.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
[Authorize(Policy = BackendPermissions.TenantCommerceOperate)]
|
||||||
[Produces("application/json")]
|
[Produces("application/json")]
|
||||||
[Route("api/tenant-commerce")]
|
[Route("api/tenant-commerce")]
|
||||||
public sealed class TenantCommerceController(
|
public sealed class TenantCommerceController(
|
||||||
@@ -17,6 +17,7 @@ public sealed class TenantCommerceController(
|
|||||||
ITenantContext currentTenant) : ControllerBase
|
ITenantContext currentTenant) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("payment-accounts")]
|
[HttpGet("payment-accounts")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||||
[EndpointSummary("查询租户支付账号")]
|
[EndpointSummary("查询租户支付账号")]
|
||||||
[ProducesResponseType<IReadOnlyCollection<TenantPaymentProviderItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<IReadOnlyCollection<TenantPaymentProviderItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<IReadOnlyCollection<TenantPaymentProviderItem>>> PaymentAccounts(
|
public async Task<ActionResult<IReadOnlyCollection<TenantPaymentProviderItem>>> PaymentAccounts(
|
||||||
@@ -30,6 +31,7 @@ public sealed class TenantCommerceController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("payment-accounts")]
|
[HttpPut("payment-accounts")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||||
[EndpointSummary("新增或更新租户支付账号")]
|
[EndpointSummary("新增或更新租户支付账号")]
|
||||||
[ProducesResponseType<TenantPaymentProviderItem>(StatusCodes.Status200OK)]
|
[ProducesResponseType<TenantPaymentProviderItem>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<TenantPaymentProviderItem>> UpsertPaymentAccount(
|
public async Task<ActionResult<TenantPaymentProviderItem>> UpsertPaymentAccount(
|
||||||
@@ -43,6 +45,7 @@ public sealed class TenantCommerceController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("secrets")]
|
[HttpPut("secrets")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||||
[EndpointSummary("写入或轮换租户密钥")]
|
[EndpointSummary("写入或轮换租户密钥")]
|
||||||
[ProducesResponseType<TenantSecretItem>(StatusCodes.Status200OK)]
|
[ProducesResponseType<TenantSecretItem>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<TenantSecretItem>> UpsertSecret(
|
public async Task<ActionResult<TenantSecretItem>> UpsertSecret(
|
||||||
@@ -82,6 +85,7 @@ public sealed class TenantCommerceController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("code-batches")]
|
[HttpPost("code-batches")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||||
[EndpointSummary("创建兑换码批次")]
|
[EndpointSummary("创建兑换码批次")]
|
||||||
[ProducesResponseType<CodeBatchItem>(StatusCodes.Status200OK)]
|
[ProducesResponseType<CodeBatchItem>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<CodeBatchItem>> CreateCodeBatch(
|
public async Task<ActionResult<CodeBatchItem>> CreateCodeBatch(
|
||||||
@@ -95,6 +99,7 @@ public sealed class TenantCommerceController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("activation-codes")]
|
[HttpGet("activation-codes")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||||
[EndpointSummary("查询兑换码")]
|
[EndpointSummary("查询兑换码")]
|
||||||
[ProducesResponseType<ActivationCodeList>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ActivationCodeList>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ActivationCodeList>> ActivationCodes(
|
public async Task<ActionResult<ActivationCodeList>> ActivationCodes(
|
||||||
@@ -108,6 +113,7 @@ public sealed class TenantCommerceController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("activation-codes/redeem")]
|
[HttpPost("activation-codes/redeem")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||||
[EndpointSummary("后台核销兑换码")]
|
[EndpointSummary("后台核销兑换码")]
|
||||||
[ProducesResponseType<ActivationCodeItem>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ActivationCodeItem>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ActivationCodeItem>> RedeemActivationCode(
|
public async Task<ActionResult<ActivationCodeItem>> RedeemActivationCode(
|
||||||
@@ -121,6 +127,7 @@ public sealed class TenantCommerceController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("point-activity-tasks")]
|
[HttpGet("point-activity-tasks")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||||
[EndpointSummary("查询积分活动任务")]
|
[EndpointSummary("查询积分活动任务")]
|
||||||
[ProducesResponseType<TenantPointTaskList>(StatusCodes.Status200OK)]
|
[ProducesResponseType<TenantPointTaskList>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<TenantPointTaskList>> PointTasks(
|
public async Task<ActionResult<TenantPointTaskList>> PointTasks(
|
||||||
@@ -134,6 +141,7 @@ public sealed class TenantCommerceController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("point-activity-tasks")]
|
[HttpPut("point-activity-tasks")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||||
[EndpointSummary("新增或更新积分活动任务")]
|
[EndpointSummary("新增或更新积分活动任务")]
|
||||||
[ProducesResponseType<object>(StatusCodes.Status200OK)]
|
[ProducesResponseType<object>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<object>> UpsertPointTask(
|
public async Task<ActionResult<object>> UpsertPointTask(
|
||||||
@@ -147,6 +155,7 @@ public sealed class TenantCommerceController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("point-activity-claims")]
|
[HttpGet("point-activity-claims")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||||
[EndpointSummary("查询积分任务领取记录")]
|
[EndpointSummary("查询积分任务领取记录")]
|
||||||
[ProducesResponseType<TenantPointClaimList>(StatusCodes.Status200OK)]
|
[ProducesResponseType<TenantPointClaimList>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<TenantPointClaimList>> PointClaims(
|
public async Task<ActionResult<TenantPointClaimList>> PointClaims(
|
||||||
@@ -160,6 +169,7 @@ public sealed class TenantCommerceController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("point-exchange-items")]
|
[HttpGet("point-exchange-items")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||||
[EndpointSummary("查询积分兑换项")]
|
[EndpointSummary("查询积分兑换项")]
|
||||||
[ProducesResponseType<TenantPointExchangeItemList>(StatusCodes.Status200OK)]
|
[ProducesResponseType<TenantPointExchangeItemList>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<TenantPointExchangeItemList>> PointExchangeItems(
|
public async Task<ActionResult<TenantPointExchangeItemList>> PointExchangeItems(
|
||||||
@@ -173,6 +183,7 @@ public sealed class TenantCommerceController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("point-exchange-items")]
|
[HttpPut("point-exchange-items")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||||
[EndpointSummary("新增或更新积分兑换项")]
|
[EndpointSummary("新增或更新积分兑换项")]
|
||||||
[ProducesResponseType<object>(StatusCodes.Status200OK)]
|
[ProducesResponseType<object>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<object>> UpsertPointExchangeItem(
|
public async Task<ActionResult<object>> UpsertPointExchangeItem(
|
||||||
@@ -186,6 +197,7 @@ public sealed class TenantCommerceController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("point-exchange-orders")]
|
[HttpGet("point-exchange-orders")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||||
[EndpointSummary("查询积分兑换订单")]
|
[EndpointSummary("查询积分兑换订单")]
|
||||||
[ProducesResponseType<TenantPointExchangeOrderList>(StatusCodes.Status200OK)]
|
[ProducesResponseType<TenantPointExchangeOrderList>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<TenantPointExchangeOrderList>> PointExchangeOrders(
|
public async Task<ActionResult<TenantPointExchangeOrderList>> PointExchangeOrders(
|
||||||
@@ -199,6 +211,7 @@ public sealed class TenantCommerceController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("point-exchange-orders/status")]
|
[HttpPost("point-exchange-orders/status")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||||
[EndpointSummary("更新积分兑换订单状态")]
|
[EndpointSummary("更新积分兑换订单状态")]
|
||||||
[ProducesResponseType<object>(StatusCodes.Status200OK)]
|
[ProducesResponseType<object>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<object>> UpdatePointExchangeOrderStatus(
|
public async Task<ActionResult<object>> UpdatePointExchangeOrderStatus(
|
||||||
@@ -212,6 +225,7 @@ public sealed class TenantCommerceController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("coupons")]
|
[HttpGet("coupons")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||||
[EndpointSummary("查询租户优惠券")]
|
[EndpointSummary("查询租户优惠券")]
|
||||||
[ProducesResponseType<TenantCouponList>(StatusCodes.Status200OK)]
|
[ProducesResponseType<TenantCouponList>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<TenantCouponList>> Coupons(
|
public async Task<ActionResult<TenantCouponList>> Coupons(
|
||||||
@@ -225,6 +239,7 @@ public sealed class TenantCommerceController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("coupons")]
|
[HttpPut("coupons")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||||
[EndpointSummary("新增或更新租户优惠券")]
|
[EndpointSummary("新增或更新租户优惠券")]
|
||||||
[ProducesResponseType<object>(StatusCodes.Status200OK)]
|
[ProducesResponseType<object>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<object>> UpsertCoupon(
|
public async Task<ActionResult<object>> UpsertCoupon(
|
||||||
@@ -238,6 +253,7 @@ public sealed class TenantCommerceController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("coupons/redemptions")]
|
[HttpGet("coupons/redemptions")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||||
[EndpointSummary("查询优惠券领取和核销记录")]
|
[EndpointSummary("查询优惠券领取和核销记录")]
|
||||||
[ProducesResponseType<TenantCouponRedemptionList>(StatusCodes.Status200OK)]
|
[ProducesResponseType<TenantCouponRedemptionList>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<TenantCouponRedemptionList>> CouponRedemptions(
|
public async Task<ActionResult<TenantCouponRedemptionList>> CouponRedemptions(
|
||||||
@@ -251,6 +267,7 @@ public sealed class TenantCommerceController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("coupons/report")]
|
[HttpGet("coupons/report")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||||
[EndpointSummary("查询优惠券基础报表")]
|
[EndpointSummary("查询优惠券基础报表")]
|
||||||
[ProducesResponseType<TenantCouponReport>(StatusCodes.Status200OK)]
|
[ProducesResponseType<TenantCouponReport>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<TenantCouponReport>> CouponReport(
|
public async Task<ActionResult<TenantCouponReport>> CouponReport(
|
||||||
@@ -316,6 +333,7 @@ public sealed class TenantCommerceController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("reconciliation/batches")]
|
[HttpGet("reconciliation/batches")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||||
[EndpointSummary("查询对账批次")]
|
[EndpointSummary("查询对账批次")]
|
||||||
[ProducesResponseType<TenantReconciliationBatchList>(StatusCodes.Status200OK)]
|
[ProducesResponseType<TenantReconciliationBatchList>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<TenantReconciliationBatchList>> ReconciliationBatches(
|
public async Task<ActionResult<TenantReconciliationBatchList>> ReconciliationBatches(
|
||||||
@@ -329,6 +347,7 @@ public sealed class TenantCommerceController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("reconciliation/batches")]
|
[HttpPost("reconciliation/batches")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||||
[EndpointSummary("创建对账批次")]
|
[EndpointSummary("创建对账批次")]
|
||||||
[ProducesResponseType<CommerceReconciliationBatch>(StatusCodes.Status200OK)]
|
[ProducesResponseType<CommerceReconciliationBatch>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<CommerceReconciliationBatch>> CreateReconciliationBatch(
|
public async Task<ActionResult<CommerceReconciliationBatch>> CreateReconciliationBatch(
|
||||||
@@ -342,6 +361,7 @@ public sealed class TenantCommerceController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("reconciliation/issues")]
|
[HttpGet("reconciliation/issues")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||||
[EndpointSummary("查询对账异常")]
|
[EndpointSummary("查询对账异常")]
|
||||||
[ProducesResponseType<TenantReconciliationIssueList>(StatusCodes.Status200OK)]
|
[ProducesResponseType<TenantReconciliationIssueList>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<TenantReconciliationIssueList>> ReconciliationIssues(
|
public async Task<ActionResult<TenantReconciliationIssueList>> ReconciliationIssues(
|
||||||
@@ -355,6 +375,7 @@ public sealed class TenantCommerceController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("reconciliation/issues/status")]
|
[HttpPost("reconciliation/issues/status")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||||
[EndpointSummary("更新对账异常状态")]
|
[EndpointSummary("更新对账异常状态")]
|
||||||
[ProducesResponseType<CommerceReconciliationIssue>(StatusCodes.Status200OK)]
|
[ProducesResponseType<CommerceReconciliationIssue>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<CommerceReconciliationIssue>> UpdateReconciliationIssue(
|
public async Task<ActionResult<CommerceReconciliationIssue>> UpdateReconciliationIssue(
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ using Tiku.Application.Security;
|
|||||||
namespace Tiku.Api.Controllers;
|
namespace Tiku.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
[Authorize(Policy = BackendPermissions.TenantContentManage)]
|
||||||
[Produces("application/json")]
|
[Produces("application/json")]
|
||||||
[Route("api/tenant-content")]
|
[Route("api/tenant-content")]
|
||||||
public sealed class TenantContentController(
|
public sealed class TenantContentController(
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ using Tiku.Domain.Content;
|
|||||||
namespace Tiku.Api.Controllers;
|
namespace Tiku.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
[Authorize(Policy = BackendPermissions.TenantContentManage)]
|
||||||
[Produces("application/json")]
|
[Produces("application/json")]
|
||||||
[Route("api/tenant-content")]
|
[Route("api/tenant-content")]
|
||||||
public sealed class TenantContentDirectController(
|
public sealed class TenantContentDirectController(
|
||||||
@@ -20,6 +20,7 @@ public sealed class TenantContentDirectController(
|
|||||||
ITenantContext currentTenant) : ControllerBase
|
ITenantContext currentTenant) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpPost("questions")]
|
[HttpPost("questions")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||||
[EndpointSummary("创建题目及首个版本")]
|
[EndpointSummary("创建题目及首个版本")]
|
||||||
[ProducesResponseType<ContentManagementResult<QuestionManagementItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<QuestionManagementItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<QuestionManagementItem>>> CreateQuestion(
|
public async Task<ActionResult<ContentManagementResult<QuestionManagementItem>>> CreateQuestion(
|
||||||
@@ -30,6 +31,7 @@ public sealed class TenantContentDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPatch("questions")]
|
[HttpPatch("questions")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||||
[EndpointSummary("更新题目并可选择创建新版本")]
|
[EndpointSummary("更新题目并可选择创建新版本")]
|
||||||
[ProducesResponseType<ContentManagementResult<QuestionManagementItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<QuestionManagementItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<QuestionManagementItem>>> UpdateQuestion(
|
public async Task<ActionResult<ContentManagementResult<QuestionManagementItem>>> UpdateQuestion(
|
||||||
@@ -60,6 +62,7 @@ public sealed class TenantContentDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("vocabulary-words")]
|
[HttpGet("vocabulary-words")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||||
[EndpointSummary("查询管理侧词汇")]
|
[EndpointSummary("查询管理侧词汇")]
|
||||||
[ProducesResponseType<CatalogList<VocabularyWord>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<CatalogList<VocabularyWord>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<CatalogList<VocabularyWord>>> GetVocabularyWords(
|
public async Task<ActionResult<CatalogList<VocabularyWord>>> GetVocabularyWords(
|
||||||
@@ -70,6 +73,7 @@ public sealed class TenantContentDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("vocabulary-words")]
|
[HttpPut("vocabulary-words")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||||
[EndpointSummary("新增或更新词汇")]
|
[EndpointSummary("新增或更新词汇")]
|
||||||
[ProducesResponseType<ContentManagementResult<VocabularyWord>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<VocabularyWord>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<VocabularyWord>>> UpsertVocabularyWord(
|
public async Task<ActionResult<ContentManagementResult<VocabularyWord>>> UpsertVocabularyWord(
|
||||||
@@ -100,6 +104,7 @@ public sealed class TenantContentDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("handbook-chapters")]
|
[HttpGet("handbook-chapters")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||||
[EndpointSummary("查询管理侧知识手册章节")]
|
[EndpointSummary("查询管理侧知识手册章节")]
|
||||||
[ProducesResponseType<CatalogList<HandbookChapter>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<CatalogList<HandbookChapter>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<CatalogList<HandbookChapter>>> GetHandbookChapters(
|
public async Task<ActionResult<CatalogList<HandbookChapter>>> GetHandbookChapters(
|
||||||
@@ -110,6 +115,7 @@ public sealed class TenantContentDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("handbook-chapters")]
|
[HttpPut("handbook-chapters")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||||
[EndpointSummary("新增或更新知识手册章节")]
|
[EndpointSummary("新增或更新知识手册章节")]
|
||||||
[ProducesResponseType<ContentManagementResult<HandbookChapter>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<HandbookChapter>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<HandbookChapter>>> UpsertHandbookChapter(
|
public async Task<ActionResult<ContentManagementResult<HandbookChapter>>> UpsertHandbookChapter(
|
||||||
@@ -120,6 +126,7 @@ public sealed class TenantContentDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("handbook-entries")]
|
[HttpGet("handbook-entries")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||||
[EndpointSummary("查询管理侧知识手册条目")]
|
[EndpointSummary("查询管理侧知识手册条目")]
|
||||||
[ProducesResponseType<CatalogList<HandbookEntry>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<CatalogList<HandbookEntry>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<CatalogList<HandbookEntry>>> GetHandbookEntries(
|
public async Task<ActionResult<CatalogList<HandbookEntry>>> GetHandbookEntries(
|
||||||
@@ -130,6 +137,7 @@ public sealed class TenantContentDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("handbook-entries")]
|
[HttpPut("handbook-entries")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||||
[EndpointSummary("新增或更新知识手册条目")]
|
[EndpointSummary("新增或更新知识手册条目")]
|
||||||
[ProducesResponseType<ContentManagementResult<HandbookEntry>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<HandbookEntry>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<HandbookEntry>>> UpsertHandbookEntry(
|
public async Task<ActionResult<ContentManagementResult<HandbookEntry>>> UpsertHandbookEntry(
|
||||||
@@ -240,6 +248,7 @@ public sealed class TenantContentDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("videos")]
|
[HttpGet("videos")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||||
[EndpointSummary("查询租户视频解析")]
|
[EndpointSummary("查询租户视频解析")]
|
||||||
[ProducesResponseType<CatalogList<VideoManagementItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<CatalogList<VideoManagementItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<CatalogList<VideoManagementItem>>> GetVideos(
|
public async Task<ActionResult<CatalogList<VideoManagementItem>>> GetVideos(
|
||||||
@@ -250,6 +259,7 @@ public sealed class TenantContentDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("videos")]
|
[HttpPut("videos")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||||
[EndpointSummary("新增或更新视频解析")]
|
[EndpointSummary("新增或更新视频解析")]
|
||||||
[ProducesResponseType<ContentManagementResult<VideoManagementItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<VideoManagementItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<VideoManagementItem>>> UpsertVideo(
|
public async Task<ActionResult<ContentManagementResult<VideoManagementItem>>> UpsertVideo(
|
||||||
@@ -260,6 +270,7 @@ public sealed class TenantContentDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("question-videos")]
|
[HttpPost("question-videos")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||||
[EndpointSummary("绑定题目与解析视频")]
|
[EndpointSummary("绑定题目与解析视频")]
|
||||||
[ProducesResponseType<ContentManagementResult<QuestionVideoManagementItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<QuestionVideoManagementItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<QuestionVideoManagementItem>>> BindQuestionVideo(
|
public async Task<ActionResult<ContentManagementResult<QuestionVideoManagementItem>>> BindQuestionVideo(
|
||||||
@@ -270,6 +281,7 @@ public sealed class TenantContentDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("operations/{kind}")]
|
[HttpGet("operations/{kind}")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||||
[EndpointSummary("查询运营内容")]
|
[EndpointSummary("查询运营内容")]
|
||||||
[ProducesResponseType<CatalogList<OperationContentItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<CatalogList<OperationContentItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<CatalogList<OperationContentItem>>> GetOperationContent(
|
public async Task<ActionResult<CatalogList<OperationContentItem>>> GetOperationContent(
|
||||||
@@ -281,6 +293,7 @@ public sealed class TenantContentDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("operations/{kind}")]
|
[HttpPut("operations/{kind}")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||||
[EndpointSummary("新增或更新运营内容")]
|
[EndpointSummary("新增或更新运营内容")]
|
||||||
[ProducesResponseType<ContentManagementResult<OperationContentItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ContentManagementResult<OperationContentItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ContentManagementResult<OperationContentItem>>> UpsertOperationContent(
|
public async Task<ActionResult<ContentManagementResult<OperationContentItem>>> UpsertOperationContent(
|
||||||
@@ -292,6 +305,7 @@ public sealed class TenantContentDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("imports/preview/{importType}")]
|
[HttpPost("imports/preview/{importType}")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||||
[EndpointSummary("预览内容导入数据")]
|
[EndpointSummary("预览内容导入数据")]
|
||||||
[ProducesResponseType<SimpleImportResult>(StatusCodes.Status200OK)]
|
[ProducesResponseType<SimpleImportResult>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<SimpleImportResult>> PreviewImport(
|
public async Task<ActionResult<SimpleImportResult>> PreviewImport(
|
||||||
@@ -303,6 +317,7 @@ public sealed class TenantContentDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("imports/{importType}")]
|
[HttpPost("imports/{importType}")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||||
[EndpointSummary("执行同步内容导入")]
|
[EndpointSummary("执行同步内容导入")]
|
||||||
[ProducesResponseType<SimpleImportResult>(StatusCodes.Status200OK)]
|
[ProducesResponseType<SimpleImportResult>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<SimpleImportResult>> ExecuteImport(
|
public async Task<ActionResult<SimpleImportResult>> ExecuteImport(
|
||||||
@@ -314,6 +329,7 @@ public sealed class TenantContentDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("imports/issues")]
|
[HttpGet("imports/issues")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||||
[EndpointSummary("查询内容导入问题明细")]
|
[EndpointSummary("查询内容导入问题明细")]
|
||||||
[ProducesResponseType<CatalogList<ContentImportIssueModel>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<CatalogList<ContentImportIssueModel>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<CatalogList<ContentImportIssueModel>>> GetImportIssues(
|
public async Task<ActionResult<CatalogList<ContentImportIssueModel>>> GetImportIssues(
|
||||||
@@ -324,6 +340,7 @@ public sealed class TenantContentDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("imports/post-check")]
|
[HttpPost("imports/post-check")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||||
[EndpointSummary("执行内容导入后完整性检查")]
|
[EndpointSummary("执行内容导入后完整性检查")]
|
||||||
[ProducesResponseType<ImportPostCheckResult>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ImportPostCheckResult>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ImportPostCheckResult>> RunImportPostCheck(
|
public async Task<ActionResult<ImportPostCheckResult>> RunImportPostCheck(
|
||||||
@@ -334,6 +351,7 @@ public sealed class TenantContentDirectController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("imports/post-check")]
|
[HttpGet("imports/post-check")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||||
[EndpointSummary("查询内容导入后检查状态")]
|
[EndpointSummary("查询内容导入后检查状态")]
|
||||||
[ProducesResponseType<ImportPostCheckResult>(StatusCodes.Status200OK)]
|
[ProducesResponseType<ImportPostCheckResult>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ImportPostCheckResult>> GetImportPostCheck(
|
public async Task<ActionResult<ImportPostCheckResult>> GetImportPostCheck(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ using Tiku.Application.Tenancy;
|
|||||||
namespace Tiku.Api.Controllers;
|
namespace Tiku.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
|
||||||
[Produces("application/json")]
|
[Produces("application/json")]
|
||||||
[Route("api/tenant-admin/frontend-config")]
|
[Route("api/tenant-admin/frontend-config")]
|
||||||
public sealed class TenantFrontendConfigController(
|
public sealed class TenantFrontendConfigController(
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using System.Text.Json;
|
|
||||||
using Tiku.Application.Auth;
|
using Tiku.Application.Auth;
|
||||||
using Tiku.Application.Security;
|
using Tiku.Application.Security;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
@@ -39,8 +38,7 @@ public sealed class TenantsController(
|
|||||||
tenant.Name,
|
tenant.Name,
|
||||||
tenant.Slug,
|
tenant.Slug,
|
||||||
tenant.Status,
|
tenant.Status,
|
||||||
membership.Role,
|
membership.Role))
|
||||||
membership.Permissions))
|
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (result is null)
|
if (result is null)
|
||||||
@@ -60,11 +58,9 @@ public sealed class TenantsController(
|
|||||||
/// <param name="TenantSlug">租户编码。</param>
|
/// <param name="TenantSlug">租户编码。</param>
|
||||||
/// <param name="Status">租户状态。</param>
|
/// <param name="Status">租户状态。</param>
|
||||||
/// <param name="Role">当前用户在租户内的角色。</param>
|
/// <param name="Role">当前用户在租户内的角色。</param>
|
||||||
/// <param name="Permissions">当前用户在租户内的权限扩展。</param>
|
|
||||||
public sealed record CurrentTenantResponse(
|
public sealed record CurrentTenantResponse(
|
||||||
Guid TenantId,
|
Guid TenantId,
|
||||||
string TenantName,
|
string TenantName,
|
||||||
string TenantSlug,
|
string TenantSlug,
|
||||||
TenantStatus Status,
|
TenantStatus Status,
|
||||||
TenantRole Role,
|
TenantRole Role);
|
||||||
JsonElement Permissions);
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ public static class SerilogRequestLogging
|
|||||||
SetClaim(diagnosticContext, "UserId", user, TikuClaimTypes.UserId);
|
SetClaim(diagnosticContext, "UserId", user, TikuClaimTypes.UserId);
|
||||||
SetClaim(diagnosticContext, "TenantId", user, TikuClaimTypes.TenantId);
|
SetClaim(diagnosticContext, "TenantId", user, TikuClaimTypes.TenantId);
|
||||||
SetClaim(diagnosticContext, "SessionId", user, TikuClaimTypes.SessionId);
|
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.Infrastructure.Scoreline;
|
||||||
using Tiku.Application.TenantAdmin;
|
using Tiku.Application.TenantAdmin;
|
||||||
using Tiku.Application.Tenancy;
|
using Tiku.Application.Tenancy;
|
||||||
|
using Tiku.Infrastructure.Backoffice;
|
||||||
|
|
||||||
namespace Tiku.Api.Middleware;
|
namespace Tiku.Api.Middleware;
|
||||||
|
|
||||||
@@ -224,6 +225,16 @@ public sealed class ExceptionHandlingMiddleware(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (exception is BackofficeException backofficeException)
|
||||||
|
{
|
||||||
|
await WriteProblemAsync(
|
||||||
|
context,
|
||||||
|
backofficeException.Message,
|
||||||
|
BackofficeStatusCode(backofficeException.Code),
|
||||||
|
backofficeException.Code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (exception is CommerceException commerceException)
|
if (exception is CommerceException commerceException)
|
||||||
{
|
{
|
||||||
await WriteProblemAsync(
|
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
|
"entry_not_found" or "node_not_found" or "collection_not_found" or "question_not_found" or
|
||||||
"import_type_invalid" => StatusCodes.Status404NotFound,
|
"import_type_invalid" => StatusCodes.Status404NotFound,
|
||||||
"tenant_content_access_denied" => StatusCodes.Status403Forbidden,
|
"tenant_content_access_denied" => StatusCodes.Status403Forbidden,
|
||||||
|
_ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound,
|
||||||
_ => StatusCodes.Status400BadRequest
|
_ => StatusCodes.Status400BadRequest
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -425,8 +437,8 @@ public sealed class ExceptionHandlingMiddleware(
|
|||||||
{
|
{
|
||||||
"tenant_admin_access_denied" => StatusCodes.Status403Forbidden,
|
"tenant_admin_access_denied" => StatusCodes.Status403Forbidden,
|
||||||
"class_not_found" or "class_member_not_found" or "student_not_found" or "user_not_found" => StatusCodes.Status404NotFound,
|
"class_not_found" or "class_member_not_found" or "student_not_found" or "user_not_found" => StatusCodes.Status404NotFound,
|
||||||
"tenant_member_not_found" => StatusCodes.Status400BadRequest,
|
"tenant_member_not_found" => StatusCodes.Status404NotFound,
|
||||||
_ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status400BadRequest,
|
_ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound,
|
||||||
_ => StatusCodes.Status400BadRequest
|
_ => StatusCodes.Status400BadRequest
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -442,6 +454,17 @@ public sealed class ExceptionHandlingMiddleware(
|
|||||||
"payment_provider_not_configured" or "payment_secret_not_configured" => StatusCodes.Status503ServiceUnavailable,
|
"payment_provider_not_configured" or "payment_secret_not_configured" => StatusCodes.Status503ServiceUnavailable,
|
||||||
"order_status_invalid" or "activation_code_used" or "payment_amount_mismatch" or
|
"order_status_invalid" or "activation_code_used" or "payment_amount_mismatch" or
|
||||||
"coupon_usage_limit_reached" or "coupon_redemption_status_invalid" => StatusCodes.Status409Conflict,
|
"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
|
_ => 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 static class OptionsValidation
|
||||||
{
|
{
|
||||||
public const string DevelopmentSigningKey = "development-only-tiku-signing-key-change-before-production";
|
|
||||||
|
|
||||||
public static string ResolveDatabaseConnectionString(
|
public static string ResolveDatabaseConnectionString(
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
bool isDevelopment)
|
bool isDevelopment)
|
||||||
@@ -30,11 +28,7 @@ public static class OptionsValidation
|
|||||||
|
|
||||||
public static bool BeValidJwtOptions(JwtOptions options, bool isProduction)
|
public static bool BeValidJwtOptions(JwtOptions options, bool isProduction)
|
||||||
{
|
{
|
||||||
return !isProduction ||
|
return JwtOptions.BeValid(options, isProduction);
|
||||||
!string.Equals(
|
|
||||||
options.SigningKey,
|
|
||||||
DevelopmentSigningKey,
|
|
||||||
StringComparison.Ordinal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool BeValidCorsOptions(CorsOptions options)
|
public static bool BeValidCorsOptions(CorsOptions options)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
@@ -8,7 +9,6 @@ using Microsoft.IdentityModel.Tokens;
|
|||||||
using Scalar.AspNetCore;
|
using Scalar.AspNetCore;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Serilog.Events;
|
using Serilog.Events;
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
using Tiku.Api.Logging;
|
using Tiku.Api.Logging;
|
||||||
@@ -17,11 +17,13 @@ using Tiku.Api.OpenApi;
|
|||||||
using Tiku.Api.Options;
|
using Tiku.Api.Options;
|
||||||
using Tiku.Api.Security;
|
using Tiku.Api.Security;
|
||||||
using Tiku.Application;
|
using Tiku.Application;
|
||||||
|
using Tiku.Application.Auth;
|
||||||
using Tiku.Application.Security;
|
using Tiku.Application.Security;
|
||||||
using Tiku.Application.Tenancy;
|
using Tiku.Application.Tenancy;
|
||||||
using Tiku.Infrastructure;
|
using Tiku.Infrastructure;
|
||||||
using Tiku.Infrastructure.Commerce;
|
using Tiku.Infrastructure.Commerce;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
using Tiku.Infrastructure.Security;
|
||||||
using Tiku.Infrastructure.Storage;
|
using Tiku.Infrastructure.Storage;
|
||||||
|
|
||||||
Log.Logger = new LoggerConfiguration()
|
Log.Logger = new LoggerConfiguration()
|
||||||
@@ -116,11 +118,18 @@ try
|
|||||||
var rateLimitOptions = builder.Configuration
|
var rateLimitOptions = builder.Configuration
|
||||||
.GetSection(ApiRateLimitOptions.SectionName)
|
.GetSection(ApiRateLimitOptions.SectionName)
|
||||||
.Get<ApiRateLimitOptions>() ?? new ApiRateLimitOptions();
|
.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 =>
|
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
|
||||||
{
|
{
|
||||||
var partitionKey =
|
var partitionKey =
|
||||||
@@ -139,33 +148,113 @@ try
|
|||||||
Window = TimeSpan.FromSeconds(rateLimitOptions.WindowSeconds)
|
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
|
var problem = new ProblemDetails
|
||||||
{
|
{
|
||||||
Title = "Too many requests.",
|
Title = "Too many requests.",
|
||||||
Status = StatusCodes.Status429TooManyRequests,
|
Status = StatusCodes.Status429TooManyRequests,
|
||||||
Instance = context.HttpContext.Request.Path
|
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);
|
|
||||||
};
|
};
|
||||||
});
|
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(
|
var connectionString = OptionsValidation.ResolveDatabaseConnectionString(
|
||||||
builder.Configuration,
|
builder.Configuration,
|
||||||
builder.Environment.IsDevelopment());
|
builder.Environment.IsDevelopment());
|
||||||
|
|
||||||
builder.Services.AddInfrastructure(connectionString);
|
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.Services.Configure<ObjectStorageOptions>(
|
||||||
builder.Configuration.GetSection(ObjectStorageOptions.SectionName));
|
builder.Configuration.GetSection(ObjectStorageOptions.SectionName));
|
||||||
builder.Services.Configure<AliyunOssOptions>(
|
builder.Services.Configure<AliyunOssOptions>(
|
||||||
@@ -215,6 +304,18 @@ try
|
|||||||
"Production tenant secret encryption cannot use the development master key.")
|
"Production tenant secret encryption cannot use the development master key.")
|
||||||
.ValidateOnStart();
|
.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>()
|
builder.Services.AddOptions<JwtOptions>()
|
||||||
.Bind(builder.Configuration.GetSection("Security:Jwt"))
|
.Bind(builder.Configuration.GetSection("Security:Jwt"))
|
||||||
.ValidateDataAnnotations()
|
.ValidateDataAnnotations()
|
||||||
@@ -230,6 +331,7 @@ try
|
|||||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
.AddJwtBearer(options =>
|
.AddJwtBearer(options =>
|
||||||
{
|
{
|
||||||
|
options.MapInboundClaims = false;
|
||||||
options.TokenValidationParameters = new TokenValidationParameters
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
{
|
{
|
||||||
ValidateIssuer = true,
|
ValidateIssuer = true,
|
||||||
@@ -237,57 +339,93 @@ try
|
|||||||
ValidateAudience = true,
|
ValidateAudience = true,
|
||||||
ValidAudience = jwtOptions.Audience,
|
ValidAudience = jwtOptions.Audience,
|
||||||
ValidateIssuerSigningKey = true,
|
ValidateIssuerSigningKey = true,
|
||||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)),
|
RequireSignedTokens = true,
|
||||||
|
ValidAlgorithms = [SecurityAlgorithms.RsaSha256],
|
||||||
ValidateLifetime = true,
|
ValidateLifetime = true,
|
||||||
ClockSkew = TimeSpan.FromMinutes(1)
|
RequireExpirationTime = true,
|
||||||
|
ClockSkew = TimeSpan.FromMinutes(1),
|
||||||
|
NameClaimType = TikuClaimTypes.UserId
|
||||||
};
|
};
|
||||||
options.Events = new JwtBearerEvents
|
options.Events = new JwtBearerEvents
|
||||||
{
|
{
|
||||||
OnTokenValidated = async context =>
|
OnTokenValidated = async context =>
|
||||||
{
|
{
|
||||||
var tenantIdValue = context.Principal?.FindFirst(TikuClaimTypes.TenantId)?.Value;
|
var principal = context.Principal;
|
||||||
if (!Guid.TryParse(tenantIdValue, out var tenantId))
|
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;
|
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
|
var tenantInitializer = context.HttpContext.RequestServices
|
||||||
.GetRequiredService<ITenantContextInitializer>();
|
.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;
|
if (isPlatformHost && !resolvedTenantContext.IsResolved)
|
||||||
context.Fail("Authenticated tenant does not match the request host.");
|
{
|
||||||
return;
|
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;
|
context.Fail("Session, identity, membership, tenant, role or MFA state is no longer valid.");
|
||||||
}
|
|
||||||
|
|
||||||
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.");
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
OnChallenge = async context =>
|
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 =>
|
builder.Services.AddAuthorization(options =>
|
||||||
{
|
{
|
||||||
|
options.FallbackPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder()
|
||||||
|
.RequireAuthenticatedUser()
|
||||||
|
.Build();
|
||||||
options.AddPolicy(
|
options.AddPolicy(
|
||||||
TikuPolicies.AuthenticatedUser,
|
TikuPolicies.AuthenticatedUser,
|
||||||
policy => policy.RequireAuthenticatedUser());
|
policy => policy.RequireAuthenticatedUser());
|
||||||
options.AddPolicy(
|
|
||||||
TikuPolicies.CurrentTenantMember,
|
|
||||||
policy => policy
|
|
||||||
.RequireAuthenticatedUser()
|
|
||||||
.RequireAssertion(context => TenantRoleAuthorization.IsTenantMember(context.User)));
|
|
||||||
options.AddPolicy(
|
options.AddPolicy(
|
||||||
TikuPolicies.TenantAdmin,
|
TikuPolicies.TenantAdmin,
|
||||||
policy => policy
|
policy => policy.RequireAuthenticatedUser());
|
||||||
.RequireAuthenticatedUser()
|
|
||||||
.RequireAssertion(context => TenantRoleAuthorization.IsTenantAdmin(context.User)));
|
|
||||||
});
|
});
|
||||||
|
builder.Services.AddTikuRbacAuthorization();
|
||||||
|
builder.Services.AddSingleton<Microsoft.AspNetCore.Authorization.IAuthorizationMiddlewareResultHandler,
|
||||||
|
AuditingAuthorizationMiddlewareResultHandler>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.MapOpenApi();
|
app.MapOpenApi().AllowAnonymous();
|
||||||
app.MapScalarApiReference(options => options
|
app.MapScalarApiReference(options => options
|
||||||
.WithTitle("TIKU Backend API")
|
.WithTitle("TIKU Backend API")
|
||||||
.AddPreferredSecuritySchemes("BearerAuth")
|
.AddPreferredSecuritySchemes("BearerAuth")
|
||||||
.EnablePersistentAuthentication());
|
.EnablePersistentAuthentication())
|
||||||
|
.AllowAnonymous();
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseSerilogRequestLogging(SerilogRequestLogging.ConfigureRequestLogging);
|
app.UseSerilogRequestLogging(SerilogRequestLogging.ConfigureRequestLogging);
|
||||||
@@ -343,10 +493,8 @@ try
|
|||||||
app.UseCors(CorsOptions.PolicyName);
|
app.UseCors(CorsOptions.PolicyName);
|
||||||
app.UseMiddleware<TenantResolutionMiddleware>();
|
app.UseMiddleware<TenantResolutionMiddleware>();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
if (rateLimitOptions.Enabled)
|
app.UseMiddleware<AuthRateLimitPartitionMiddleware>();
|
||||||
{
|
app.UseRateLimiter();
|
||||||
app.UseRateLimiter();
|
|
||||||
}
|
|
||||||
|
|
||||||
app.UseMiddleware<CurrentPrincipalMiddleware>();
|
app.UseMiddleware<CurrentPrincipalMiddleware>();
|
||||||
app.UseAuthorization();
|
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,
|
"WindowSeconds": 60,
|
||||||
"QueueLimit": 0
|
"QueueLimit": 0
|
||||||
},
|
},
|
||||||
|
"Authentication": {
|
||||||
|
"Sms": {
|
||||||
|
"CodePepper": "development-only-sms-code-pepper-change-before-production"
|
||||||
|
}
|
||||||
|
},
|
||||||
"Security": {
|
"Security": {
|
||||||
"Jwt": {
|
"Jwt": {
|
||||||
"SigningKey": "development-only-tiku-signing-key-change-before-production"
|
"KeyId": "development-ephemeral"
|
||||||
},
|
},
|
||||||
"TenantSecrets": {
|
"TenantSecrets": {
|
||||||
"KeyId": "development-v1",
|
"KeyId": "development-v1",
|
||||||
|
|||||||
@@ -47,7 +47,25 @@
|
|||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"PermitLimit": 600,
|
"PermitLimit": 600,
|
||||||
"WindowSeconds": 60,
|
"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": {
|
"Storage": {
|
||||||
"DefaultProvider": "aliyun_oss",
|
"DefaultProvider": "aliyun_oss",
|
||||||
@@ -84,14 +102,20 @@
|
|||||||
"Jwt": {
|
"Jwt": {
|
||||||
"Issuer": "tiku-backend",
|
"Issuer": "tiku-backend",
|
||||||
"Audience": "tiku-api",
|
"Audience": "tiku-api",
|
||||||
"SigningKey": "",
|
"KeyId": "",
|
||||||
"AccessTokenMinutes": 30,
|
"PrivateKeyPem": "",
|
||||||
"RefreshTokenDays": 30,
|
"PublicKeys": {},
|
||||||
"ValidateSessions": true
|
"AccessTokenMinutes": 15,
|
||||||
|
"RefreshTokenDays": 30
|
||||||
},
|
},
|
||||||
"TenantSecrets": {
|
"TenantSecrets": {
|
||||||
"KeyId": "",
|
"KeyId": "",
|
||||||
"MasterKey": ""
|
"MasterKey": ""
|
||||||
|
},
|
||||||
|
"DataProtection": {
|
||||||
|
"ApplicationName": "Tiku.Api",
|
||||||
|
"CertificatePath": "",
|
||||||
|
"CertificatePassword": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
|
|
||||||
namespace Tiku.Application.Auth;
|
namespace Tiku.Application.Auth;
|
||||||
@@ -35,6 +36,7 @@ public sealed record TenantMembershipSummary(
|
|||||||
/// <param name="Phone">手机号。</param>
|
/// <param name="Phone">手机号。</param>
|
||||||
/// <param name="Email">邮箱。</param>
|
/// <param name="Email">邮箱。</param>
|
||||||
/// <param name="Name">用户显示名称。</param>
|
/// <param name="Name">用户显示名称。</param>
|
||||||
|
/// <param name="Realm">当前令牌的 tenant 或 platform 授权域。</param>
|
||||||
/// <param name="Tenant">当前登录租户成员摘要。</param>
|
/// <param name="Tenant">当前登录租户成员摘要。</param>
|
||||||
/// <param name="Tokens">认证令牌对。</param>
|
/// <param name="Tokens">认证令牌对。</param>
|
||||||
public sealed record AuthenticatedUser(
|
public sealed record AuthenticatedUser(
|
||||||
@@ -42,25 +44,47 @@ public sealed record AuthenticatedUser(
|
|||||||
string? Phone,
|
string? Phone,
|
||||||
string? Email,
|
string? Email,
|
||||||
string? Name,
|
string? Name,
|
||||||
TenantMembershipSummary Tenant,
|
AuthRealm Realm,
|
||||||
|
TenantMembershipSummary? Tenant,
|
||||||
AuthTokenPair Tokens);
|
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(
|
public sealed record PasswordLoginRequest(
|
||||||
Guid TenantId,
|
AuthRealm Realm,
|
||||||
|
Guid? TenantId,
|
||||||
string Phone,
|
string Phone,
|
||||||
string Password,
|
string Password,
|
||||||
string? IpAddress,
|
string? IpAddress,
|
||||||
string? UserAgent);
|
string? UserAgent);
|
||||||
|
|
||||||
public sealed record SmsLoginRequest(
|
public sealed record SmsLoginRequest(
|
||||||
Guid TenantId,
|
AuthRealm Realm,
|
||||||
|
Guid? TenantId,
|
||||||
string Phone,
|
string Phone,
|
||||||
string Code,
|
string Code,
|
||||||
string? IpAddress,
|
string? IpAddress,
|
||||||
string? UserAgent);
|
string? UserAgent);
|
||||||
|
|
||||||
public sealed record WechatLoginRequest(
|
public sealed record WechatLoginRequest(
|
||||||
Guid TenantId,
|
AuthRealm Realm,
|
||||||
|
Guid? TenantId,
|
||||||
string Code,
|
string Code,
|
||||||
string? IpAddress,
|
string? IpAddress,
|
||||||
string? UserAgent);
|
string? UserAgent);
|
||||||
@@ -73,6 +97,24 @@ public sealed record RefreshSessionRequest(
|
|||||||
public sealed record LogoutSessionRequest(
|
public sealed record LogoutSessionRequest(
|
||||||
string RefreshToken);
|
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(
|
public sealed record SmsSendResult(
|
||||||
Guid VerificationId,
|
Guid VerificationId,
|
||||||
DateTimeOffset ExpiresAt);
|
DateTimeOffset ExpiresAt);
|
||||||
@@ -82,4 +124,5 @@ public sealed record SendSmsCodeRequest(
|
|||||||
string Phone,
|
string Phone,
|
||||||
SmsPurpose Purpose,
|
SmsPurpose Purpose,
|
||||||
string? IpAddress,
|
string? IpAddress,
|
||||||
string? UserAgent);
|
string? UserAgent,
|
||||||
|
string? DeviceId = null);
|
||||||
|
|||||||
@@ -19,3 +19,6 @@ public sealed class SmsRateLimitedException()
|
|||||||
|
|
||||||
public sealed class AuthProviderNotConfiguredException(string provider)
|
public sealed class AuthProviderNotConfiguredException(string provider)
|
||||||
: AuthException("auth_provider_not_configured", $"The {provider} auth provider is not configured.");
|
: 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
|
public interface IAuthService
|
||||||
{
|
{
|
||||||
Task<AuthenticatedUser> LoginWithPasswordAsync(
|
Task<AuthenticationResult> LoginWithPasswordAsync(
|
||||||
PasswordLoginRequest request,
|
PasswordLoginRequest request,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<AuthenticatedUser> LoginWithSmsAsync(
|
Task<AuthenticationResult> LoginWithSmsAsync(
|
||||||
SmsLoginRequest request,
|
SmsLoginRequest request,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<AuthenticatedUser> LoginWithWechatWebAsync(
|
Task<AuthenticationResult> LoginWithWechatWebAsync(
|
||||||
WechatLoginRequest request,
|
WechatLoginRequest request,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<AuthenticatedUser> LoginWithWechatMiniAppAsync(
|
Task<AuthenticationResult> LoginWithWechatMiniAppAsync(
|
||||||
WechatLoginRequest request,
|
WechatLoginRequest request,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
@@ -25,4 +25,22 @@ public interface IAuthService
|
|||||||
Task LogoutAsync(
|
Task LogoutAsync(
|
||||||
LogoutSessionRequest request,
|
LogoutSessionRequest request,
|
||||||
CancellationToken cancellationToken = default);
|
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,
|
Guid sessionId,
|
||||||
string? phone,
|
string? phone,
|
||||||
string? email,
|
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 System.Text.Json;
|
||||||
|
using Tiku.Application.Security;
|
||||||
using Tiku.Domain.Operations;
|
using Tiku.Domain.Operations;
|
||||||
|
|
||||||
namespace Tiku.Application.Backoffice;
|
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(
|
public sealed record BackofficeBootstrap(
|
||||||
IReadOnlyCollection<BackofficePermissionItem> Permissions,
|
IReadOnlyCollection<BackofficePermissionItem> Permissions,
|
||||||
IReadOnlyCollection<BackofficeMenuItem> Menus,
|
IReadOnlyCollection<BackofficeMenuItem> Menus,
|
||||||
IReadOnlyCollection<BackofficeRoleItem> Roles);
|
IReadOnlyCollection<BackofficeRoleItem> Roles);
|
||||||
|
|
||||||
|
public sealed record BackofficeUiBootstrap(
|
||||||
|
IReadOnlyCollection<string> PermissionCodes,
|
||||||
|
IReadOnlyCollection<BackofficeMenuItem> Menus);
|
||||||
|
|
||||||
public sealed record BackofficePermissionItem(
|
public sealed record BackofficePermissionItem(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string Code,
|
string Code,
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
|
using Tiku.Application.Security;
|
||||||
|
|
||||||
namespace Tiku.Application.Backoffice;
|
namespace Tiku.Application.Backoffice;
|
||||||
|
|
||||||
public interface IBackofficeService
|
public interface IBackofficeService
|
||||||
{
|
{
|
||||||
|
Task<BackofficeUiBootstrap> GetTenantUiBootstrapAsync(
|
||||||
|
CurrentAccessSnapshot access,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<BackofficeUiBootstrap> GetPlatformUiBootstrapAsync(
|
||||||
|
CurrentAccessSnapshot access,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<BackofficeBootstrap> GetTenantBootstrapAsync(
|
Task<BackofficeBootstrap> GetTenantBootstrapAsync(
|
||||||
BackofficeActor actor,
|
BackofficeActor actor,
|
||||||
CancellationToken cancellationToken = default);
|
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 Guid? SessionId { get; private set; }
|
||||||
public string? Phone { get; private set; }
|
public string? Phone { get; private set; }
|
||||||
public string? Email { get; private set; }
|
public string? Email { get; private set; }
|
||||||
public string? TenantRole { get; private set; }
|
|
||||||
public bool IsAuthenticated { get; private set; }
|
public bool IsAuthenticated { get; private set; }
|
||||||
|
|
||||||
public void Load(ClaimsPrincipal principal)
|
public void Load(ClaimsPrincipal principal)
|
||||||
@@ -18,6 +17,5 @@ public sealed class CurrentUser : ICurrentUser
|
|||||||
SessionId = principal.FindGuid(TikuClaimTypes.SessionId);
|
SessionId = principal.FindGuid(TikuClaimTypes.SessionId);
|
||||||
Phone = principal.FindValue(TikuClaimTypes.Phone);
|
Phone = principal.FindValue(TikuClaimTypes.Phone);
|
||||||
Email = principal.FindValue(TikuClaimTypes.Email);
|
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; }
|
Guid? SessionId { get; }
|
||||||
string? Phone { get; }
|
string? Phone { get; }
|
||||||
string? Email { get; }
|
string? Email { get; }
|
||||||
string? TenantRole { get; }
|
|
||||||
bool IsAuthenticated { get; }
|
bool IsAuthenticated { get; }
|
||||||
void Load(ClaimsPrincipal principal);
|
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";
|
public string Audience { get; set; } = "tiku-api";
|
||||||
|
|
||||||
[System.ComponentModel.DataAnnotations.Required]
|
[System.ComponentModel.DataAnnotations.Required]
|
||||||
[System.ComponentModel.DataAnnotations.MinLength(32)]
|
public string KeyId { get; set; } = "development-ephemeral";
|
||||||
public string SigningKey { get; set; } = string.Empty;
|
|
||||||
|
public string PrivateKeyPem { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public Dictionary<string, string> PublicKeys { get; set; } = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
[System.ComponentModel.DataAnnotations.Range(1, 1440)]
|
[System.ComponentModel.DataAnnotations.Range(1, 1440)]
|
||||||
public int AccessTokenMinutes { get; set; } = 30;
|
public int AccessTokenMinutes { get; set; } = 15;
|
||||||
|
|
||||||
[System.ComponentModel.DataAnnotations.Range(1, 365)]
|
[System.ComponentModel.DataAnnotations.Range(1, 365)]
|
||||||
public int RefreshTokenDays { get; set; } = 30;
|
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 static class TikuClaimTypes
|
||||||
{
|
{
|
||||||
public const string UserId = "tiku:user_id";
|
public const string UserId = "sub";
|
||||||
public const string TenantId = "tiku:tenant_id";
|
public const string TenantId = "tid";
|
||||||
public const string SessionId = "tiku:session_id";
|
public const string SessionId = "sid";
|
||||||
public const string TenantRole = "tiku:tenant_role";
|
public const string Realm = "scope";
|
||||||
|
public const string Mfa = "amr";
|
||||||
public const string Phone = ClaimTypes.MobilePhone;
|
public const string Phone = ClaimTypes.MobilePhone;
|
||||||
public const string Email = ClaimTypes.Email;
|
public const string Email = ClaimTypes.Email;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,23 @@ public static class TikuPolicies
|
|||||||
{
|
{
|
||||||
public const string AuthenticatedUser = "authenticated_user";
|
public const string AuthenticatedUser = "authenticated_user";
|
||||||
public const string CurrentTenantMember = "current_tenant_member";
|
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 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,
|
TenantAdminAuditLogFilter filter,
|
||||||
CancellationToken cancellationToken = default);
|
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(
|
Task<ContentManagementResult<TenantBrandingItem>> UpsertBrandingAsync(
|
||||||
TenantAdminActor actor,
|
TenantAdminActor actor,
|
||||||
UpsertTenantBrandingCommand command,
|
UpsertTenantBrandingCommand command,
|
||||||
|
|||||||
@@ -6,7 +6,18 @@ using Tiku.Domain.Tenancy;
|
|||||||
|
|
||||||
namespace Tiku.Application.TenantAdmin;
|
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(
|
public sealed record TenantAdminClassFilter(
|
||||||
Guid? RegionId = null,
|
Guid? RegionId = null,
|
||||||
@@ -44,10 +55,6 @@ public sealed record TenantAdminAuditLogFilter(
|
|||||||
Guid? ActorUserId = null,
|
Guid? ActorUserId = null,
|
||||||
int? Limit = null);
|
int? Limit = null);
|
||||||
|
|
||||||
public sealed record TenantAdminRoleTemplateFilter(
|
|
||||||
string? Status = null,
|
|
||||||
int? Limit = null);
|
|
||||||
|
|
||||||
public sealed record TenantAdminBadgeFilter(
|
public sealed record TenantAdminBadgeFilter(
|
||||||
string? Category = null,
|
string? Category = null,
|
||||||
bool IncludeInactive = false,
|
bool IncludeInactive = false,
|
||||||
@@ -140,24 +147,8 @@ public sealed record UpsertTenantAdminMemberCommand(
|
|||||||
UserLookupCommand User,
|
UserLookupCommand User,
|
||||||
string? Role,
|
string? Role,
|
||||||
string? Status,
|
string? Status,
|
||||||
Guid? RoleTemplateId,
|
|
||||||
JsonElement Permissions,
|
|
||||||
string? PrimaryRole);
|
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(
|
public sealed record UpsertTenantBrandingCommand(
|
||||||
string BrandName,
|
string BrandName,
|
||||||
string? ShortName,
|
string? ShortName,
|
||||||
@@ -357,10 +348,6 @@ public sealed record TenantAdminMemberItem(
|
|||||||
Guid UserId,
|
Guid UserId,
|
||||||
TenantRole Role,
|
TenantRole Role,
|
||||||
MembershipStatus Status,
|
MembershipStatus Status,
|
||||||
JsonElement Permissions,
|
|
||||||
Guid? RoleTemplateId,
|
|
||||||
string? RoleTemplateCode,
|
|
||||||
string? RoleTemplateName,
|
|
||||||
string? LegacyRole,
|
string? LegacyRole,
|
||||||
TenantAdminUserSummary User,
|
TenantAdminUserSummary User,
|
||||||
DateTimeOffset CreatedAt,
|
DateTimeOffset CreatedAt,
|
||||||
@@ -379,37 +366,6 @@ public sealed record TenantAdminAuditLogItem(
|
|||||||
string? ActorPhone,
|
string? ActorPhone,
|
||||||
DateTimeOffset CreatedAt);
|
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(
|
public sealed record TenantBrandingItem(
|
||||||
Guid TenantId,
|
Guid TenantId,
|
||||||
string BrandName,
|
string BrandName,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||||
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
|||||||
@@ -1,12 +1,24 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Tiku.Infrastructure;
|
using Tiku.Infrastructure;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
using Tiku.Infrastructure.Bootstrap;
|
||||||
using Tiku.Application;
|
using Tiku.Application;
|
||||||
|
|
||||||
var builder = Host.CreateApplicationBuilder(args);
|
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 =
|
var connectionString =
|
||||||
builder.Configuration.GetConnectionString("Database") ??
|
builder.Configuration.GetConnectionString("Database") ??
|
||||||
Environment.GetEnvironmentVariable("DATABASE_URL") ??
|
Environment.GetEnvironmentVariable("DATABASE_URL") ??
|
||||||
@@ -15,8 +27,26 @@ var connectionString =
|
|||||||
|
|
||||||
builder.Services.AddApplication();
|
builder.Services.AddApplication();
|
||||||
builder.Services.AddInfrastructure(connectionString);
|
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();
|
using var host = builder.Build();
|
||||||
await using var scope = host.Services.CreateAsyncScope();
|
await using var scope = host.Services.CreateAsyncScope();
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
await dbContext.Database.MigrateAsync();
|
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 System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Tiku.Domain.Common;
|
using Tiku.Domain.Common;
|
||||||
|
|
||||||
namespace Tiku.Domain.Identity;
|
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? LegacyId { get; set; }
|
||||||
public string? Username { get; set; }
|
|
||||||
public string? Email { get; set; }
|
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
public string? Name { get; set; }
|
public string? Name { get; set; }
|
||||||
public string? AvatarUrl { get; set; }
|
public string? AvatarUrl { get; set; }
|
||||||
public string PrimaryRole { get; set; } = "student";
|
public string PrimaryRole { get; set; } = "student";
|
||||||
public int Score { get; set; }
|
public int Score { get; set; }
|
||||||
public DateTimeOffset? LastSeenAt { get; set; }
|
public DateTimeOffset? LastSeenAt { get; set; }
|
||||||
public string? LegacyPasswordHash { get; set; }
|
public UserStatus Status { get; set; } = UserStatus.Active;
|
||||||
public bool PasswordMigrationRequired { get; set; }
|
public bool ForcePasswordChange { get; set; }
|
||||||
public JsonElement RawProfile { get; set; } = JsonDefaults.Object();
|
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;
|
using Tiku.Domain.Common;
|
||||||
|
|
||||||
namespace Tiku.Domain.Identity;
|
namespace Tiku.Domain.Identity;
|
||||||
@@ -12,5 +11,4 @@ public sealed class UserIdentity : AuditableEntity
|
|||||||
public string? OpenId { get; set; }
|
public string? OpenId { get; set; }
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
public string? Email { 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;
|
using Tiku.Domain.Common;
|
||||||
|
|
||||||
namespace Tiku.Domain.Tenancy;
|
namespace Tiku.Domain.Tenancy;
|
||||||
@@ -6,16 +5,13 @@ namespace Tiku.Domain.Tenancy;
|
|||||||
public sealed class TenantMembership : AuditableTenantEntity
|
public sealed class TenantMembership : AuditableTenantEntity
|
||||||
{
|
{
|
||||||
public Guid UserId { get; set; }
|
public Guid UserId { get; set; }
|
||||||
public Guid? RoleTemplateId { get; set; }
|
|
||||||
public TenantRole Role { get; set; } = TenantRole.Student;
|
public TenantRole Role { get; set; } = TenantRole.Student;
|
||||||
public MembershipStatus Status { get; set; } = MembershipStatus.Active;
|
public MembershipStatus Status { get; set; } = MembershipStatus.Active;
|
||||||
public JsonElement Permissions { get; set; } = JsonDefaults.Object();
|
|
||||||
public string? LegacyRole { get; set; }
|
public string? LegacyRole { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum TenantRole
|
public enum TenantRole
|
||||||
{
|
{
|
||||||
PlatformAdmin,
|
|
||||||
TenantOwner,
|
TenantOwner,
|
||||||
TenantAdmin,
|
TenantAdmin,
|
||||||
TenantOperator,
|
TenantOperator,
|
||||||
|
|||||||
@@ -47,9 +47,9 @@ public sealed class SmsVerificationCode : Entity, ITenantOwned
|
|||||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
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 Guid? UserId { get; set; }
|
||||||
public string Provider { get; set; } = string.Empty;
|
public string Provider { get; set; } = string.Empty;
|
||||||
public string? Identifier { get; set; }
|
public string? Identifier { get; set; }
|
||||||
@@ -61,18 +61,46 @@ public sealed class AuthLoginEvent : Entity, ITenantOwned
|
|||||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
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 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 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 string Provider { get; set; } = string.Empty;
|
||||||
public DateTimeOffset ExpiresAt { get; set; }
|
public DateTimeOffset ExpiresAt { get; set; }
|
||||||
public DateTimeOffset? RevokedAt { get; set; }
|
public DateTimeOffset? RevokedAt { get; set; }
|
||||||
|
public string? RevokedReason { get; set; }
|
||||||
public string? IpAddress { get; set; }
|
public string? IpAddress { get; set; }
|
||||||
public string? UserAgent { get; set; }
|
public string? UserAgent { get; set; }
|
||||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
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 sealed class SmsSendRateLimit : ITenantOwned
|
||||||
{
|
{
|
||||||
public Guid TenantId { get; set; }
|
public Guid TenantId { get; set; }
|
||||||
@@ -83,24 +111,6 @@ public sealed class SmsSendRateLimit : ITenantOwned
|
|||||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
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 sealed class TenantClass : AuditableTenantEntity
|
||||||
{
|
{
|
||||||
public Guid? RegionId { get; set; }
|
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 SmsVerificationStatus { Pending, Sent, Verified, Expired, Blocked, Failed }
|
||||||
public enum AuthLoginResult { Sent, Success, Failed, Blocked }
|
public enum AuthLoginResult { Sent, Success, Failed, Blocked }
|
||||||
public enum SmsRateLimitDimension { Tenant, Phone, Ip, Device }
|
public enum SmsRateLimitDimension { Tenant, Phone, Ip, Device }
|
||||||
public enum TenantRoleTemplateStatus { Active, Disabled, Archived }
|
|
||||||
public enum TenantRecordStatus { Active, Disabled, Archived }
|
public enum TenantRecordStatus { Active, Disabled, Archived }
|
||||||
public enum TenantClassMemberType { Student, Teacher, Assistant, HeadTeacher }
|
public enum TenantClassMemberType { Student, Teacher, Assistant, HeadTeacher }
|
||||||
public enum TenantClassMemberStatus { Active, Disabled, Removed }
|
public enum TenantClassMemberStatus { Active, Disabled, Removed }
|
||||||
|
|||||||
@@ -6,4 +6,8 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Identity.Stores" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Tiku.Application.Auth;
|
using Tiku.Application.Auth;
|
||||||
using Tiku.Application.Tenancy;
|
using Tiku.Application.Tenancy;
|
||||||
using Tiku.Domain.Identity;
|
using Tiku.Domain.Identity;
|
||||||
@@ -10,9 +14,10 @@ namespace Tiku.Infrastructure.Auth;
|
|||||||
|
|
||||||
public sealed class AuthService(
|
public sealed class AuthService(
|
||||||
TikuDbContext dbContext,
|
TikuDbContext dbContext,
|
||||||
IPasswordHasher passwordHasher,
|
SignInManager<User> signInManager,
|
||||||
|
UserManager<User> userManager,
|
||||||
ISmsVerificationService smsVerificationService,
|
ISmsVerificationService smsVerificationService,
|
||||||
ISessionService sessionService,
|
IAuthSessionStore sessionStore,
|
||||||
IWechatOAuthClient wechatOAuthClient,
|
IWechatOAuthClient wechatOAuthClient,
|
||||||
ITenantExternalProviderConfigService providerConfigService) : IAuthService
|
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[] 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"];
|
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,
|
PasswordLoginRequest request,
|
||||||
CancellationToken cancellationToken = default)
|
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
|
var user = await dbContext.Users
|
||||||
.SingleOrDefaultAsync(entity => entity.Phone == phone, cancellationToken);
|
.SingleOrDefaultAsync(entity =>
|
||||||
var identity = user is null
|
entity.Phone == identifier ||
|
||||||
? null
|
entity.NormalizedEmail == normalizedEmail ||
|
||||||
: await dbContext.UserIdentities
|
entity.NormalizedUserName == normalizedUserName,
|
||||||
.SingleOrDefaultAsync(
|
cancellationToken);
|
||||||
entity =>
|
var passwordResult = user is null || user.Status != UserStatus.Active
|
||||||
entity.UserId == user.Id &&
|
? SignInResult.Failed
|
||||||
entity.Provider == PasswordProvider &&
|
: await signInManager.CheckPasswordSignInAsync(user, request.Password, lockoutOnFailure: true);
|
||||||
entity.ProviderSubject == phone,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
if (user is null ||
|
if (!passwordResult.Succeeded)
|
||||||
identity is null ||
|
|
||||||
!TryGetPasswordHash(identity.SecretPayload, out var passwordHash) ||
|
|
||||||
!passwordHasher.Verify(request.Password, passwordHash))
|
|
||||||
{
|
{
|
||||||
|
var loginResult = passwordResult.IsLockedOut
|
||||||
|
? AuthLoginResult.Blocked
|
||||||
|
: AuthLoginResult.Failed;
|
||||||
|
var failureCode = passwordResult.IsLockedOut
|
||||||
|
? "account_locked"
|
||||||
|
: "invalid_credentials";
|
||||||
await AddLoginEventAsync(
|
await AddLoginEventAsync(
|
||||||
request.TenantId,
|
request.TenantId,
|
||||||
user?.Id,
|
user?.Id,
|
||||||
PasswordProvider,
|
PasswordProvider,
|
||||||
phone,
|
identifier,
|
||||||
AuthLoginResult.Failed,
|
loginResult,
|
||||||
"invalid_credentials",
|
failureCode,
|
||||||
request.IpAddress,
|
request.IpAddress,
|
||||||
request.UserAgent,
|
request.UserAgent,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
@@ -60,16 +68,17 @@ public sealed class AuthService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return await CompleteSuccessfulLoginAsync(
|
return await CompleteSuccessfulLoginAsync(
|
||||||
|
request.Realm,
|
||||||
request.TenantId,
|
request.TenantId,
|
||||||
user,
|
user!,
|
||||||
PasswordProvider,
|
PasswordProvider,
|
||||||
phone,
|
identifier,
|
||||||
request.IpAddress,
|
request.IpAddress,
|
||||||
request.UserAgent,
|
request.UserAgent,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AuthenticatedUser> LoginWithSmsAsync(
|
public async Task<AuthenticationResult> LoginWithSmsAsync(
|
||||||
SmsLoginRequest request,
|
SmsLoginRequest request,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -79,8 +88,12 @@ public sealed class AuthService(
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if (!request.TenantId.HasValue)
|
||||||
|
{
|
||||||
|
throw new InvalidCredentialsException("tenant_required_for_sms");
|
||||||
|
}
|
||||||
await smsVerificationService.VerifyCodeAsync(
|
await smsVerificationService.VerifyCodeAsync(
|
||||||
request.TenantId,
|
request.TenantId.Value,
|
||||||
phone,
|
phone,
|
||||||
SmsPurpose.Login,
|
SmsPurpose.Login,
|
||||||
request.Code,
|
request.Code,
|
||||||
@@ -117,6 +130,7 @@ public sealed class AuthService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return await CompleteSuccessfulLoginAsync(
|
return await CompleteSuccessfulLoginAsync(
|
||||||
|
request.Realm,
|
||||||
request.TenantId,
|
request.TenantId,
|
||||||
user,
|
user,
|
||||||
SmsProvider,
|
SmsProvider,
|
||||||
@@ -126,7 +140,7 @@ public sealed class AuthService(
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<AuthenticatedUser> LoginWithWechatWebAsync(
|
public Task<AuthenticationResult> LoginWithWechatWebAsync(
|
||||||
WechatLoginRequest request,
|
WechatLoginRequest request,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -138,7 +152,7 @@ public sealed class AuthService(
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<AuthenticatedUser> LoginWithWechatMiniAppAsync(
|
public Task<AuthenticationResult> LoginWithWechatMiniAppAsync(
|
||||||
WechatLoginRequest request,
|
WechatLoginRequest request,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -154,90 +168,218 @@ public sealed class AuthService(
|
|||||||
RefreshSessionRequest request,
|
RefreshSessionRequest request,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (!sessionService.TryParseRefreshToken(request.RefreshToken, out var locator))
|
return await sessionStore.RotateAsync(
|
||||||
{
|
request.RefreshToken, request.IpAddress, request.UserAgent, cancellationToken);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LogoutAsync(
|
public async Task LogoutAsync(
|
||||||
LogoutSessionRequest request,
|
LogoutSessionRequest request,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (!sessionService.TryParseRefreshToken(request.RefreshToken, out var locator))
|
await sessionStore.RevokeFamilyAsync(request.RefreshToken, "logout", cancellationToken);
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<AuthenticatedUser> CompleteSuccessfulLoginAsync(
|
public async Task LogoutAllAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||||
Guid tenantId,
|
{
|
||||||
|
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,
|
User user,
|
||||||
string provider,
|
string provider,
|
||||||
string identifier,
|
string identifier,
|
||||||
@@ -245,36 +387,96 @@ public sealed class AuthService(
|
|||||||
string? userAgent,
|
string? userAgent,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var membership = await FindActiveMembershipAsync(tenantId, user.Id, cancellationToken);
|
if (user.Status != UserStatus.Active)
|
||||||
if (membership is null)
|
|
||||||
{
|
{
|
||||||
await AddLoginEventAsync(
|
await AddLoginEventAsync(
|
||||||
tenantId,
|
tenantId, user.Id, provider, identifier, AuthLoginResult.Failed,
|
||||||
user.Id,
|
"user_disabled", ipAddress, userAgent, cancellationToken);
|
||||||
provider,
|
throw new InvalidCredentialsException();
|
||||||
identifier,
|
}
|
||||||
AuthLoginResult.Failed,
|
|
||||||
"tenant_access_denied",
|
TenantMembership? membership = null;
|
||||||
ipAddress,
|
Tenant? tenant = null;
|
||||||
userAgent,
|
if (realm == AuthRealm.Tenant && tenantId.HasValue)
|
||||||
cancellationToken);
|
{
|
||||||
|
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();
|
throw new TenantAccessDeniedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
var tenant = await dbContext.Tenants.FindAsync([tenantId], cancellationToken)
|
if (user.ForcePasswordChange)
|
||||||
?? throw new TenantAccessDeniedException();
|
{
|
||||||
var tokens = await sessionService.IssueAsync(
|
return await CreateChallengeResultAsync(
|
||||||
user.Id,
|
user, realm, tenantId, AuthChallengePurpose.PasswordChange, provider,
|
||||||
user.Phone,
|
AuthenticationStatus.PasswordChangeRequired, ipAddress, userAgent, cancellationToken);
|
||||||
user.Email,
|
}
|
||||||
membership,
|
|
||||||
provider,
|
var requiresMfa = await HasBackendPermissionsAsync(realm, tenantId, user.Id, cancellationToken);
|
||||||
ipAddress,
|
if (requiresMfa)
|
||||||
userAgent,
|
{
|
||||||
|
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);
|
cancellationToken);
|
||||||
|
|
||||||
await AddLoginEventAsync(
|
await AddLoginEventAsync(
|
||||||
tenantId,
|
tenant?.Id,
|
||||||
user.Id,
|
user.Id,
|
||||||
provider,
|
provider,
|
||||||
identifier,
|
identifier,
|
||||||
@@ -284,28 +486,28 @@ public sealed class AuthService(
|
|||||||
userAgent,
|
userAgent,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
return new AuthenticatedUser(
|
var tenantSummary = tenant is not null && membership is not null
|
||||||
user.Id,
|
? new TenantMembershipSummary(tenant.Id, tenant.Name, membership.Role, membership.Status)
|
||||||
user.Phone,
|
: null;
|
||||||
user.Email,
|
return new AuthenticationResult(
|
||||||
user.Name,
|
AuthenticationStatus.Authenticated,
|
||||||
new TenantMembershipSummary(
|
new AuthenticatedUser(user.Id, user.Phone, user.Email, user.Name, realm, tenantSummary, tokens));
|
||||||
tenant.Id,
|
|
||||||
tenant.Name,
|
|
||||||
membership.Role,
|
|
||||||
membership.Status),
|
|
||||||
tokens);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<AuthenticatedUser> LoginWithWechatAsync(
|
private async Task<AuthenticationResult> LoginWithWechatAsync(
|
||||||
WechatLoginRequest request,
|
WechatLoginRequest request,
|
||||||
string provider,
|
string provider,
|
||||||
IReadOnlyList<string> providerAliases,
|
IReadOnlyList<string> providerAliases,
|
||||||
Func<WechatProviderOptions, string, CancellationToken, Task<WechatIdentity>> exchangeCodeAsync,
|
Func<WechatProviderOptions, string, CancellationToken, Task<WechatIdentity>> exchangeCodeAsync,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (request.Realm != AuthRealm.Tenant || !request.TenantId.HasValue)
|
||||||
|
{
|
||||||
|
throw new InvalidCredentialsException("tenant_realm_required_for_wechat");
|
||||||
|
}
|
||||||
|
|
||||||
var config = await LoadWechatProviderOptionsAsync(
|
var config = await LoadWechatProviderOptionsAsync(
|
||||||
request.TenantId,
|
request.TenantId.Value,
|
||||||
provider,
|
provider,
|
||||||
providerAliases,
|
providerAliases,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
@@ -338,11 +540,12 @@ public sealed class AuthService(
|
|||||||
identity,
|
identity,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
await EnsureTenantMembershipAsync(
|
await EnsureTenantMembershipAsync(
|
||||||
request.TenantId,
|
request.TenantId.Value,
|
||||||
user.Id,
|
user.Id,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
return await CompleteSuccessfulLoginAsync(
|
return await CompleteSuccessfulLoginAsync(
|
||||||
|
request.Realm,
|
||||||
request.TenantId,
|
request.TenantId,
|
||||||
user,
|
user,
|
||||||
provider,
|
provider,
|
||||||
@@ -438,7 +641,6 @@ public sealed class AuthService(
|
|||||||
existingIdentity.UserId = user.Id;
|
existingIdentity.UserId = user.Id;
|
||||||
existingIdentity.OpenId = wechatIdentity.OpenId;
|
existingIdentity.OpenId = wechatIdentity.OpenId;
|
||||||
existingIdentity.UnionId = wechatIdentity.UnionId;
|
existingIdentity.UnionId = wechatIdentity.UnionId;
|
||||||
existingIdentity.SecretPayload = CreateWechatSecretPayload(appId, wechatIdentity);
|
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
@@ -521,8 +723,111 @@ public sealed class AuthService(
|
|||||||
.FirstOrDefaultAsync(cancellationToken);
|
.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(
|
private async Task AddLoginEventAsync(
|
||||||
Guid tenantId,
|
Guid? tenantId,
|
||||||
Guid? userId,
|
Guid? userId,
|
||||||
string provider,
|
string provider,
|
||||||
string? identifier,
|
string? identifier,
|
||||||
@@ -547,15 +852,6 @@ public sealed class AuthService(
|
|||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
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)
|
private static string? GetJsonString(JsonElement element, params string[] names)
|
||||||
{
|
{
|
||||||
if (element.ValueKind != JsonValueKind.Object)
|
if (element.ValueKind != JsonValueKind.Object)
|
||||||
@@ -578,19 +874,13 @@ public sealed class AuthService(
|
|||||||
|
|
||||||
private static JsonElement CreateWechatRawProfile(WechatIdentity identity)
|
private static JsonElement CreateWechatRawProfile(WechatIdentity identity)
|
||||||
{
|
{
|
||||||
using var document = JsonDocument.Parse(identity.RawJson);
|
return JsonSerializer.SerializeToElement(new
|
||||||
return document.RootElement.Clone();
|
{
|
||||||
|
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.Application.Auth;
|
||||||
|
using Tiku.Domain.Tenancy;
|
||||||
|
|
||||||
namespace Tiku.Infrastructure.Auth;
|
namespace Tiku.Infrastructure.Auth;
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IId
|
|||||||
{
|
{
|
||||||
"password" => await authService.LoginWithPasswordAsync(
|
"password" => await authService.LoginWithPasswordAsync(
|
||||||
new PasswordLoginRequest(
|
new PasswordLoginRequest(
|
||||||
|
AuthRealm.Tenant,
|
||||||
request.TenantId,
|
request.TenantId,
|
||||||
request.Identifier,
|
request.Identifier,
|
||||||
request.Secret,
|
request.Secret,
|
||||||
@@ -21,6 +23,7 @@ internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IId
|
|||||||
cancellationToken),
|
cancellationToken),
|
||||||
"sms" => await authService.LoginWithSmsAsync(
|
"sms" => await authService.LoginWithSmsAsync(
|
||||||
new SmsLoginRequest(
|
new SmsLoginRequest(
|
||||||
|
AuthRealm.Tenant,
|
||||||
request.TenantId,
|
request.TenantId,
|
||||||
request.Identifier,
|
request.Identifier,
|
||||||
request.Secret,
|
request.Secret,
|
||||||
@@ -29,6 +32,7 @@ internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IId
|
|||||||
cancellationToken),
|
cancellationToken),
|
||||||
"wechat_web" => await authService.LoginWithWechatWebAsync(
|
"wechat_web" => await authService.LoginWithWechatWebAsync(
|
||||||
new WechatLoginRequest(
|
new WechatLoginRequest(
|
||||||
|
AuthRealm.Tenant,
|
||||||
request.TenantId,
|
request.TenantId,
|
||||||
request.Secret,
|
request.Secret,
|
||||||
request.IpAddress,
|
request.IpAddress,
|
||||||
@@ -36,6 +40,7 @@ internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IId
|
|||||||
cancellationToken),
|
cancellationToken),
|
||||||
"wechat_miniapp" => await authService.LoginWithWechatMiniAppAsync(
|
"wechat_miniapp" => await authService.LoginWithWechatMiniAppAsync(
|
||||||
new WechatLoginRequest(
|
new WechatLoginRequest(
|
||||||
|
AuthRealm.Tenant,
|
||||||
request.TenantId,
|
request.TenantId,
|
||||||
request.Secret,
|
request.Secret,
|
||||||
request.IpAddress,
|
request.IpAddress,
|
||||||
@@ -44,11 +49,13 @@ internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IId
|
|||||||
_ => throw new AuthProviderNotConfiguredException(provider)
|
_ => throw new AuthProviderNotConfiguredException(provider)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var user = authenticated.User ?? throw new InvalidAuthChallengeException("interactive_authentication_required");
|
||||||
|
|
||||||
return new IdentityProviderResult(
|
return new IdentityProviderResult(
|
||||||
provider,
|
provider,
|
||||||
authenticated.UserId.ToString("N"),
|
user.UserId.ToString("N"),
|
||||||
authenticated.Phone,
|
user.Phone,
|
||||||
authenticated.Email,
|
user.Email,
|
||||||
authenticated.Name);
|
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 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 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();
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Tiku.Domain.Common;
|
using Microsoft.Extensions.Options;
|
||||||
using Tiku.Application.Auth;
|
using Tiku.Application.Auth;
|
||||||
|
using Tiku.Domain.Common;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
@@ -8,44 +12,33 @@ namespace Tiku.Infrastructure.Auth;
|
|||||||
|
|
||||||
public sealed class SmsVerificationService(
|
public sealed class SmsVerificationService(
|
||||||
TikuDbContext dbContext,
|
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 TimeSpan CodeLifetime = TimeSpan.FromMinutes(10);
|
||||||
|
private static readonly SemaphoreSlim InMemoryRateLimitLock = new(1, 1);
|
||||||
|
private readonly SmsSecurityOptions options = securityOptions.Value;
|
||||||
|
|
||||||
public async Task<SmsSendResult> CreateCodeAsync(
|
public async Task<SmsSendResult> CreateCodeAsync(
|
||||||
SendSmsCodeRequest request,
|
SendSmsCodeRequest request,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
EnsureValidOptions();
|
||||||
|
|
||||||
var phone = SmsCodeHashing.NormalizePhone(request.Phone);
|
var phone = SmsCodeHashing.NormalizePhone(request.Phone);
|
||||||
var bucketStart = TruncateToHour(DateTimeOffset.UtcNow);
|
var now = DateTimeOffset.UtcNow;
|
||||||
var scopeHash = SmsCodeHashing.Hash(request.TenantId, phone, request.Purpose, "phone-bucket");
|
await ConsumeRateLimitsAsync(request, phone, now, cancellationToken);
|
||||||
var rateLimit = await dbContext.SmsSendRateLimits.FindAsync(
|
|
||||||
[request.TenantId, SmsRateLimitDimension.Phone, scopeHash, bucketStart],
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
if (rateLimit is null)
|
var code = RandomNumberGenerator
|
||||||
{
|
.GetInt32(100000, 1000000)
|
||||||
rateLimit = new SmsSendRateLimit
|
.ToString(CultureInfo.InvariantCulture);
|
||||||
{
|
var codeHash = SmsCodeHashing.Hash(
|
||||||
TenantId = request.TenantId,
|
request.TenantId,
|
||||||
Dimension = SmsRateLimitDimension.Phone,
|
phone,
|
||||||
ScopeHash = scopeHash,
|
request.Purpose,
|
||||||
BucketStart = bucketStart
|
code,
|
||||||
};
|
options.CodePepper);
|
||||||
dbContext.SmsSendRateLimits.Add(rateLimit);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
SmsProviderSendResult sendResult;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -69,18 +62,38 @@ public sealed class SmsVerificationService(
|
|||||||
CodeHash = codeHash,
|
CodeHash = codeHash,
|
||||||
Provider = "failed",
|
Provider = "failed",
|
||||||
Status = SmsVerificationStatus.Failed,
|
Status = SmsVerificationStatus.Failed,
|
||||||
ExpiresAt = DateTimeOffset.UtcNow,
|
ExpiresAt = now,
|
||||||
IpAddress = request.IpAddress,
|
IpAddress = request.IpAddress,
|
||||||
UserAgent = request.UserAgent,
|
UserAgent = request.UserAgent,
|
||||||
Metadata = JsonDefaults.Object()
|
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);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
throw exception is SmsProviderException
|
throw exception is SmsProviderException
|
||||||
? exception
|
? 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
|
var verification = new SmsVerificationCode
|
||||||
{
|
{
|
||||||
TenantId = request.TenantId,
|
TenantId = request.TenantId,
|
||||||
@@ -89,12 +102,29 @@ public sealed class SmsVerificationService(
|
|||||||
CodeHash = codeHash,
|
CodeHash = codeHash,
|
||||||
Provider = sendResult.Provider,
|
Provider = sendResult.Provider,
|
||||||
Status = SmsVerificationStatus.Sent,
|
Status = SmsVerificationStatus.Sent,
|
||||||
ExpiresAt = DateTimeOffset.UtcNow.Add(CodeLifetime),
|
ExpiresAt = now.Add(CodeLifetime),
|
||||||
IpAddress = request.IpAddress,
|
IpAddress = request.IpAddress,
|
||||||
UserAgent = request.UserAgent
|
UserAgent = request.UserAgent
|
||||||
};
|
};
|
||||||
|
|
||||||
dbContext.SmsVerificationCodes.Add(verification);
|
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);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
return new SmsSendResult(verification.Id, verification.ExpiresAt);
|
return new SmsSendResult(verification.Id, verification.ExpiresAt);
|
||||||
@@ -107,35 +137,346 @@ public sealed class SmsVerificationService(
|
|||||||
string code,
|
string code,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
EnsureValidOptions();
|
||||||
|
|
||||||
var normalizedPhone = SmsCodeHashing.NormalizePhone(phone);
|
var normalizedPhone = SmsCodeHashing.NormalizePhone(phone);
|
||||||
var now = DateTimeOffset.UtcNow;
|
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
|
var verification = await dbContext.SmsVerificationCodes
|
||||||
|
.AsNoTracking()
|
||||||
.Where(entity =>
|
.Where(entity =>
|
||||||
entity.TenantId == tenantId &&
|
entity.TenantId == tenantId &&
|
||||||
entity.Phone == normalizedPhone &&
|
entity.Phone == normalizedPhone &&
|
||||||
entity.Purpose == purpose &&
|
entity.Purpose == purpose &&
|
||||||
entity.ConsumedAt == null)
|
entity.ConsumedAt == null &&
|
||||||
|
entity.Status == SmsVerificationStatus.Sent)
|
||||||
.OrderByDescending(entity => entity.CreatedAt)
|
.OrderByDescending(entity => entity.CreatedAt)
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (verification is null ||
|
if (verification is null)
|
||||||
verification.ExpiresAt <= now ||
|
|
||||||
verification.Status != SmsVerificationStatus.Sent)
|
|
||||||
{
|
{
|
||||||
throw new InvalidCredentialsException("invalid_sms_code");
|
throw new InvalidCredentialsException("invalid_sms_code");
|
||||||
}
|
}
|
||||||
|
|
||||||
verification.Attempts++;
|
if (verification.ExpiresAt <= now)
|
||||||
if (!string.Equals(verification.CodeHash, codeHash, StringComparison.Ordinal))
|
|
||||||
{
|
{
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await MarkExpiredAsync(verification.Id, now, cancellationToken);
|
||||||
throw new InvalidCredentialsException("invalid_sms_code");
|
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.Status = SmsVerificationStatus.Verified;
|
||||||
verification.ConsumedAt = now;
|
verification.ConsumedAt = now;
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
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)
|
private static DateTimeOffset TruncateToHour(DateTimeOffset value)
|
||||||
@@ -149,4 +490,9 @@ public sealed class SmsVerificationService(
|
|||||||
0,
|
0,
|
||||||
value.Offset);
|
value.Offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record RateLimitSpec(
|
||||||
|
SmsRateLimitDimension Dimension,
|
||||||
|
string ScopeHash,
|
||||||
|
int Maximum);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text;
|
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
|
||||||
using Tiku.Application.Auth;
|
using Tiku.Application.Auth;
|
||||||
using Tiku.Application.Security;
|
using Tiku.Application.Security;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
|
|
||||||
namespace Tiku.Infrastructure.Auth;
|
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;
|
private readonly JwtOptions options = options.Value;
|
||||||
|
|
||||||
@@ -18,17 +16,30 @@ public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
|
|||||||
Guid sessionId,
|
Guid sessionId,
|
||||||
string? phone,
|
string? phone,
|
||||||
string? email,
|
string? email,
|
||||||
TenantMembership membership)
|
AuthRealm realm,
|
||||||
|
Guid? tenantId,
|
||||||
|
bool mfaSatisfied)
|
||||||
{
|
{
|
||||||
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(options.AccessTokenMinutes);
|
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(options.AccessTokenMinutes);
|
||||||
var claims = new List<Claim>
|
var claims = new List<Claim>
|
||||||
{
|
{
|
||||||
new(TikuClaimTypes.UserId, userId.ToString()),
|
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
new(TikuClaimTypes.SessionId, sessionId.ToString()),
|
new(TikuClaimTypes.SessionId, sessionId.ToString()),
|
||||||
new(TikuClaimTypes.TenantId, membership.TenantId.ToString()),
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")),
|
||||||
new(TikuClaimTypes.TenantRole, membership.Role.ToString())
|
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))
|
if (!string.IsNullOrWhiteSpace(phone))
|
||||||
{
|
{
|
||||||
claims.Add(new Claim(TikuClaimTypes.Phone, 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));
|
claims.Add(new Claim(TikuClaimTypes.Email, email));
|
||||||
}
|
}
|
||||||
|
|
||||||
var credentials = new SigningCredentials(
|
|
||||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.SigningKey)),
|
|
||||||
SecurityAlgorithms.HmacSha256);
|
|
||||||
var token = new JwtSecurityToken(
|
var token = new JwtSecurityToken(
|
||||||
options.Issuer,
|
options.Issuer,
|
||||||
options.Audience,
|
options.Audience,
|
||||||
claims,
|
claims,
|
||||||
expires: expiresAt.UtcDateTime,
|
expires: expiresAt.UtcDateTime,
|
||||||
signingCredentials: credentials);
|
signingCredentials: keyRing.SigningCredentials);
|
||||||
|
|
||||||
return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt);
|
return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Tiku.Application.Backoffice;
|
using Tiku.Application.Backoffice;
|
||||||
|
using Tiku.Application.Security;
|
||||||
using Tiku.Domain.Common;
|
using Tiku.Domain.Common;
|
||||||
using Tiku.Domain.Operations;
|
using Tiku.Domain.Operations;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
@@ -14,19 +15,23 @@ internal sealed class BackofficeService(
|
|||||||
{
|
{
|
||||||
private static readonly BuiltinPermission[] BuiltinPermissions =
|
private static readonly BuiltinPermission[] BuiltinPermissions =
|
||||||
[
|
[
|
||||||
new("tenant:dashboard:view", "租户总览", BackendPermissionArea.Tenant, "tenant_dashboard"),
|
new(BackendPermissions.TenantDashboardView, "租户总览", BackendPermissionArea.Tenant, "tenant_dashboard"),
|
||||||
new("tenant:staff:manage", "租户员工管理", BackendPermissionArea.Tenant, "tenant_staff"),
|
new(BackendPermissions.TenantStaffManage, "租户员工管理", BackendPermissionArea.Tenant, "tenant_staff"),
|
||||||
new("tenant:role:manage", "租户角色权限管理", BackendPermissionArea.Tenant, "tenant_staff"),
|
new(BackendPermissions.TenantRoleManage, "租户角色权限管理", BackendPermissionArea.Tenant, "tenant_staff"),
|
||||||
new("tenant:student:manage", "学生与班级管理", BackendPermissionArea.Tenant, "tenant_student"),
|
new(BackendPermissions.TenantStudentManage, "学生与班级管理", BackendPermissionArea.Tenant, "tenant_student"),
|
||||||
new("tenant:content:manage", "租户内容管理", BackendPermissionArea.Tenant, "tenant_content"),
|
new(BackendPermissions.TenantContentManage, "租户内容管理", BackendPermissionArea.Tenant, "tenant_content"),
|
||||||
new("tenant:provider:manage", "租户外部服务配置", BackendPermissionArea.Tenant, "tenant_provider"),
|
new(BackendPermissions.TenantSettingsManage, "租户设置管理", BackendPermissionArea.Tenant, "tenant_settings"),
|
||||||
new("tenant:commerce:operate", "租户交易运营", BackendPermissionArea.Tenant, "tenant_commerce"),
|
new(BackendPermissions.TenantProviderManage, "租户外部服务配置", BackendPermissionArea.Tenant, "tenant_provider"),
|
||||||
new("platform:dashboard:view", "平台总览", BackendPermissionArea.Platform, "platform_dashboard"),
|
new(BackendPermissions.TenantCommerceOperate, "租户交易运营", BackendPermissionArea.Tenant, "tenant_commerce"),
|
||||||
new("platform:tenant:manage", "平台租户管理", BackendPermissionArea.Platform, "platform_tenant"),
|
new(BackendPermissions.TenantCrmManage, "租户客户管理", BackendPermissionArea.Tenant, "tenant_crm"),
|
||||||
new("platform:staff:manage", "平台员工管理", BackendPermissionArea.Platform, "platform_staff"),
|
new(BackendPermissions.TenantCommissionManage, "租户佣金管理", BackendPermissionArea.Tenant, "tenant_commission"),
|
||||||
new("platform:role:manage", "平台角色权限管理", BackendPermissionArea.Platform, "platform_staff"),
|
new(BackendPermissions.TenantJobManage, "租户任务管理", BackendPermissionArea.Tenant, "tenant_job"),
|
||||||
new("platform:question-bank:manage", "平台公共题库运营", BackendPermissionArea.Platform, "platform_content"),
|
new(BackendPermissions.PlatformDashboardView, "平台总览", BackendPermissionArea.Platform, "platform_dashboard"),
|
||||||
new("platform:audit:view", "平台审计查询", BackendPermissionArea.Platform, "platform_audit"),
|
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:refund:approve", "退款审核", BackendPermissionArea.Both, "commerce"),
|
||||||
new("commerce:reconciliation:manage", "对账管理", BackendPermissionArea.Both, "commerce"),
|
new("commerce:reconciliation:manage", "对账管理", BackendPermissionArea.Both, "commerce"),
|
||||||
new("commerce:adjustment: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)
|
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(
|
public async Task<BackofficeBootstrap> GetTenantBootstrapAsync(
|
||||||
BackofficeActor actor,
|
BackofficeActor actor,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -193,6 +235,21 @@ internal sealed class BackofficeService(
|
|||||||
{
|
{
|
||||||
var tenantId = RequireTenantAdmin(actor);
|
var tenantId = RequireTenantAdmin(actor);
|
||||||
var roleIds = command.RoleIds.Distinct().ToArray();
|
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(
|
var count = await dbContext.TenantBackendRoles.CountAsync(
|
||||||
item => item.TenantId == tenantId && roleIds.Contains(item.Id) && item.Status == BackendRoleStatus.Active,
|
item => item.TenantId == tenantId && roleIds.Contains(item.Id) && item.Status == BackendRoleStatus.Active,
|
||||||
cancellationToken);
|
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)
|
private async Task ValidateMenuCodesAsync(string[] codes, BackendPermissionArea area, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var count = await dbContext.BackendMenus.CountAsync(
|
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 System.Text.Json;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Tiku.Application.Commerce;
|
using Tiku.Application.Commerce;
|
||||||
|
using Tiku.Application.Security;
|
||||||
using Tiku.Application.Tenancy;
|
using Tiku.Application.Tenancy;
|
||||||
using Tiku.Domain.Catalog;
|
using Tiku.Domain.Catalog;
|
||||||
using Tiku.Domain.Commerce;
|
using Tiku.Domain.Commerce;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
using Tiku.Infrastructure.Security;
|
||||||
|
|
||||||
namespace Tiku.Infrastructure.Commerce;
|
namespace Tiku.Infrastructure.Commerce;
|
||||||
|
|
||||||
internal sealed class CommerceAdminService(
|
internal sealed class CommerceAdminService(
|
||||||
TikuDbContext dbContext,
|
TikuDbContext dbContext,
|
||||||
ITenantSecretProtector tenantSecretProtector,
|
ITenantSecretProtector tenantSecretProtector,
|
||||||
ITenantExternalProviderConfigService providerConfigService) : ICommerceAdminService
|
ITenantExternalProviderConfigService providerConfigService,
|
||||||
|
ICurrentAccessContext currentAccessContext) : ICommerceAdminService
|
||||||
{
|
{
|
||||||
public async Task<IReadOnlyCollection<TenantPaymentProviderItem>> GetPaymentAccountsAsync(
|
public async Task<IReadOnlyCollection<TenantPaymentProviderItem>> GetPaymentAccountsAsync(
|
||||||
CommerceAdminActor actor,
|
CommerceAdminActor actor,
|
||||||
@@ -105,8 +108,14 @@ internal sealed class CommerceAdminService(
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await AssertAdminAsync(actor, cancellationToken);
|
await AssertAdminAsync(actor, cancellationToken);
|
||||||
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||||
|
var regionIds = scope.RegionIds.ToArray();
|
||||||
var orders = dbContext.Orders.AsNoTracking()
|
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))
|
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||||
{
|
{
|
||||||
orders = orders.Where(item => item.Status == ParseOrderStatus(query.Status));
|
orders = orders.Where(item => item.Status == ParseOrderStatus(query.Status));
|
||||||
@@ -125,8 +134,16 @@ internal sealed class CommerceAdminService(
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await AssertAdminAsync(actor, cancellationToken);
|
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()
|
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 }
|
on new { payment.TenantId, payment.OrderId } equals new { order.TenantId, OrderId = order.Id }
|
||||||
where payment.TenantId == actor.TenantId
|
where payment.TenantId == actor.TenantId
|
||||||
select new { payment, order.OrderNo };
|
select new { payment, order.OrderNo };
|
||||||
@@ -568,8 +585,19 @@ internal sealed class CommerceAdminService(
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await AssertAdminAsync(actor, cancellationToken);
|
await AssertAdminAsync(actor, cancellationToken);
|
||||||
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||||
|
var regionIds = scope.RegionIds.ToArray();
|
||||||
var refunds = dbContext.CommerceRefundRequests.AsNoTracking()
|
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))
|
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||||
{
|
{
|
||||||
refunds = refunds.Where(item => item.Status == ParseRefundStatus(query.Status));
|
refunds = refunds.Where(item => item.Status == ParseRefundStatus(query.Status));
|
||||||
@@ -588,9 +616,16 @@ internal sealed class CommerceAdminService(
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await AssertAdminAsync(actor, cancellationToken);
|
await AssertAdminAsync(actor, cancellationToken);
|
||||||
var order = await dbContext.Orders.SingleOrDefaultAsync(
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||||
item => item.TenantId == actor.TenantId && item.Id == command.OrderId,
|
var regionIds = scope.RegionIds.ToArray();
|
||||||
cancellationToken) ?? throw new CommerceException("Order was not found.", "order_not_found");
|
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))
|
if (order.Status is not (OrderStatus.Paid or OrderStatus.PartiallyRefunded))
|
||||||
{
|
{
|
||||||
throw new CommerceException("Only paid orders can be refunded.", "order_not_refundable");
|
throw new CommerceException("Only paid orders can be refunded.", "order_not_refundable");
|
||||||
@@ -639,9 +674,21 @@ internal sealed class CommerceAdminService(
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await AssertAdminAsync(actor, cancellationToken);
|
await AssertAdminAsync(actor, cancellationToken);
|
||||||
var refund = await dbContext.CommerceRefundRequests.SingleOrDefaultAsync(
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||||
item => item.TenantId == actor.TenantId && item.Id == command.RefundRequestId,
|
var regionIds = scope.RegionIds.ToArray();
|
||||||
cancellationToken) ?? throw new CommerceException("Refund request was not found.", "refund_not_found");
|
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;
|
var fromStatus = refund.Status;
|
||||||
if (!IsAllowedRefundTransition(fromStatus, command.Status))
|
if (!IsAllowedRefundTransition(fromStatus, command.Status))
|
||||||
{
|
{
|
||||||
@@ -687,6 +734,25 @@ internal sealed class CommerceAdminService(
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await AssertAdminAsync(actor, cancellationToken);
|
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()
|
var items = await dbContext.CommerceRefundEvents.AsNoTracking()
|
||||||
.Where(item => item.TenantId == actor.TenantId && item.RefundRequestId == refundRequestId)
|
.Where(item => item.TenantId == actor.TenantId && item.RefundRequestId == refundRequestId)
|
||||||
.OrderBy(item => item.CreatedAt)
|
.OrderBy(item => item.CreatedAt)
|
||||||
@@ -807,20 +873,24 @@ internal sealed class CommerceAdminService(
|
|||||||
|
|
||||||
private async Task AssertAdminAsync(CommerceAdminActor actor, CancellationToken cancellationToken)
|
private async Task AssertAdminAsync(CommerceAdminActor actor, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var isAdmin = await dbContext.TenantMemberships.AnyAsync(item =>
|
var access = await currentAccessContext.GetAsync(cancellationToken);
|
||||||
item.TenantId == actor.TenantId &&
|
if (!access.IsCurrentTenantMember ||
|
||||||
item.UserId == actor.UserId &&
|
access.UserId != actor.UserId ||
|
||||||
item.Status == MembershipStatus.Active &&
|
access.TenantId != actor.TenantId ||
|
||||||
(item.Role == TenantRole.PlatformAdmin ||
|
!access.HasTenantPermission(BackendPermissions.TenantCommerceOperate))
|
||||||
item.Role == TenantRole.TenantOwner ||
|
|
||||||
item.Role == TenantRole.TenantAdmin),
|
|
||||||
cancellationToken);
|
|
||||||
if (!isAdmin)
|
|
||||||
{
|
{
|
||||||
throw new CommerceException("Tenant admin access is required.", "tenant_admin_access_denied");
|
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) =>
|
private static TenantPaymentProviderItem ToPaymentAccountItem(TenantExternalProviderItem item) =>
|
||||||
new(
|
new(
|
||||||
item.Id,
|
item.Id,
|
||||||
|
|||||||
@@ -4,17 +4,20 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Tiku.Application.Catalog;
|
using Tiku.Application.Catalog;
|
||||||
using Tiku.Application.Content;
|
using Tiku.Application.Content;
|
||||||
using Tiku.Application.QuestionBanks;
|
using Tiku.Application.QuestionBanks;
|
||||||
|
using Tiku.Application.Security;
|
||||||
using Tiku.Domain.Catalog;
|
using Tiku.Domain.Catalog;
|
||||||
using Tiku.Domain.Common;
|
using Tiku.Domain.Common;
|
||||||
using Tiku.Domain.Content;
|
using Tiku.Domain.Content;
|
||||||
using Tiku.Domain.QuestionBanks;
|
using Tiku.Domain.QuestionBanks;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
using Tiku.Infrastructure.Security;
|
||||||
|
|
||||||
namespace Tiku.Infrastructure.Content;
|
namespace Tiku.Infrastructure.Content;
|
||||||
|
|
||||||
public sealed class ContentManagementService(
|
public sealed class ContentManagementService(
|
||||||
TikuDbContext dbContext,
|
TikuDbContext dbContext,
|
||||||
IQuestionReferenceService questionReferenceService) : IContentManagementService
|
IQuestionReferenceService questionReferenceService,
|
||||||
|
ICurrentAccessContext currentAccessContext) : IContentManagementService
|
||||||
{
|
{
|
||||||
private const int DefaultLimit = 100;
|
private const int DefaultLimit = 100;
|
||||||
private const int MaxLimit = 1000;
|
private const int MaxLimit = 1000;
|
||||||
@@ -24,9 +27,15 @@ public sealed class ContentManagementService(
|
|||||||
ContentManagementFilter filter,
|
ContentManagementFilter filter,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||||
|
var regionIds = scope.RegionIds.ToArray();
|
||||||
var query = dbContext.ContentEntries
|
var query = dbContext.ContentEntries
|
||||||
.AsNoTracking()
|
.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)
|
if (!filter.IncludeInactive)
|
||||||
{
|
{
|
||||||
@@ -67,6 +76,7 @@ public sealed class ContentManagementService(
|
|||||||
UpsertContentEntryCommand command,
|
UpsertContentEntryCommand command,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
||||||
await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken);
|
await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken);
|
||||||
|
|
||||||
@@ -81,6 +91,21 @@ public sealed class ContentManagementService(
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
var isNew = entry is null;
|
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
|
entry ??= new ContentEntry
|
||||||
{
|
{
|
||||||
Id = command.Id ?? Guid.NewGuid(),
|
Id = command.Id ?? Guid.NewGuid(),
|
||||||
@@ -117,14 +142,21 @@ public sealed class ContentManagementService(
|
|||||||
ContentManagementFilter filter,
|
ContentManagementFilter filter,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||||
if (!filter.EntryId.HasValue)
|
if (!filter.EntryId.HasValue)
|
||||||
{
|
{
|
||||||
throw new ContentManagementException("entryId is required.", "entry_id_required");
|
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
|
var query = dbContext.ContentNodes
|
||||||
.AsNoTracking()
|
.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)
|
if (!filter.IncludeInactive)
|
||||||
{
|
{
|
||||||
@@ -178,9 +210,11 @@ public sealed class ContentManagementService(
|
|||||||
UpsertContentNodeCommand command,
|
UpsertContentNodeCommand command,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
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 AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken);
|
||||||
|
await AssertNodeAsync(actor, scope, command.ParentId, cancellationToken);
|
||||||
|
|
||||||
var nodeKey = Normalize(command.NodeKey) ??
|
var nodeKey = Normalize(command.NodeKey) ??
|
||||||
Normalize(command.Id?.ToString("N")) ??
|
Normalize(command.Id?.ToString("N")) ??
|
||||||
@@ -193,6 +227,21 @@ public sealed class ContentManagementService(
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
var isNew = node is null;
|
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
|
node ??= new ContentNode
|
||||||
{
|
{
|
||||||
Id = command.Id ?? Guid.NewGuid(),
|
Id = command.Id ?? Guid.NewGuid(),
|
||||||
@@ -246,9 +295,15 @@ public sealed class ContentManagementService(
|
|||||||
ContentManagementFilter filter,
|
ContentManagementFilter filter,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||||
|
var regionIds = scope.RegionIds.ToArray();
|
||||||
var query = dbContext.QuestionCollections
|
var query = dbContext.QuestionCollections
|
||||||
.AsNoTracking()
|
.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)
|
if (!filter.IncludeInactive)
|
||||||
{
|
{
|
||||||
@@ -296,10 +351,11 @@ public sealed class ContentManagementService(
|
|||||||
UpsertQuestionCollectionCommand command,
|
UpsertQuestionCollectionCommand command,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
||||||
await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken);
|
await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken);
|
||||||
await AssertEntryAsync(actor.TenantId, command.EntryId, cancellationToken);
|
await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken);
|
||||||
await AssertNodeAsync(actor.TenantId, command.NodeId, cancellationToken);
|
await AssertNodeAsync(actor, scope, command.NodeId, cancellationToken);
|
||||||
await AssertReferenceAsync<Subject>(actor.TenantId, command.SubjectId, "subject_not_found", 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<Category>(actor.TenantId, command.CategoryId, "category_not_found", cancellationToken);
|
||||||
await AssertReferenceAsync<QuestionBank>(actor.TenantId, command.QuestionBankId, "question_bank_not_found", cancellationToken);
|
await AssertReferenceAsync<QuestionBank>(actor.TenantId, command.QuestionBankId, "question_bank_not_found", cancellationToken);
|
||||||
@@ -312,6 +368,21 @@ public sealed class ContentManagementService(
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
var isNew = collection is null;
|
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
|
collection ??= new QuestionCollection
|
||||||
{
|
{
|
||||||
Id = command.Id ?? Guid.NewGuid(),
|
Id = command.Id ?? Guid.NewGuid(),
|
||||||
@@ -352,9 +423,15 @@ public sealed class ContentManagementService(
|
|||||||
ReplaceCollectionItemsCommand command,
|
ReplaceCollectionItemsCommand command,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var collection = await dbContext.QuestionCollections.SingleOrDefaultAsync(
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||||
item => item.TenantId == actor.TenantId && item.Id == command.CollectionId,
|
var regionIds = scope.RegionIds.ToArray();
|
||||||
cancellationToken);
|
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)
|
if (collection is null)
|
||||||
{
|
{
|
||||||
@@ -409,9 +486,15 @@ public sealed class ContentManagementService(
|
|||||||
ContentManagementFilter filter,
|
ContentManagementFilter filter,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||||
|
var regionIds = scope.RegionIds.ToArray();
|
||||||
var query = dbContext.PracticeBlueprints
|
var query = dbContext.PracticeBlueprints
|
||||||
.AsNoTracking()
|
.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)
|
if (!filter.IncludeInactive)
|
||||||
{
|
{
|
||||||
@@ -464,10 +547,11 @@ public sealed class ContentManagementService(
|
|||||||
UpsertPracticeBlueprintCommand command,
|
UpsertPracticeBlueprintCommand command,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
||||||
await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken);
|
await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken);
|
||||||
await AssertEntryAsync(actor.TenantId, command.EntryId, cancellationToken);
|
await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken);
|
||||||
await AssertNodeAsync(actor.TenantId, command.NodeId, cancellationToken);
|
await AssertNodeAsync(actor, scope, command.NodeId, cancellationToken);
|
||||||
await AssertReferenceAsync<QuestionCollection>(actor.TenantId, command.CollectionId, "collection_not_found", cancellationToken);
|
await AssertReferenceAsync<QuestionCollection>(actor.TenantId, command.CollectionId, "collection_not_found", cancellationToken);
|
||||||
|
|
||||||
var blueprint = await ResolveEntityByIdOrLegacyAsync(
|
var blueprint = await ResolveEntityByIdOrLegacyAsync(
|
||||||
@@ -478,6 +562,21 @@ public sealed class ContentManagementService(
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
var isNew = blueprint is null;
|
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
|
blueprint ??= new PracticeBlueprint
|
||||||
{
|
{
|
||||||
Id = command.Id ?? Guid.NewGuid(),
|
Id = command.Id ?? Guid.NewGuid(),
|
||||||
@@ -584,11 +683,74 @@ public sealed class ContentManagementService(
|
|||||||
await AssertReferenceAsync<ContentEntry>(tenantId, entryId, "entry_not_found", cancellationToken);
|
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)
|
private async Task AssertNodeAsync(Guid tenantId, Guid? nodeId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await AssertReferenceAsync<ContentNode>(tenantId, nodeId, "node_not_found", 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>(
|
private async Task AssertReferenceAsync<TEntity>(
|
||||||
Guid tenantId,
|
Guid tenantId,
|
||||||
Guid? id,
|
Guid? id,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Tiku.Application.Assets;
|
|||||||
using Tiku.Application.Catalog;
|
using Tiku.Application.Catalog;
|
||||||
using Tiku.Application.Content;
|
using Tiku.Application.Content;
|
||||||
using Tiku.Application.QuestionBanks;
|
using Tiku.Application.QuestionBanks;
|
||||||
|
using Tiku.Application.Security;
|
||||||
using Tiku.Domain.Catalog;
|
using Tiku.Domain.Catalog;
|
||||||
using Tiku.Domain.Common;
|
using Tiku.Domain.Common;
|
||||||
using Tiku.Domain.Content;
|
using Tiku.Domain.Content;
|
||||||
@@ -12,12 +13,14 @@ using Tiku.Domain.Learning;
|
|||||||
using Tiku.Domain.Operations;
|
using Tiku.Domain.Operations;
|
||||||
using Tiku.Domain.QuestionBanks;
|
using Tiku.Domain.QuestionBanks;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
using Tiku.Infrastructure.Security;
|
||||||
|
|
||||||
namespace Tiku.Infrastructure.Content;
|
namespace Tiku.Infrastructure.Content;
|
||||||
|
|
||||||
public sealed class DirectContentService(
|
public sealed class DirectContentService(
|
||||||
TikuDbContext dbContext,
|
TikuDbContext dbContext,
|
||||||
IQuestionReferenceService questionReferenceService) : IDirectContentService
|
IQuestionReferenceService questionReferenceService,
|
||||||
|
ICurrentAccessContext currentAccessContext) : IDirectContentService
|
||||||
{
|
{
|
||||||
private const int DefaultLimit = 100;
|
private const int DefaultLimit = 100;
|
||||||
private const int MaxLimit = 1000;
|
private const int MaxLimit = 1000;
|
||||||
@@ -120,7 +123,11 @@ public sealed class DirectContentService(
|
|||||||
AdminLimitFilter filter,
|
AdminLimitFilter filter,
|
||||||
CancellationToken cancellationToken = default)
|
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)
|
if (filter.RegionId.HasValue)
|
||||||
{
|
{
|
||||||
query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
||||||
@@ -159,6 +166,7 @@ public sealed class DirectContentService(
|
|||||||
VocabularyUnitCommand command,
|
VocabularyUnitCommand command,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
||||||
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
||||||
await AssertReferenceAsync<ContentEntry>(actor.TenantId, command.EntryId, "entry_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 item = await ResolveByIdOrLegacyAsync(dbContext.VocabularyUnits, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||||
var isNew = item is null;
|
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 ??= new VocabularyUnit { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||||
item.RegionId = command.RegionId;
|
item.RegionId = command.RegionId;
|
||||||
item.EntryId = command.EntryId;
|
item.EntryId = command.EntryId;
|
||||||
@@ -272,7 +281,11 @@ public sealed class DirectContentService(
|
|||||||
AdminLimitFilter filter,
|
AdminLimitFilter filter,
|
||||||
CancellationToken cancellationToken = default)
|
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)
|
if (filter.RegionId.HasValue)
|
||||||
{
|
{
|
||||||
query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
||||||
@@ -321,6 +334,7 @@ public sealed class DirectContentService(
|
|||||||
HandbookSubjectCommand command,
|
HandbookSubjectCommand command,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
||||||
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
||||||
await AssertReferenceAsync<School>(actor.TenantId, command.SchoolId, "school_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 item = await ResolveByIdOrLegacyAsync(dbContext.HandbookSubjects, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||||
var isNew = item is null;
|
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 ??= new HandbookSubject { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||||
item.RegionId = command.RegionId;
|
item.RegionId = command.RegionId;
|
||||||
item.SchoolId = command.SchoolId;
|
item.SchoolId = command.SchoolId;
|
||||||
@@ -513,7 +528,11 @@ public sealed class DirectContentService(
|
|||||||
AdminLimitFilter filter,
|
AdminLimitFilter filter,
|
||||||
CancellationToken cancellationToken = default)
|
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)
|
if (filter.RegionId.HasValue)
|
||||||
{
|
{
|
||||||
query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
||||||
@@ -536,10 +555,12 @@ public sealed class DirectContentService(
|
|||||||
SchoolCommand command,
|
SchoolCommand command,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
||||||
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
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 item = await ResolveByIdOrLegacyAsync(dbContext.Schools, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||||
var isNew = item is null;
|
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 ??= new School { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||||
item.RegionId = command.RegionId;
|
item.RegionId = command.RegionId;
|
||||||
item.LegacyId = Normalize(command.LegacyId);
|
item.LegacyId = Normalize(command.LegacyId);
|
||||||
@@ -560,7 +581,11 @@ public sealed class DirectContentService(
|
|||||||
AdminLimitFilter filter,
|
AdminLimitFilter filter,
|
||||||
CancellationToken cancellationToken = default)
|
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)
|
if (filter.RegionId.HasValue)
|
||||||
{
|
{
|
||||||
query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
||||||
@@ -594,11 +619,13 @@ public sealed class DirectContentService(
|
|||||||
MajorCommand command,
|
MajorCommand command,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
||||||
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
||||||
await AssertReferenceAsync<School>(actor.TenantId, command.SchoolId, "school_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 item = await ResolveByIdOrLegacyAsync(dbContext.Majors, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||||
var isNew = item is null;
|
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 ??= new Major { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||||
item.RegionId = command.RegionId;
|
item.RegionId = command.RegionId;
|
||||||
item.SchoolId = command.SchoolId;
|
item.SchoolId = command.SchoolId;
|
||||||
@@ -622,7 +649,11 @@ public sealed class DirectContentService(
|
|||||||
AdminLimitFilter filter,
|
AdminLimitFilter filter,
|
||||||
CancellationToken cancellationToken = default)
|
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)
|
if (filter.RegionId.HasValue)
|
||||||
{
|
{
|
||||||
query = query.Where(item => item.RegionId == filter.RegionId.Value || item.RegionId == null);
|
query = query.Where(item => item.RegionId == filter.RegionId.Value || item.RegionId == null);
|
||||||
@@ -646,6 +677,7 @@ public sealed class DirectContentService(
|
|||||||
ScorelineFieldCommand command,
|
ScorelineFieldCommand command,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.FieldKey);
|
ArgumentException.ThrowIfNullOrWhiteSpace(command.FieldKey);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.FieldName);
|
ArgumentException.ThrowIfNullOrWhiteSpace(command.FieldName);
|
||||||
if (!ScorelineFieldKeyRegex.IsMatch(command.FieldKey.Trim()))
|
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);
|
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 item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineFields, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||||
var isNew = item is null;
|
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 ??= new ScorelineField { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||||
item.RegionId = command.RegionId;
|
item.RegionId = command.RegionId;
|
||||||
item.LegacyId = Normalize(command.LegacyId);
|
item.LegacyId = Normalize(command.LegacyId);
|
||||||
@@ -685,7 +718,11 @@ public sealed class DirectContentService(
|
|||||||
AdminLimitFilter filter,
|
AdminLimitFilter filter,
|
||||||
CancellationToken cancellationToken = default)
|
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)
|
if (filter.RegionId.HasValue)
|
||||||
{
|
{
|
||||||
query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
||||||
@@ -727,6 +764,7 @@ public sealed class DirectContentService(
|
|||||||
ScorelineRecordCommand command,
|
ScorelineRecordCommand command,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||||
if (command.Year is < 1900 or > 3000)
|
if (command.Year is < 1900 or > 3000)
|
||||||
{
|
{
|
||||||
throw new ContentManagementException("Scoreline record year is invalid.", "scoreline_year_invalid");
|
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);
|
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 item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineRecords, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||||
var isNew = item is null;
|
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 ??= new ScorelineRecord { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||||
item.RegionId = command.RegionId;
|
item.RegionId = command.RegionId;
|
||||||
item.SchoolId = command.SchoolId;
|
item.SchoolId = command.SchoolId;
|
||||||
@@ -760,8 +799,11 @@ public sealed class DirectContentService(
|
|||||||
AdminLimitFilter filter,
|
AdminLimitFilter filter,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||||
|
var regionIds = scope.RegionIds.ToArray();
|
||||||
var query = dbContext.ScorelineRecords.AsNoTracking()
|
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)
|
if (filter.RegionId.HasValue)
|
||||||
{
|
{
|
||||||
query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
||||||
@@ -1720,6 +1762,38 @@ public sealed class DirectContentService(
|
|||||||
item.IssuesCount);
|
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>(
|
private async Task<TEntity?> ResolveByIdOrLegacyAsync<TEntity>(
|
||||||
DbSet<TEntity> set,
|
DbSet<TEntity> set,
|
||||||
Guid tenantId,
|
Guid tenantId,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Tiku.Application.Assets;
|
using Tiku.Application.Assets;
|
||||||
@@ -35,10 +36,12 @@ using Tiku.Infrastructure.Profile;
|
|||||||
using Tiku.Infrastructure.Points;
|
using Tiku.Infrastructure.Points;
|
||||||
using Tiku.Infrastructure.QuestionBanks;
|
using Tiku.Infrastructure.QuestionBanks;
|
||||||
using Tiku.Infrastructure.Scoreline;
|
using Tiku.Infrastructure.Scoreline;
|
||||||
|
using Tiku.Infrastructure.Security;
|
||||||
using Tiku.Infrastructure.Storage;
|
using Tiku.Infrastructure.Storage;
|
||||||
using Tiku.Infrastructure.StudyContent;
|
using Tiku.Infrastructure.StudyContent;
|
||||||
using Tiku.Infrastructure.TenantAdmin;
|
using Tiku.Infrastructure.TenantAdmin;
|
||||||
using Tiku.Infrastructure.Tenancy;
|
using Tiku.Infrastructure.Tenancy;
|
||||||
|
using Tiku.Domain.Identity;
|
||||||
|
|
||||||
namespace Tiku.Infrastructure;
|
namespace Tiku.Infrastructure;
|
||||||
|
|
||||||
@@ -59,6 +62,21 @@ public static class DependencyInjection
|
|||||||
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName));
|
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName));
|
||||||
options.AddInterceptors(serviceProvider.GetRequiredService<TenantIsolationSaveChangesInterceptor>());
|
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.AddScoped<ITenantDirectory, TenantDirectory>();
|
||||||
services.AddMemoryCache();
|
services.AddMemoryCache();
|
||||||
services.AddScoped<ITenantFrontendConfigService, TenantFrontendConfigService>();
|
services.AddScoped<ITenantFrontendConfigService, TenantFrontendConfigService>();
|
||||||
@@ -69,9 +87,10 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ITenantDomainLifecycleService, TenantDomainLifecycleService>();
|
services.AddScoped<ITenantDomainLifecycleService, TenantDomainLifecycleService>();
|
||||||
services.AddOptions<DomainLifecycleOptions>();
|
services.AddOptions<DomainLifecycleOptions>();
|
||||||
services.AddSingleton<ITenantExecutionScope, TenantExecutionScope>();
|
services.AddSingleton<ITenantExecutionScope, TenantExecutionScope>();
|
||||||
services.AddScoped<IPasswordHasher, PasswordHasher>();
|
services.AddSingleton<IJwtKeyRing, JwtKeyRing>();
|
||||||
services.AddScoped<ITokenService, TokenService>();
|
services.AddScoped<ITokenService, TokenService>();
|
||||||
services.AddScoped<ISessionService, SessionService>();
|
services.AddScoped<AuthSessionStore>();
|
||||||
|
services.AddScoped<IAuthSessionStore>(provider => provider.GetRequiredService<AuthSessionStore>());
|
||||||
services.AddScoped<ISmsProvider, AliyunSmsProvider>();
|
services.AddScoped<ISmsProvider, AliyunSmsProvider>();
|
||||||
services.AddScoped<ISmsVerificationService, SmsVerificationService>();
|
services.AddScoped<ISmsVerificationService, SmsVerificationService>();
|
||||||
services.AddScoped<IWechatOAuthClient, WechatOAuthClient>();
|
services.AddScoped<IWechatOAuthClient, WechatOAuthClient>();
|
||||||
@@ -94,6 +113,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ILearningActivityService, LearningActivityService>();
|
services.AddScoped<ILearningActivityService, LearningActivityService>();
|
||||||
services.AddScoped<ITenantAdminDirectService, TenantAdminDirectService>();
|
services.AddScoped<ITenantAdminDirectService, TenantAdminDirectService>();
|
||||||
services.AddScoped<IBackofficeService, BackofficeService>();
|
services.AddScoped<IBackofficeService, BackofficeService>();
|
||||||
|
services.AddScoped<ICurrentAccessContext, CurrentAccessContext>();
|
||||||
services.AddScoped<IOperationAuditService, OperationAuditService>();
|
services.AddScoped<IOperationAuditService, OperationAuditService>();
|
||||||
services.AddScoped<IBackgroundJobService, BackgroundJobService>();
|
services.AddScoped<IBackgroundJobService, BackgroundJobService>();
|
||||||
services.AddScoped<ICommerceService, CommerceService>();
|
services.AddScoped<ICommerceService, CommerceService>();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Text;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Tiku.Application.Growth;
|
using Tiku.Application.Growth;
|
||||||
|
using Tiku.Application.Security;
|
||||||
using Tiku.Domain.Commerce;
|
using Tiku.Domain.Commerce;
|
||||||
using Tiku.Domain.Common;
|
using Tiku.Domain.Common;
|
||||||
using Tiku.Domain.Growth;
|
using Tiku.Domain.Growth;
|
||||||
@@ -13,7 +14,9 @@ using Tiku.Infrastructure.Persistence;
|
|||||||
|
|
||||||
namespace Tiku.Infrastructure.Growth;
|
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)
|
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
|
var member = await dbContext.TenantMemberships
|
||||||
.FirstOrDefaultAsync(item => item.TenantId == actor.TenantId && item.UserId == command.UserId, cancellationToken)
|
.FirstOrDefaultAsync(item => item.TenantId == actor.TenantId && item.UserId == command.UserId, cancellationToken)
|
||||||
?? throw new CommissionException("Commission member was not found.", "commission_member_not_found");
|
?? 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,
|
commissionRate = command.CommissionRate,
|
||||||
commissionConfig = command.CommissionConfig
|
commissionConfig = command.CommissionConfig
|
||||||
});
|
});
|
||||||
|
config["memberRates"] = JsonSerializer.SerializeToElement(memberRates);
|
||||||
|
settings.Config = JsonSerializer.SerializeToElement(config);
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
return new { member.UserId, commissionRate = command.CommissionRate, commissionConfig = command.CommissionConfig };
|
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)
|
foreach (var row in orders)
|
||||||
{
|
{
|
||||||
if (query.ReferrerUserId.HasValue && row.lead.ReferrerUserId != query.ReferrerUserId) continue;
|
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;
|
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"));
|
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;
|
if (!row.code.AgentUserId.HasValue) continue;
|
||||||
var agentUserId = row.code.AgentUserId.Value;
|
var agentUserId = row.code.AgentUserId.Value;
|
||||||
if (query.ReferrerUserId.HasValue && agentUserId != query.ReferrerUserId) continue;
|
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 sourceAmount = row.code.UnitPriceCents ?? row.batch?.DefaultUnitPriceCents ?? 0;
|
||||||
var settled = existing.FirstOrDefault(item => item.SourceType == CommissionSourceType.ActivationCode && item.SourceId == row.code.Id)?.SettlementId;
|
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"));
|
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();
|
return result.OrderBy(item => item.SourcePaidAt).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<JsonElement> GetMembershipPermissionsAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken) =>
|
private static decimal? GetMemberRate(JsonElement config, Guid userId)
|
||||||
await dbContext.TenantMemberships.AsNoTracking().Where(item => item.TenantId == tenantId && item.UserId == userId).Select(item => item.Permissions).FirstOrDefaultAsync(cancellationToken);
|
{
|
||||||
|
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) =>
|
return rate;
|
||||||
permissions.ValueKind == JsonValueKind.Object && permissions.TryGetProperty("commissionRate", out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetDecimal(out var rate) ? rate : null;
|
}
|
||||||
|
|
||||||
private async Task<TenantCommissionSetting> GetSettingsCoreAsync(Guid tenantId, CancellationToken cancellationToken)
|
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)
|
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);
|
var access = await currentAccessContext.GetAsync(cancellationToken);
|
||||||
if (!ok) throw new CommissionException("Commission admin access was denied.", "commission_access_denied");
|
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)
|
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 System.Text.Json;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Tiku.Application.Growth;
|
using Tiku.Application.Growth;
|
||||||
|
using Tiku.Application.Security;
|
||||||
using Tiku.Domain.Common;
|
using Tiku.Domain.Common;
|
||||||
using Tiku.Domain.Growth;
|
using Tiku.Domain.Growth;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
@@ -11,7 +12,8 @@ namespace Tiku.Infrastructure.Growth;
|
|||||||
|
|
||||||
internal sealed class CrmService(
|
internal sealed class CrmService(
|
||||||
TikuDbContext dbContext,
|
TikuDbContext dbContext,
|
||||||
ITenantSecretProtector tenantSecretProtector) : ICrmService
|
ITenantSecretProtector tenantSecretProtector,
|
||||||
|
ICurrentAccessContext currentAccessContext) : ICrmService
|
||||||
{
|
{
|
||||||
private static readonly HashSet<string> SensitiveKeys = new(StringComparer.OrdinalIgnoreCase)
|
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)
|
private async Task AssertAdminAsync(CrmAdminActor actor, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var isAdmin = await dbContext.TenantMemberships.AnyAsync(
|
var access = await currentAccessContext.GetAsync(cancellationToken);
|
||||||
item =>
|
if (!access.IsCurrentTenantMember ||
|
||||||
item.TenantId == actor.TenantId &&
|
access.UserId != actor.UserId ||
|
||||||
item.UserId == actor.UserId &&
|
access.TenantId != actor.TenantId ||
|
||||||
item.Status == MembershipStatus.Active &&
|
!access.HasTenantPermission(BackendPermissions.TenantCrmManage) ||
|
||||||
(item.Role == TenantRole.PlatformAdmin ||
|
access.DataScope.Mode != DataScopeMode.All)
|
||||||
item.Role == TenantRole.TenantOwner ||
|
|
||||||
item.Role == TenantRole.TenantAdmin),
|
|
||||||
cancellationToken);
|
|
||||||
if (!isAdmin)
|
|
||||||
{
|
{
|
||||||
throw new CrmException("CRM admin access was denied.", "crm_access_denied");
|
throw new CrmException("CRM admin access was denied.", "crm_access_denied");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Security.Cryptography;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Tiku.Application.Growth;
|
using Tiku.Application.Growth;
|
||||||
|
using Tiku.Application.Security;
|
||||||
using Tiku.Domain.Commerce;
|
using Tiku.Domain.Commerce;
|
||||||
using Tiku.Domain.Common;
|
using Tiku.Domain.Common;
|
||||||
using Tiku.Domain.Growth;
|
using Tiku.Domain.Growth;
|
||||||
@@ -14,7 +15,8 @@ namespace Tiku.Infrastructure.Growth;
|
|||||||
|
|
||||||
public sealed class ReferralService(
|
public sealed class ReferralService(
|
||||||
TikuDbContext dbContext,
|
TikuDbContext dbContext,
|
||||||
IReferralQrcodeGenerator qrcodeGenerator) : IReferralService
|
IReferralQrcodeGenerator qrcodeGenerator,
|
||||||
|
ICurrentAccessContext currentAccessContext) : IReferralService
|
||||||
{
|
{
|
||||||
private static readonly HashSet<string> AllowedEventTypes = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly HashSet<string> AllowedEventTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
@@ -109,7 +111,7 @@ public sealed class ReferralService(
|
|||||||
referralCode.UserId,
|
referralCode.UserId,
|
||||||
membership.Role,
|
membership.Role,
|
||||||
user.Name,
|
user.Name,
|
||||||
user.Username,
|
user.UserName,
|
||||||
user.Phone
|
user.Phone
|
||||||
})
|
})
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
@@ -121,7 +123,7 @@ public sealed class ReferralService(
|
|||||||
row.UserId,
|
row.UserId,
|
||||||
row.Code,
|
row.Code,
|
||||||
row.Role.ToString(),
|
row.Role.ToString(),
|
||||||
FirstNonBlank(row.Name, row.Username, row.Phone));
|
FirstNonBlank(row.Name, row.UserName, row.Phone));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ReferralTrackResult> TrackEventAsync(
|
public async Task<ReferralTrackResult> TrackEventAsync(
|
||||||
@@ -655,16 +657,11 @@ public sealed class ReferralService(
|
|||||||
|
|
||||||
private async Task AssertAdminAsync(ReferralAdminActor actor, CancellationToken cancellationToken)
|
private async Task AssertAdminAsync(ReferralAdminActor actor, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var isAdmin = await dbContext.TenantMemberships.AnyAsync(
|
var access = await currentAccessContext.GetAsync(cancellationToken);
|
||||||
item =>
|
if (!access.IsCurrentTenantMember ||
|
||||||
item.TenantId == actor.TenantId &&
|
access.TenantId != actor.TenantId ||
|
||||||
item.UserId == actor.UserId &&
|
access.UserId != actor.UserId ||
|
||||||
item.Status == MembershipStatus.Active &&
|
!access.HasTenantPermission(BackendPermissions.TenantCrmManage))
|
||||||
(item.Role == TenantRole.PlatformAdmin ||
|
|
||||||
item.Role == TenantRole.TenantOwner ||
|
|
||||||
item.Role == TenantRole.TenantAdmin),
|
|
||||||
cancellationToken);
|
|
||||||
if (!isAdmin)
|
|
||||||
{
|
{
|
||||||
throw new ReferralException("Referral admin access was denied.", "referral_access_denied");
|
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()
|
var user = await dbContext.Users.AsNoTracking()
|
||||||
.Where(item => item.Id == referrerUserId)
|
.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);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
var membership = await dbContext.TenantMemberships.AsNoTracking()
|
var membership = await dbContext.TenantMemberships.AsNoTracking()
|
||||||
.Where(item => item.TenantId == tenantId && item.UserId == referrerUserId)
|
.Where(item => item.TenantId == tenantId && item.UserId == referrerUserId)
|
||||||
@@ -714,7 +711,7 @@ public sealed class ReferralService(
|
|||||||
|
|
||||||
return new ReferralStatsItem(
|
return new ReferralStatsItem(
|
||||||
referrerUserId,
|
referrerUserId,
|
||||||
FirstNonBlank(user?.Name, user?.Username, user?.Phone),
|
FirstNonBlank(user?.Name, user?.UserName, user?.Phone),
|
||||||
membership.ToString(),
|
membership.ToString(),
|
||||||
inviteCode,
|
inviteCode,
|
||||||
leads.Length,
|
leads.Length,
|
||||||
|
|||||||
@@ -9,22 +9,30 @@ internal sealed class UserConfiguration : IEntityTypeConfiguration<User>
|
|||||||
{
|
{
|
||||||
public void Configure(EntityTypeBuilder<User> builder)
|
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.ConfigureTimestamps();
|
||||||
|
|
||||||
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
|
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.Email).HasColumnType("citext").HasMaxLength(320);
|
||||||
|
builder.Property(entity => entity.NormalizedEmail).HasMaxLength(320);
|
||||||
builder.Property(entity => entity.Phone).HasMaxLength(32);
|
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.Name).HasMaxLength(200);
|
||||||
builder.Property(entity => entity.AvatarUrl).HasMaxLength(2048);
|
builder.Property(entity => entity.AvatarUrl).HasMaxLength(2048);
|
||||||
builder.Property(entity => entity.PrimaryRole).HasMaxLength(50);
|
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.Property(entity => entity.RawProfile).IsJson("{}");
|
||||||
|
|
||||||
builder.HasIndex(entity => entity.LegacyId).IsUnique();
|
builder.HasIndex(entity => entity.LegacyId).IsUnique();
|
||||||
builder.HasIndex(entity => entity.Username).IsUnique();
|
builder.HasIndex(entity => entity.NormalizedUserName).IsUnique();
|
||||||
builder.HasIndex(entity => entity.Email).IsUnique();
|
builder.HasIndex(entity => entity.NormalizedEmail);
|
||||||
builder.HasIndex(entity => entity.Phone).IsUnique();
|
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.OpenId).HasMaxLength(255);
|
||||||
builder.Property(entity => entity.Phone).HasMaxLength(32);
|
builder.Property(entity => entity.Phone).HasMaxLength(32);
|
||||||
builder.Property(entity => entity.Email).HasColumnType("citext").HasMaxLength(320);
|
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.HasIndex(entity => new { entity.Provider, entity.ProviderSubject }).IsUnique();
|
||||||
builder.HasOne<User>()
|
builder.HasOne<User>()
|
||||||
.WithMany()
|
.WithMany()
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ internal sealed class TenantMembershipConfiguration : IEntityTypeConfiguration<T
|
|||||||
|
|
||||||
builder.Property(entity => entity.Role).HasSnakeCaseEnum();
|
builder.Property(entity => entity.Role).HasSnakeCaseEnum();
|
||||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||||
builder.Property(entity => entity.Permissions).IsJson("{}");
|
|
||||||
builder.Property(entity => entity.LegacyRole).HasMaxLength(50);
|
builder.Property(entity => entity.LegacyRole).HasMaxLength(50);
|
||||||
|
|
||||||
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.Role }).IsUnique();
|
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.Role }).IsUnique();
|
||||||
@@ -52,11 +51,6 @@ internal sealed class TenantMembershipConfiguration : IEntityTypeConfiguration<T
|
|||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(entity => entity.UserId)
|
.HasForeignKey(entity => entity.UserId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.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)
|
public void Configure(EntityTypeBuilder<SmsVerificationCode> builder)
|
||||||
{
|
{
|
||||||
builder.ConfigureEntity("sms_verification_codes");
|
builder.ConfigureEntity("sms_verification_codes");
|
||||||
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
|
|
||||||
builder.Property(entity => entity.Phone).HasMaxLength(32);
|
builder.Property(entity => entity.Phone).HasMaxLength(32);
|
||||||
builder.Property(entity => entity.Purpose).HasSnakeCaseEnum();
|
builder.Property(entity => entity.Purpose).HasSnakeCaseEnum();
|
||||||
builder.Property(entity => entity.CodeHash).HasMaxLength(256);
|
builder.Property(entity => entity.CodeHash).HasMaxLength(256);
|
||||||
@@ -96,7 +95,7 @@ internal sealed class AuthLoginEventConfiguration : IEntityTypeConfiguration<Aut
|
|||||||
|
|
||||||
builder.HasOne<Tenant>().WithMany()
|
builder.HasOne<Tenant>().WithMany()
|
||||||
.HasForeignKey(entity => entity.TenantId)
|
.HasForeignKey(entity => entity.TenantId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
builder.HasOne<User>().WithMany()
|
builder.HasOne<User>().WithMany()
|
||||||
.HasForeignKey(entity => entity.UserId)
|
.HasForeignKey(entity => entity.UserId)
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
@@ -107,18 +106,30 @@ internal sealed class AuthSessionConfiguration : IEntityTypeConfiguration<AuthSe
|
|||||||
{
|
{
|
||||||
public void Configure(EntityTypeBuilder<AuthSession> builder)
|
public void Configure(EntityTypeBuilder<AuthSession> builder)
|
||||||
{
|
{
|
||||||
builder.ConfigureTenantEntity("auth_sessions");
|
builder.ConfigureEntity("auth_sessions");
|
||||||
builder.ConfigureTimestamps();
|
builder.ConfigureTimestamps();
|
||||||
|
builder.Property(entity => entity.Realm).HasSnakeCaseEnum();
|
||||||
builder.Property(entity => entity.TokenHash).HasMaxLength(256);
|
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.Provider).HasMaxLength(50);
|
||||||
|
builder.Property(entity => entity.RevokedReason).HasMaxLength(100);
|
||||||
builder.Property(entity => entity.IpAddress).HasMaxLength(64);
|
builder.Property(entity => entity.IpAddress).HasMaxLength(64);
|
||||||
builder.Property(entity => entity.UserAgent).HasMaxLength(1000);
|
builder.Property(entity => entity.UserAgent).HasMaxLength(1000);
|
||||||
builder.Property(entity => entity.Metadata).IsJson("{}");
|
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||||
builder.HasIndex(entity => entity.TokenHash)
|
builder.HasIndex(entity => entity.TokenHash)
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasAnnotation("Tiku:GlobalUnique", true);
|
.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");
|
.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()
|
builder.HasOne<User>().WithMany()
|
||||||
.HasForeignKey(entity => entity.UserId)
|
.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>
|
internal sealed class SmsSendRateLimitConfiguration : IEntityTypeConfiguration<SmsSendRateLimit>
|
||||||
{
|
{
|
||||||
public void Configure(EntityTypeBuilder<SmsSendRateLimit> builder)
|
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>
|
internal sealed class TenantClassConfiguration : IEntityTypeConfiguration<TenantClass>
|
||||||
{
|
{
|
||||||
public void Configure(EntityTypeBuilder<TenantClass> builder)
|
public void Configure(EntityTypeBuilder<TenantClass> builder)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ using Tiku.Infrastructure.Persistence;
|
|||||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(TikuDbContext))]
|
[DbContext(typeof(TikuDbContext))]
|
||||||
[Migration("20260728014412_InitialSchema")]
|
[Migration("20260728031410_InitialSchema")]
|
||||||
partial class InitialSchema
|
partial class InitialSchema
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -28,6 +28,110 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree");
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree");
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
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 =>
|
modelBuilder.Entity("Tiku.Domain.Catalog.Category", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -7918,11 +8022,21 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnName("id")
|
.HasColumnName("id")
|
||||||
.HasDefaultValueSql("gen_random_uuid()");
|
.HasDefaultValueSql("gen_random_uuid()");
|
||||||
|
|
||||||
|
b.Property<int>("AccessFailedCount")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("access_failed_count");
|
||||||
|
|
||||||
b.Property<string>("AvatarUrl")
|
b.Property<string>("AvatarUrl")
|
||||||
.HasMaxLength(2048)
|
.HasMaxLength(2048)
|
||||||
.HasColumnType("character varying(2048)")
|
.HasColumnType("character varying(2048)")
|
||||||
.HasColumnName("avatar_url");
|
.HasColumnName("avatar_url");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)")
|
||||||
|
.HasColumnName("concurrency_stamp");
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("CreatedAt")
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
@@ -7934,6 +8048,14 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnType("citext")
|
.HasColumnType("citext")
|
||||||
.HasColumnName("email");
|
.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")
|
b.Property<DateTimeOffset?>("LastSeenAt")
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("last_seen_at");
|
.HasColumnName("last_seen_at");
|
||||||
@@ -7943,25 +8065,48 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnType("character varying(64)")
|
.HasColumnType("character varying(64)")
|
||||||
.HasColumnName("legacy_id");
|
.HasColumnName("legacy_id");
|
||||||
|
|
||||||
b.Property<string>("LegacyPasswordHash")
|
b.Property<bool>("LockoutEnabled")
|
||||||
.HasMaxLength(512)
|
.HasColumnType("boolean")
|
||||||
.HasColumnType("character varying(512)")
|
.HasColumnName("lockout_enabled");
|
||||||
.HasColumnName("legacy_password_hash");
|
|
||||||
|
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("lockout_end");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("character varying(200)")
|
.HasColumnType("character varying(200)")
|
||||||
.HasColumnName("name");
|
.HasColumnName("name");
|
||||||
|
|
||||||
b.Property<bool>("PasswordMigrationRequired")
|
b.Property<string>("NormalizedEmail")
|
||||||
.HasColumnType("boolean")
|
.HasMaxLength(320)
|
||||||
.HasColumnName("password_migration_required");
|
.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")
|
b.Property<string>("Phone")
|
||||||
.HasMaxLength(32)
|
.HasMaxLength(32)
|
||||||
.HasColumnType("character varying(32)")
|
.HasColumnType("character varying(32)")
|
||||||
.HasColumnName("phone");
|
.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")
|
b.Property<string>("PrimaryRole")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(50)
|
.HasMaxLength(50)
|
||||||
@@ -7978,36 +8123,50 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnType("integer")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("score");
|
.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")
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("updated_at")
|
.HasColumnName("updated_at")
|
||||||
.HasDefaultValueSql("now()");
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
b.Property<string>("Username")
|
b.Property<string>("UserName")
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("character varying(100)")
|
.HasColumnType("character varying(100)")
|
||||||
.HasColumnName("username");
|
.HasColumnName("user_name");
|
||||||
|
|
||||||
b.HasKey("Id")
|
b.HasKey("Id")
|
||||||
.HasName("pk_users");
|
.HasName("pk_users");
|
||||||
|
|
||||||
b.HasIndex("Email")
|
|
||||||
.IsUnique()
|
|
||||||
.HasDatabaseName("ix_users_email");
|
|
||||||
|
|
||||||
b.HasIndex("LegacyId")
|
b.HasIndex("LegacyId")
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasDatabaseName("ix_users_legacy_id");
|
.HasDatabaseName("ix_users_legacy_id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedEmail")
|
||||||
|
.HasDatabaseName("email_index");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedUserName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("user_name_index");
|
||||||
|
|
||||||
b.HasIndex("Phone")
|
b.HasIndex("Phone")
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasDatabaseName("ix_users_phone");
|
.HasDatabaseName("ix_users_phone");
|
||||||
|
|
||||||
b.HasIndex("Username")
|
|
||||||
.IsUnique()
|
|
||||||
.HasDatabaseName("ix_users_username");
|
|
||||||
|
|
||||||
b.ToTable("users", (string)null);
|
b.ToTable("users", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -8052,12 +8211,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnType("character varying(255)")
|
.HasColumnType("character varying(255)")
|
||||||
.HasColumnName("provider_subject");
|
.HasColumnName("provider_subject");
|
||||||
|
|
||||||
b.Property<JsonElement>("SecretPayload")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("jsonb")
|
|
||||||
.HasColumnName("secret_payload")
|
|
||||||
.HasDefaultValueSql("'{}'::jsonb");
|
|
||||||
|
|
||||||
b.Property<string>("UnionId")
|
b.Property<string>("UnionId")
|
||||||
.HasMaxLength(255)
|
.HasMaxLength(255)
|
||||||
.HasColumnType("character varying(255)")
|
.HasColumnType("character varying(255)")
|
||||||
@@ -12651,6 +12804,95 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("question_versions", (string)null);
|
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 =>
|
modelBuilder.Entity("Tiku.Domain.Tenancy.AuthLoginEvent", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -12755,20 +12997,53 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnName("metadata")
|
.HasColumnName("metadata")
|
||||||
.HasDefaultValueSql("'{}'::jsonb");
|
.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")
|
b.Property<string>("Provider")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(50)
|
.HasMaxLength(50)
|
||||||
.HasColumnType("character varying(50)")
|
.HasColumnType("character varying(50)")
|
||||||
.HasColumnName("provider");
|
.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")
|
b.Property<DateTimeOffset?>("RevokedAt")
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("revoked_at");
|
.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")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("tenant_id");
|
.HasColumnName("tenant_id");
|
||||||
|
|
||||||
|
b.Property<Guid>("TokenFamilyId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("token_family_id");
|
||||||
|
|
||||||
b.Property<string>("TokenHash")
|
b.Property<string>("TokenHash")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
@@ -12793,8 +13068,8 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
b.HasKey("Id")
|
b.HasKey("Id")
|
||||||
.HasName("pk_auth_sessions");
|
.HasName("pk_auth_sessions");
|
||||||
|
|
||||||
b.HasAlternateKey("TenantId", "Id")
|
b.HasIndex("TenantId")
|
||||||
.HasName("ak_auth_sessions_tenant_id_id");
|
.HasDatabaseName("ix_auth_sessions_tenant_id");
|
||||||
|
|
||||||
b.HasIndex("TokenHash")
|
b.HasIndex("TokenHash")
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
@@ -12804,11 +13079,17 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
b.HasIndex("UserId")
|
b.HasIndex("UserId")
|
||||||
.HasDatabaseName("ix_auth_sessions_user_id");
|
.HasDatabaseName("ix_auth_sessions_user_id");
|
||||||
|
|
||||||
b.HasIndex("TenantId", "UserId", "ExpiresAt")
|
b.HasIndex("TokenFamilyId", "RevokedAt")
|
||||||
.HasDatabaseName("ix_auth_sessions_tenant_id_user_id_expires_at")
|
.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");
|
.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 =>
|
modelBuilder.Entity("Tiku.Domain.Tenancy.SmsSendRateLimit", b =>
|
||||||
@@ -12934,9 +13215,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
b.HasKey("Id")
|
b.HasKey("Id")
|
||||||
.HasName("pk_sms_verification_codes");
|
.HasName("pk_sms_verification_codes");
|
||||||
|
|
||||||
b.HasAlternateKey("TenantId", "Id")
|
|
||||||
.HasName("ak_sms_verification_codes_tenant_id_id");
|
|
||||||
|
|
||||||
b.HasIndex("TenantId", "Phone", "Purpose")
|
b.HasIndex("TenantId", "Phone", "Purpose")
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasDatabaseName("ix_sms_verification_codes_tenant_id_phone_purpose")
|
.HasDatabaseName("ix_sms_verification_codes_tenant_id_phone_purpose")
|
||||||
@@ -13626,22 +13904,12 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnType("character varying(50)")
|
.HasColumnType("character varying(50)")
|
||||||
.HasColumnName("legacy_role");
|
.HasColumnName("legacy_role");
|
||||||
|
|
||||||
b.Property<JsonElement>("Permissions")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("jsonb")
|
|
||||||
.HasColumnName("permissions")
|
|
||||||
.HasDefaultValueSql("'{}'::jsonb");
|
|
||||||
|
|
||||||
b.Property<string>("Role")
|
b.Property<string>("Role")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(32)
|
.HasMaxLength(32)
|
||||||
.HasColumnType("character varying(32)")
|
.HasColumnType("character varying(32)")
|
||||||
.HasColumnName("role");
|
.HasColumnName("role");
|
||||||
|
|
||||||
b.Property<Guid?>("RoleTemplateId")
|
|
||||||
.HasColumnType("uuid")
|
|
||||||
.HasColumnName("role_template_id");
|
|
||||||
|
|
||||||
b.Property<string>("Status")
|
b.Property<string>("Status")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(32)
|
.HasMaxLength(32)
|
||||||
@@ -13671,9 +13939,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
b.HasIndex("UserId")
|
b.HasIndex("UserId")
|
||||||
.HasDatabaseName("ix_tenant_memberships_user_id");
|
.HasDatabaseName("ix_tenant_memberships_user_id");
|
||||||
|
|
||||||
b.HasIndex("TenantId", "RoleTemplateId")
|
|
||||||
.HasDatabaseName("ix_tenant_memberships_tenant_id_role_template_id");
|
|
||||||
|
|
||||||
b.HasIndex("TenantId", "UserId", "Role")
|
b.HasIndex("TenantId", "UserId", "Role")
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasDatabaseName("ix_tenant_memberships_tenant_id_user_id_role");
|
.HasDatabaseName("ix_tenant_memberships_tenant_id_user_id_role");
|
||||||
@@ -13681,126 +13946,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("tenant_memberships", (string)null);
|
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 =>
|
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantSecret", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -14156,6 +14301,36 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("tenant_student_notes", (string)null);
|
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 =>
|
modelBuilder.Entity("Tiku.Domain.Catalog.Category", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
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");
|
.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)
|
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("TenantId")
|
.HasForeignKey("TenantId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.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()
|
.IsRequired()
|
||||||
.HasConstraintName("fk_auth_login_events_tenants_tenant_id");
|
.HasConstraintName("fk_auth_login_events_tenants_tenant_id");
|
||||||
|
|
||||||
@@ -17229,7 +17420,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("TenantId")
|
.HasForeignKey("TenantId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired()
|
|
||||||
.HasConstraintName("fk_auth_sessions_tenants_tenant_id");
|
.HasConstraintName("fk_auth_sessions_tenants_tenant_id");
|
||||||
|
|
||||||
b.HasOne("Tiku.Domain.Identity.User", null)
|
b.HasOne("Tiku.Domain.Identity.User", null)
|
||||||
@@ -17378,35 +17568,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasConstraintName("fk_tenant_memberships_users_user_id");
|
.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 =>
|
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantSecret", b =>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
@@ -38,6 +38,20 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
table.UniqueConstraint("ak_backend_permissions_code", x => x.code);
|
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(
|
migrationBuilder.CreateTable(
|
||||||
name: "platform_backend_roles",
|
name: "platform_backend_roles",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
@@ -137,19 +151,31 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
{
|
{
|
||||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
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),
|
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),
|
phone = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||||
name = table.Column<string>(type: "character varying(200)", maxLength: 200, 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),
|
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),
|
primary_role = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
score = table.Column<int>(type: "integer", nullable: false),
|
score = table.Column<int>(type: "integer", nullable: false),
|
||||||
last_seen_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
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),
|
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
password_migration_required = table.Column<bool>(type: "boolean", nullable: false),
|
force_password_change = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
raw_profile = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
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()"),
|
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 =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
@@ -265,6 +291,27 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
onDelete: ReferentialAction.SetNull);
|
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(
|
migrationBuilder.CreateTable(
|
||||||
name: "user_identities",
|
name: "user_identities",
|
||||||
columns: table => new
|
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),
|
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),
|
phone = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||||
email = table.Column<string>(type: "citext", maxLength: 320, 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()"),
|
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()")
|
||||||
},
|
},
|
||||||
@@ -292,6 +338,46 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
onDelete: ReferentialAction.Cascade);
|
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(
|
migrationBuilder.CreateTable(
|
||||||
name: "platform_backend_role_menus",
|
name: "platform_backend_role_menus",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
@@ -403,6 +489,42 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
onDelete: ReferentialAction.SetNull);
|
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(
|
migrationBuilder.CreateTable(
|
||||||
name: "auth_login_events",
|
name: "auth_login_events",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
@@ -428,7 +550,7 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
column: x => x.tenant_id,
|
column: x => x.tenant_id,
|
||||||
principalTable: "tenants",
|
principalTable: "tenants",
|
||||||
principalColumn: "id",
|
principalColumn: "id",
|
||||||
onDelete: ReferentialAction.Cascade);
|
onDelete: ReferentialAction.SetNull);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "fk_auth_login_events_users_user_id",
|
name: "fk_auth_login_events_users_user_id",
|
||||||
column: x => x.user_id,
|
column: x => x.user_id,
|
||||||
@@ -442,22 +564,29 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
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),
|
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),
|
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),
|
provider = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
expires_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", 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_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),
|
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),
|
user_agent = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||||
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
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()"),
|
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()")
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("pk_auth_sessions", x => x.id);
|
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(
|
table.ForeignKey(
|
||||||
name: "fk_auth_sessions_tenants_tenant_id",
|
name: "fk_auth_sessions_tenants_tenant_id",
|
||||||
column: x => x.tenant_id,
|
column: x => x.tenant_id,
|
||||||
@@ -1186,7 +1315,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("pk_sms_verification_codes", x => x.id);
|
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.CheckConstraint("ck_sms_verification_codes_attempts", "attempts >= 0");
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "fk_sms_verification_codes_tenants_tenant_id",
|
name: "fk_sms_verification_codes_tenants_tenant_id",
|
||||||
@@ -1498,50 +1626,34 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
});
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "tenant_role_templates",
|
name: "tenant_memberships",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||||
code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
description = table.Column<string>(type: "text", nullable: true),
|
|
||||||
base_role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
|
||||||
status = 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),
|
||||||
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),
|
|
||||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
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()")
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("pk_tenant_role_templates", x => x.id);
|
table.PrimaryKey("pk_tenant_memberships", x => x.id);
|
||||||
table.UniqueConstraint("ak_tenant_role_templates_tenant_id_id", x => new { x.tenant_id, x.id });
|
table.UniqueConstraint("ak_tenant_memberships_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "fk_tenant_role_templates_tenants_tenant_id",
|
name: "fk_tenant_memberships_tenants_tenant_id",
|
||||||
column: x => x.tenant_id,
|
column: x => x.tenant_id,
|
||||||
principalTable: "tenants",
|
principalTable: "tenants",
|
||||||
principalColumn: "id",
|
principalColumn: "id",
|
||||||
onDelete: ReferentialAction.Cascade);
|
onDelete: ReferentialAction.Cascade);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "fk_tenant_role_templates_users_created_by",
|
name: "fk_tenant_memberships_users_user_id",
|
||||||
column: x => x.created_by,
|
column: x => x.user_id,
|
||||||
principalTable: "users",
|
principalTable: "users",
|
||||||
principalColumn: "id",
|
principalColumn: "id",
|
||||||
onDelete: ReferentialAction.SetNull);
|
onDelete: ReferentialAction.Cascade);
|
||||||
table.ForeignKey(
|
|
||||||
name: "fk_tenant_role_templates_users_updated_by",
|
|
||||||
column: x => x.updated_by,
|
|
||||||
principalTable: "users",
|
|
||||||
principalColumn: "id",
|
|
||||||
onDelete: ReferentialAction.SetNull);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
@@ -2885,45 +2997,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
onDelete: ReferentialAction.SetNull);
|
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(
|
migrationBuilder.CreateTable(
|
||||||
name: "content_nodes",
|
name: "content_nodes",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
@@ -6392,6 +6465,22 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
table: "audit_logs",
|
table: "audit_logs",
|
||||||
columns: new[] { "tenant_id", "target_type", "target_id", "created_at" });
|
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(
|
migrationBuilder.CreateIndex(
|
||||||
name: "ix_auth_login_events_tenant_id_user_id_created_at",
|
name: "ix_auth_login_events_tenant_id_user_id_created_at",
|
||||||
table: "auth_login_events",
|
table: "auth_login_events",
|
||||||
@@ -6403,11 +6492,21 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
column: "user_id");
|
column: "user_id");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
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",
|
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");
|
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(
|
migrationBuilder.CreateIndex(
|
||||||
name: "ix_auth_sessions_token_hash",
|
name: "ix_auth_sessions_token_hash",
|
||||||
table: "auth_sessions",
|
table: "auth_sessions",
|
||||||
@@ -8609,11 +8708,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
table: "tenant_invoices",
|
table: "tenant_invoices",
|
||||||
columns: new[] { "tenant_id", "status", "due_date" });
|
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(
|
migrationBuilder.CreateIndex(
|
||||||
name: "ix_tenant_memberships_tenant_id_user_id_role",
|
name: "ix_tenant_memberships_tenant_id_user_id_role",
|
||||||
table: "tenant_memberships",
|
table: "tenant_memberships",
|
||||||
@@ -8656,27 +8750,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
table: "tenant_question_references",
|
table: "tenant_question_references",
|
||||||
columns: new[] { "question_owner_tenant_id", "question_id" });
|
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(
|
migrationBuilder.CreateIndex(
|
||||||
name: "ix_tenant_secrets_tenant_id_purpose_provider_secret_key",
|
name: "ix_tenant_secrets_tenant_id_purpose_provider_secret_key",
|
||||||
table: "tenant_secrets",
|
table: "tenant_secrets",
|
||||||
@@ -8850,6 +8923,11 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
table: "user_badges",
|
table: "user_badges",
|
||||||
column: "user_id");
|
column: "user_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_user_claims_user_id",
|
||||||
|
table: "user_claims",
|
||||||
|
column: "user_id");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "ix_user_identities_provider_provider_subject",
|
name: "ix_user_identities_provider_provider_subject",
|
||||||
table: "user_identities",
|
table: "user_identities",
|
||||||
@@ -8861,6 +8939,11 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
table: "user_identities",
|
table: "user_identities",
|
||||||
column: "user_id");
|
column: "user_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_user_logins_user_id",
|
||||||
|
table: "user_logins",
|
||||||
|
column: "user_id");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "ix_user_notifications_created_by",
|
name: "ix_user_notifications_created_by",
|
||||||
table: "user_notifications",
|
table: "user_notifications",
|
||||||
@@ -8942,10 +9025,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
column: "user_id");
|
column: "user_id");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "ix_users_email",
|
name: "email_index",
|
||||||
table: "users",
|
table: "users",
|
||||||
column: "email",
|
column: "normalized_email");
|
||||||
unique: true);
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "ix_users_legacy_id",
|
name: "ix_users_legacy_id",
|
||||||
@@ -8960,9 +9042,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
unique: true);
|
unique: true);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "ix_users_username",
|
name: "user_name_index",
|
||||||
table: "users",
|
table: "users",
|
||||||
column: "username",
|
column: "normalized_user_name",
|
||||||
unique: true);
|
unique: true);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
@@ -9347,6 +9429,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "app_assets");
|
name: "app_assets");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "auth_challenges");
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "auth_login_events");
|
name: "auth_login_events");
|
||||||
|
|
||||||
@@ -9395,6 +9480,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "dashboard_daily_stats");
|
name: "dashboard_daily_stats");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "data_protection_keys");
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "entitlements");
|
name: "entitlements");
|
||||||
|
|
||||||
@@ -9578,15 +9666,24 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "user_badges");
|
name: "user_badges");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "user_claims");
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "user_identities");
|
name: "user_identities");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "user_logins");
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "user_notifications");
|
name: "user_notifications");
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "user_score_events");
|
name: "user_score_events");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "user_tokens");
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "user_word_favorites");
|
name: "user_word_favorites");
|
||||||
|
|
||||||
@@ -9662,9 +9759,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "tenant_backend_roles");
|
name: "tenant_backend_roles");
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "tenant_role_templates");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "tenant_classes");
|
name: "tenant_classes");
|
||||||
|
|
||||||
@@ -25,6 +25,110 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree");
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree");
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
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 =>
|
modelBuilder.Entity("Tiku.Domain.Catalog.Category", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -7915,11 +8019,21 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnName("id")
|
.HasColumnName("id")
|
||||||
.HasDefaultValueSql("gen_random_uuid()");
|
.HasDefaultValueSql("gen_random_uuid()");
|
||||||
|
|
||||||
|
b.Property<int>("AccessFailedCount")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("access_failed_count");
|
||||||
|
|
||||||
b.Property<string>("AvatarUrl")
|
b.Property<string>("AvatarUrl")
|
||||||
.HasMaxLength(2048)
|
.HasMaxLength(2048)
|
||||||
.HasColumnType("character varying(2048)")
|
.HasColumnType("character varying(2048)")
|
||||||
.HasColumnName("avatar_url");
|
.HasColumnName("avatar_url");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)")
|
||||||
|
.HasColumnName("concurrency_stamp");
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("CreatedAt")
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
@@ -7931,6 +8045,14 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnType("citext")
|
.HasColumnType("citext")
|
||||||
.HasColumnName("email");
|
.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")
|
b.Property<DateTimeOffset?>("LastSeenAt")
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("last_seen_at");
|
.HasColumnName("last_seen_at");
|
||||||
@@ -7940,25 +8062,48 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnType("character varying(64)")
|
.HasColumnType("character varying(64)")
|
||||||
.HasColumnName("legacy_id");
|
.HasColumnName("legacy_id");
|
||||||
|
|
||||||
b.Property<string>("LegacyPasswordHash")
|
b.Property<bool>("LockoutEnabled")
|
||||||
.HasMaxLength(512)
|
.HasColumnType("boolean")
|
||||||
.HasColumnType("character varying(512)")
|
.HasColumnName("lockout_enabled");
|
||||||
.HasColumnName("legacy_password_hash");
|
|
||||||
|
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("lockout_end");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("character varying(200)")
|
.HasColumnType("character varying(200)")
|
||||||
.HasColumnName("name");
|
.HasColumnName("name");
|
||||||
|
|
||||||
b.Property<bool>("PasswordMigrationRequired")
|
b.Property<string>("NormalizedEmail")
|
||||||
.HasColumnType("boolean")
|
.HasMaxLength(320)
|
||||||
.HasColumnName("password_migration_required");
|
.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")
|
b.Property<string>("Phone")
|
||||||
.HasMaxLength(32)
|
.HasMaxLength(32)
|
||||||
.HasColumnType("character varying(32)")
|
.HasColumnType("character varying(32)")
|
||||||
.HasColumnName("phone");
|
.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")
|
b.Property<string>("PrimaryRole")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(50)
|
.HasMaxLength(50)
|
||||||
@@ -7975,36 +8120,50 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnType("integer")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("score");
|
.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")
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("updated_at")
|
.HasColumnName("updated_at")
|
||||||
.HasDefaultValueSql("now()");
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
b.Property<string>("Username")
|
b.Property<string>("UserName")
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("character varying(100)")
|
.HasColumnType("character varying(100)")
|
||||||
.HasColumnName("username");
|
.HasColumnName("user_name");
|
||||||
|
|
||||||
b.HasKey("Id")
|
b.HasKey("Id")
|
||||||
.HasName("pk_users");
|
.HasName("pk_users");
|
||||||
|
|
||||||
b.HasIndex("Email")
|
|
||||||
.IsUnique()
|
|
||||||
.HasDatabaseName("ix_users_email");
|
|
||||||
|
|
||||||
b.HasIndex("LegacyId")
|
b.HasIndex("LegacyId")
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasDatabaseName("ix_users_legacy_id");
|
.HasDatabaseName("ix_users_legacy_id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedEmail")
|
||||||
|
.HasDatabaseName("email_index");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedUserName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("user_name_index");
|
||||||
|
|
||||||
b.HasIndex("Phone")
|
b.HasIndex("Phone")
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasDatabaseName("ix_users_phone");
|
.HasDatabaseName("ix_users_phone");
|
||||||
|
|
||||||
b.HasIndex("Username")
|
|
||||||
.IsUnique()
|
|
||||||
.HasDatabaseName("ix_users_username");
|
|
||||||
|
|
||||||
b.ToTable("users", (string)null);
|
b.ToTable("users", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -8049,12 +8208,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnType("character varying(255)")
|
.HasColumnType("character varying(255)")
|
||||||
.HasColumnName("provider_subject");
|
.HasColumnName("provider_subject");
|
||||||
|
|
||||||
b.Property<JsonElement>("SecretPayload")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("jsonb")
|
|
||||||
.HasColumnName("secret_payload")
|
|
||||||
.HasDefaultValueSql("'{}'::jsonb");
|
|
||||||
|
|
||||||
b.Property<string>("UnionId")
|
b.Property<string>("UnionId")
|
||||||
.HasMaxLength(255)
|
.HasMaxLength(255)
|
||||||
.HasColumnType("character varying(255)")
|
.HasColumnType("character varying(255)")
|
||||||
@@ -12648,6 +12801,95 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("question_versions", (string)null);
|
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 =>
|
modelBuilder.Entity("Tiku.Domain.Tenancy.AuthLoginEvent", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -12752,20 +12994,53 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnName("metadata")
|
.HasColumnName("metadata")
|
||||||
.HasDefaultValueSql("'{}'::jsonb");
|
.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")
|
b.Property<string>("Provider")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(50)
|
.HasMaxLength(50)
|
||||||
.HasColumnType("character varying(50)")
|
.HasColumnType("character varying(50)")
|
||||||
.HasColumnName("provider");
|
.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")
|
b.Property<DateTimeOffset?>("RevokedAt")
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("revoked_at");
|
.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")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("tenant_id");
|
.HasColumnName("tenant_id");
|
||||||
|
|
||||||
|
b.Property<Guid>("TokenFamilyId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("token_family_id");
|
||||||
|
|
||||||
b.Property<string>("TokenHash")
|
b.Property<string>("TokenHash")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
@@ -12790,8 +13065,8 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
b.HasKey("Id")
|
b.HasKey("Id")
|
||||||
.HasName("pk_auth_sessions");
|
.HasName("pk_auth_sessions");
|
||||||
|
|
||||||
b.HasAlternateKey("TenantId", "Id")
|
b.HasIndex("TenantId")
|
||||||
.HasName("ak_auth_sessions_tenant_id_id");
|
.HasDatabaseName("ix_auth_sessions_tenant_id");
|
||||||
|
|
||||||
b.HasIndex("TokenHash")
|
b.HasIndex("TokenHash")
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
@@ -12801,11 +13076,17 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
b.HasIndex("UserId")
|
b.HasIndex("UserId")
|
||||||
.HasDatabaseName("ix_auth_sessions_user_id");
|
.HasDatabaseName("ix_auth_sessions_user_id");
|
||||||
|
|
||||||
b.HasIndex("TenantId", "UserId", "ExpiresAt")
|
b.HasIndex("TokenFamilyId", "RevokedAt")
|
||||||
.HasDatabaseName("ix_auth_sessions_tenant_id_user_id_expires_at")
|
.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");
|
.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 =>
|
modelBuilder.Entity("Tiku.Domain.Tenancy.SmsSendRateLimit", b =>
|
||||||
@@ -12931,9 +13212,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
b.HasKey("Id")
|
b.HasKey("Id")
|
||||||
.HasName("pk_sms_verification_codes");
|
.HasName("pk_sms_verification_codes");
|
||||||
|
|
||||||
b.HasAlternateKey("TenantId", "Id")
|
|
||||||
.HasName("ak_sms_verification_codes_tenant_id_id");
|
|
||||||
|
|
||||||
b.HasIndex("TenantId", "Phone", "Purpose")
|
b.HasIndex("TenantId", "Phone", "Purpose")
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasDatabaseName("ix_sms_verification_codes_tenant_id_phone_purpose")
|
.HasDatabaseName("ix_sms_verification_codes_tenant_id_phone_purpose")
|
||||||
@@ -13623,22 +13901,12 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnType("character varying(50)")
|
.HasColumnType("character varying(50)")
|
||||||
.HasColumnName("legacy_role");
|
.HasColumnName("legacy_role");
|
||||||
|
|
||||||
b.Property<JsonElement>("Permissions")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("jsonb")
|
|
||||||
.HasColumnName("permissions")
|
|
||||||
.HasDefaultValueSql("'{}'::jsonb");
|
|
||||||
|
|
||||||
b.Property<string>("Role")
|
b.Property<string>("Role")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(32)
|
.HasMaxLength(32)
|
||||||
.HasColumnType("character varying(32)")
|
.HasColumnType("character varying(32)")
|
||||||
.HasColumnName("role");
|
.HasColumnName("role");
|
||||||
|
|
||||||
b.Property<Guid?>("RoleTemplateId")
|
|
||||||
.HasColumnType("uuid")
|
|
||||||
.HasColumnName("role_template_id");
|
|
||||||
|
|
||||||
b.Property<string>("Status")
|
b.Property<string>("Status")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(32)
|
.HasMaxLength(32)
|
||||||
@@ -13668,9 +13936,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
b.HasIndex("UserId")
|
b.HasIndex("UserId")
|
||||||
.HasDatabaseName("ix_tenant_memberships_user_id");
|
.HasDatabaseName("ix_tenant_memberships_user_id");
|
||||||
|
|
||||||
b.HasIndex("TenantId", "RoleTemplateId")
|
|
||||||
.HasDatabaseName("ix_tenant_memberships_tenant_id_role_template_id");
|
|
||||||
|
|
||||||
b.HasIndex("TenantId", "UserId", "Role")
|
b.HasIndex("TenantId", "UserId", "Role")
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasDatabaseName("ix_tenant_memberships_tenant_id_user_id_role");
|
.HasDatabaseName("ix_tenant_memberships_tenant_id_user_id_role");
|
||||||
@@ -13678,126 +13943,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("tenant_memberships", (string)null);
|
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 =>
|
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantSecret", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -14153,6 +14298,36 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("tenant_student_notes", (string)null);
|
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 =>
|
modelBuilder.Entity("Tiku.Domain.Catalog.Category", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
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");
|
.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)
|
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("TenantId")
|
.HasForeignKey("TenantId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.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()
|
.IsRequired()
|
||||||
.HasConstraintName("fk_auth_login_events_tenants_tenant_id");
|
.HasConstraintName("fk_auth_login_events_tenants_tenant_id");
|
||||||
|
|
||||||
@@ -17226,7 +17417,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("TenantId")
|
.HasForeignKey("TenantId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired()
|
|
||||||
.HasConstraintName("fk_auth_sessions_tenants_tenant_id");
|
.HasConstraintName("fk_auth_sessions_tenants_tenant_id");
|
||||||
|
|
||||||
b.HasOne("Tiku.Domain.Identity.User", null)
|
b.HasOne("Tiku.Domain.Identity.User", null)
|
||||||
@@ -17375,35 +17565,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasConstraintName("fk_tenant_memberships_users_user_id");
|
.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 =>
|
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantSecret", b =>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
|
||||||
|
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using Tiku.Application.Security;
|
using Tiku.Application.Security;
|
||||||
using Tiku.Domain.Catalog;
|
using Tiku.Domain.Catalog;
|
||||||
@@ -18,7 +21,7 @@ namespace Tiku.Infrastructure.Persistence;
|
|||||||
|
|
||||||
public sealed class TikuDbContext(
|
public sealed class TikuDbContext(
|
||||||
DbContextOptions<TikuDbContext> options,
|
DbContextOptions<TikuDbContext> options,
|
||||||
ITenantContext tenantContext) : DbContext(options)
|
ITenantContext tenantContext) : IdentityUserContext<User, Guid>(options), IDataProtectionKeyContext
|
||||||
{
|
{
|
||||||
public TikuDbContext(DbContextOptions<TikuDbContext> options)
|
public TikuDbContext(DbContextOptions<TikuDbContext> options)
|
||||||
: this(options, CreateToolingTenantContext())
|
: this(options, CreateToolingTenantContext())
|
||||||
@@ -37,7 +40,8 @@ public sealed class TikuDbContext(
|
|||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
public DbSet<Tenant> Tenants => Set<Tenant>();
|
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<UserIdentity> UserIdentities => Set<UserIdentity>();
|
||||||
public DbSet<TenantMembership> TenantMemberships => Set<TenantMembership>();
|
public DbSet<TenantMembership> TenantMemberships => Set<TenantMembership>();
|
||||||
public DbSet<TenantDomain> TenantDomains => Set<TenantDomain>();
|
public DbSet<TenantDomain> TenantDomains => Set<TenantDomain>();
|
||||||
@@ -49,8 +53,8 @@ public sealed class TikuDbContext(
|
|||||||
public DbSet<SmsVerificationCode> SmsVerificationCodes => Set<SmsVerificationCode>();
|
public DbSet<SmsVerificationCode> SmsVerificationCodes => Set<SmsVerificationCode>();
|
||||||
public DbSet<AuthLoginEvent> AuthLoginEvents => Set<AuthLoginEvent>();
|
public DbSet<AuthLoginEvent> AuthLoginEvents => Set<AuthLoginEvent>();
|
||||||
public DbSet<AuthSession> AuthSessions => Set<AuthSession>();
|
public DbSet<AuthSession> AuthSessions => Set<AuthSession>();
|
||||||
|
public DbSet<AuthChallenge> AuthChallenges => Set<AuthChallenge>();
|
||||||
public DbSet<SmsSendRateLimit> SmsSendRateLimits => Set<SmsSendRateLimit>();
|
public DbSet<SmsSendRateLimit> SmsSendRateLimits => Set<SmsSendRateLimit>();
|
||||||
public DbSet<TenantRoleTemplate> TenantRoleTemplates => Set<TenantRoleTemplate>();
|
|
||||||
public DbSet<TenantClass> TenantClasses => Set<TenantClass>();
|
public DbSet<TenantClass> TenantClasses => Set<TenantClass>();
|
||||||
public DbSet<TenantClassMember> TenantClassMembers => Set<TenantClassMember>();
|
public DbSet<TenantClassMember> TenantClassMembers => Set<TenantClassMember>();
|
||||||
public DbSet<TenantStudentNote> TenantStudentNotes => Set<TenantStudentNote>();
|
public DbSet<TenantStudentNote> TenantStudentNotes => Set<TenantStudentNote>();
|
||||||
@@ -186,9 +190,14 @@ public sealed class TikuDbContext(
|
|||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
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("citext");
|
||||||
modelBuilder.HasPostgresExtension("ltree");
|
modelBuilder.HasPostgresExtension("ltree");
|
||||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(TikuDbContext).Assembly);
|
modelBuilder.ApplyConfigurationsFromAssembly(typeof(TikuDbContext).Assembly);
|
||||||
|
modelBuilder.Entity<DataProtectionKey>().ToTable("data_protection_keys");
|
||||||
ApplyTenantQueryFilters(modelBuilder);
|
ApplyTenantQueryFilters(modelBuilder);
|
||||||
ValidateTenantModel(modelBuilder);
|
ValidateTenantModel(modelBuilder);
|
||||||
modelBuilder.UseSnakeCaseIdentifiers();
|
modelBuilder.UseSnakeCaseIdentifiers();
|
||||||
@@ -298,6 +307,15 @@ public sealed class TikuDbContext(
|
|||||||
{
|
{
|
||||||
var now = DateTimeOffset.UtcNow;
|
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>())
|
foreach (var entry in ChangeTracker.Entries<IHasTimestamps>())
|
||||||
{
|
{
|
||||||
if (entry.State == EntityState.Added)
|
if (entry.State == EntityState.Added)
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService
|
|||||||
return new StudentProfileItem(
|
return new StudentProfileItem(
|
||||||
profile.Id,
|
profile.Id,
|
||||||
user.Id,
|
user.Id,
|
||||||
user.Username,
|
user.UserName,
|
||||||
user.Phone,
|
user.Phone,
|
||||||
user.Email,
|
user.Email,
|
||||||
user.Name,
|
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="AlipaySDKNet.Standard" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
<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.DependencyInjection.Abstractions" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
using System.Text.Json;
|
||||||
using Tiku.Application.Commerce;
|
using Tiku.Application.Commerce;
|
||||||
using Tiku.Application.Auth;
|
using Tiku.Application.Auth;
|
||||||
using Tiku.Application.Growth;
|
using Tiku.Application.Growth;
|
||||||
@@ -11,6 +13,7 @@ using Tiku.Application.Security;
|
|||||||
using Tiku.Application.Tenancy;
|
using Tiku.Application.Tenancy;
|
||||||
using Tiku.Api;
|
using Tiku.Api;
|
||||||
using Tiku.Domain.Identity;
|
using Tiku.Domain.Identity;
|
||||||
|
using Tiku.Domain.Operations;
|
||||||
using Tiku.Domain.QuestionBanks;
|
using Tiku.Domain.QuestionBanks;
|
||||||
using Tiku.Domain.Content;
|
using Tiku.Domain.Content;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
@@ -25,12 +28,32 @@ public sealed class ApiTestFactory(
|
|||||||
IReferralQrcodeGenerator? referralQrcodeGenerator = null,
|
IReferralQrcodeGenerator? referralQrcodeGenerator = null,
|
||||||
IPaymentProviderGateway? paymentProviderGateway = null,
|
IPaymentProviderGateway? paymentProviderGateway = null,
|
||||||
IDomainOwnershipVerifier? domainOwnershipVerifier = 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();
|
private readonly PostgresTestDatabase database = PostgresTestDatabase.Create();
|
||||||
|
|
||||||
protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
|
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 =>
|
builder.ConfigureServices(services =>
|
||||||
{
|
{
|
||||||
foreach (var descriptor in services
|
foreach (var descriptor in services
|
||||||
@@ -53,6 +76,8 @@ public sealed class ApiTestFactory(
|
|||||||
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName));
|
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName));
|
||||||
options.AddInterceptors(serviceProvider.GetRequiredService<TenantIsolationSaveChangesInterceptor>());
|
options.AddInterceptors(serviceProvider.GetRequiredService<TenantIsolationSaveChangesInterceptor>());
|
||||||
});
|
});
|
||||||
|
services.RemoveAll<IJwtKeyRing>();
|
||||||
|
services.AddSingleton<IJwtKeyRing, TestJwtKeyRing>();
|
||||||
|
|
||||||
if (wechatOAuthClient is not null)
|
if (wechatOAuthClient is not null)
|
||||||
{
|
{
|
||||||
@@ -85,6 +110,12 @@ public sealed class ApiTestFactory(
|
|||||||
services.RemoveAll<IDomainGatewayProvisioner>();
|
services.RemoveAll<IDomainGatewayProvisioner>();
|
||||||
services.AddSingleton(domainGatewayProvisioner);
|
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>();
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
dbContext.AddRange(entities);
|
dbContext.AddRange(entities);
|
||||||
await dbContext.SaveChangesAsync();
|
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")
|
public IServiceScope CreateSystemScope(string reason = "Integration test verification")
|
||||||
@@ -151,39 +267,51 @@ public sealed class ApiTestFactory(
|
|||||||
public async Task<Guid> SeedActiveSessionAsync(
|
public async Task<Guid> SeedActiveSessionAsync(
|
||||||
Guid userId,
|
Guid userId,
|
||||||
Guid? tenantId = null,
|
Guid? tenantId = null,
|
||||||
string tokenHash = "integration-test-token-hash")
|
string tokenHash = "integration-test-token-hash",
|
||||||
|
bool includeMembership = false)
|
||||||
{
|
{
|
||||||
var resolvedTenantId = tenantId ?? Guid.NewGuid();
|
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
|
new Tenant
|
||||||
{
|
{
|
||||||
Id = resolvedTenantId,
|
Id = resolvedTenantId,
|
||||||
Slug = resolvedTenantId.ToString("N"),
|
Slug = resolvedTenantId.ToString("N"),
|
||||||
Name = "Test Tenant"
|
Name = "Test Tenant"
|
||||||
},
|
},
|
||||||
new User
|
user,
|
||||||
|
session
|
||||||
|
};
|
||||||
|
if (includeMembership)
|
||||||
|
{
|
||||||
|
entities.Add(new TenantMembership
|
||||||
{
|
{
|
||||||
Id = userId,
|
|
||||||
Phone = "13800000000"
|
|
||||||
},
|
|
||||||
new AuthSession
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
TenantId = resolvedTenantId,
|
TenantId = resolvedTenantId,
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
TokenHash = tokenHash,
|
Role = TenantRole.Student,
|
||||||
Provider = "test",
|
Status = MembershipStatus.Active
|
||||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
using var scope = Services.CreateScope();
|
await SeedAsync([.. entities]);
|
||||||
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
|
return session.Id;
|
||||||
.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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
|
|||||||
@@ -214,8 +214,6 @@ public sealed class AssetAccessEndpointTests
|
|||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
var phone = "13800000000";
|
var phone = "13800000000";
|
||||||
var passwordHash = new PasswordHasher().Hash("passw0rd!");
|
|
||||||
|
|
||||||
await factory.SeedAsync(
|
await factory.SeedAsync(
|
||||||
Tenant(tenantId, tenantId.ToString("N")),
|
Tenant(tenantId, tenantId.ToString("N")),
|
||||||
new User
|
new User
|
||||||
@@ -223,21 +221,13 @@ public sealed class AssetAccessEndpointTests
|
|||||||
Id = userId,
|
Id = userId,
|
||||||
Phone = phone,
|
Phone = phone,
|
||||||
Name = "Test User"
|
Name = "Test User"
|
||||||
},
|
}.WithTestPassword(),
|
||||||
new TenantMembership
|
new TenantMembership
|
||||||
{
|
{
|
||||||
TenantId = tenantId,
|
TenantId = tenantId,
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
Role = TenantRole.Student,
|
Role = TenantRole.Student,
|
||||||
Status = MembershipStatus.Active
|
Status = MembershipStatus.Active
|
||||||
},
|
|
||||||
new UserIdentity
|
|
||||||
{
|
|
||||||
UserId = userId,
|
|
||||||
Provider = "password",
|
|
||||||
ProviderSubject = phone,
|
|
||||||
Phone = phone,
|
|
||||||
SecretPayload = CreateSecretPayload(passwordHash)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (tenantId, userId, phone);
|
return (tenantId, userId, phone);
|
||||||
@@ -247,20 +237,7 @@ public sealed class AssetAccessEndpointTests
|
|||||||
HttpClient client,
|
HttpClient client,
|
||||||
(Guid TenantId, Guid UserId, string Phone) seed)
|
(Guid TenantId, Guid UserId, string Phone) seed)
|
||||||
{
|
{
|
||||||
var loginResponse = await client.PostAsJsonAsync(
|
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
|
||||||
"/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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
|
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
|
||||||
@@ -269,13 +246,6 @@ public sealed class AssetAccessEndpointTests
|
|||||||
return await JsonDocument.ParseAsync(stream);
|
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
|
private sealed class FakeObjectStorageService : IObjectStorageService
|
||||||
{
|
{
|
||||||
public string ConfiguredDefaultProvider() => ObjectStorageProviders.LocalDev;
|
public string ConfiguredDefaultProvider() => ObjectStorageProviders.LocalDev;
|
||||||
|
|||||||
@@ -255,8 +255,6 @@ public sealed class AssetManagementEndpointTests
|
|||||||
var tenantId = Guid.NewGuid();
|
var tenantId = Guid.NewGuid();
|
||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
var phone = "13900000000";
|
var phone = "13900000000";
|
||||||
var passwordHash = new PasswordHasher().Hash("passw0rd!");
|
|
||||||
|
|
||||||
await factory.SeedAsync(
|
await factory.SeedAsync(
|
||||||
new Tenant
|
new Tenant
|
||||||
{
|
{
|
||||||
@@ -271,21 +269,13 @@ public sealed class AssetManagementEndpointTests
|
|||||||
Id = userId,
|
Id = userId,
|
||||||
Phone = phone,
|
Phone = phone,
|
||||||
Name = "Tenant Admin"
|
Name = "Tenant Admin"
|
||||||
},
|
}.WithTestPassword(),
|
||||||
new TenantMembership
|
new TenantMembership
|
||||||
{
|
{
|
||||||
TenantId = tenantId,
|
TenantId = tenantId,
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
Role = TenantRole.TenantAdmin,
|
Role = TenantRole.TenantAdmin,
|
||||||
Status = MembershipStatus.Active
|
Status = MembershipStatus.Active
|
||||||
},
|
|
||||||
new UserIdentity
|
|
||||||
{
|
|
||||||
UserId = userId,
|
|
||||||
Provider = "password",
|
|
||||||
ProviderSubject = phone,
|
|
||||||
Phone = phone,
|
|
||||||
SecretPayload = CreateSecretPayload(passwordHash)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (tenantId, userId, phone);
|
return (tenantId, userId, phone);
|
||||||
@@ -295,20 +285,7 @@ public sealed class AssetManagementEndpointTests
|
|||||||
HttpClient client,
|
HttpClient client,
|
||||||
(Guid TenantId, Guid UserId, string Phone) seed)
|
(Guid TenantId, Guid UserId, string Phone) seed)
|
||||||
{
|
{
|
||||||
var loginResponse = await client.PostAsJsonAsync(
|
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
|
||||||
"/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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
|
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
|
||||||
@@ -317,13 +294,6 @@ public sealed class AssetManagementEndpointTests
|
|||||||
return await JsonDocument.ParseAsync(stream);
|
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
|
private sealed class FakeObjectStorageService : IObjectStorageService
|
||||||
{
|
{
|
||||||
public long? MetadataSizeBytes { get; init; }
|
public long? MetadataSizeBytes { get; init; }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Tiku.Application.Auth;
|
using Tiku.Application.Auth;
|
||||||
@@ -15,6 +16,59 @@ namespace Tiku.IntegrationTests.Api;
|
|||||||
|
|
||||||
public sealed class AuthEndpointTests
|
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]
|
[Fact]
|
||||||
public async Task Custom_host_rejects_jwt_from_another_tenant_and_ignores_spoofed_tenant_header()
|
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
|
IsPrimary = true
|
||||||
});
|
});
|
||||||
using var client = factory.CreateClient();
|
using var client = factory.CreateClient();
|
||||||
var loginResponse = await client.PostAsJsonAsync(
|
var tokens = await client.LoginAsTenantAsync(tenantB.TenantId, tenantB.Phone);
|
||||||
"/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();
|
|
||||||
|
|
||||||
using var jwtRequest = new HttpRequestMessage(HttpMethod.Get, "/api/me");
|
using var jwtRequest = new HttpRequestMessage(HttpMethod.Get, "/api/me");
|
||||||
jwtRequest.Headers.Host = "a.example.test";
|
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);
|
var jwtResponse = await client.SendAsync(jwtRequest);
|
||||||
|
|
||||||
using var spoofRequest = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login/password");
|
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.Headers.Add("x-tenant-code", tenantB.TenantId.ToString("N"));
|
||||||
spoofRequest.Content = JsonContent.Create(new PasswordLoginDto
|
spoofRequest.Content = JsonContent.Create(new PasswordLoginDto
|
||||||
{
|
{
|
||||||
|
Realm = AuthRealm.Tenant,
|
||||||
Phone = tenantB.Phone,
|
Phone = tenantB.Phone,
|
||||||
Password = "passw0rd!"
|
Password = PasswordTestUserExtensions.TestPassword
|
||||||
});
|
});
|
||||||
var spoofResponse = await client.SendAsync(spoofRequest);
|
var spoofResponse = await client.SendAsync(spoofRequest);
|
||||||
|
|
||||||
@@ -69,6 +115,54 @@ public sealed class AuthEndpointTests
|
|||||||
Assert.NotEqual(HttpStatusCode.OK, spoofResponse.StatusCode);
|
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]
|
[Fact]
|
||||||
public async Task Password_login_can_access_current_user_and_tenant()
|
public async Task Password_login_can_access_current_user_and_tenant()
|
||||||
{
|
{
|
||||||
@@ -76,25 +170,11 @@ public sealed class AuthEndpointTests
|
|||||||
var seed = await SeedLoginUserAsync(factory);
|
var seed = await SeedLoginUserAsync(factory);
|
||||||
using var client = factory.CreateClient();
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
var loginResponse = await client.PostAsJsonAsync(
|
var tokens = await client.LoginAsTenantAsync(seed.TenantId, seed.Phone);
|
||||||
"/api/auth/login/password",
|
client.UseAccessToken(tokens);
|
||||||
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 meResponse = await client.GetAsync("/api/me");
|
var meResponse = await client.GetAsync("/api/me");
|
||||||
var tenantResponse = await client.GetAsync("/api/tenants/current");
|
var tenantResponse = await client.GetAsync("/api/tenants/current");
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode);
|
|
||||||
Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode);
|
||||||
Assert.Equal(HttpStatusCode.OK, tenantResponse.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, tenantResponse.StatusCode);
|
||||||
Assert.Contains(seed.UserId.ToString(), await meResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
|
Assert.Contains(seed.UserId.ToString(), await meResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
|
||||||
@@ -108,22 +188,22 @@ public sealed class AuthEndpointTests
|
|||||||
var seed = await SeedLoginUserAsync(factory);
|
var seed = await SeedLoginUserAsync(factory);
|
||||||
await SeedSmsCodeAsync(factory, seed.TenantId, seed.Phone, "123456");
|
await SeedSmsCodeAsync(factory, seed.TenantId, seed.Phone, "123456");
|
||||||
using var client = factory.CreateClient();
|
using var client = factory.CreateClient();
|
||||||
|
client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N"));
|
||||||
|
|
||||||
var loginResponse = await client.PostAsJsonAsync(
|
var loginResponse = await client.PostAsJsonAsync(
|
||||||
"/api/auth/login/sms",
|
"/api/auth/login/sms",
|
||||||
new SmsLoginDto
|
new SmsLoginDto
|
||||||
{
|
{
|
||||||
|
Realm = AuthRealm.Tenant,
|
||||||
TenantCode = seed.TenantId.ToString("N"),
|
TenantCode = seed.TenantId.ToString("N"),
|
||||||
Phone = seed.Phone,
|
Phone = seed.Phone,
|
||||||
Code = "123456"
|
Code = "123456"
|
||||||
});
|
});
|
||||||
var loginJson = await ReadJsonAsync(loginResponse);
|
var tokens = await client.CompleteTenantAuthenticationAsync(
|
||||||
var accessToken = loginJson.RootElement
|
loginResponse,
|
||||||
.GetProperty("tokens")
|
seed.TenantId,
|
||||||
.GetProperty("accessToken")
|
seed.Phone);
|
||||||
.GetString();
|
client.UseAccessToken(tokens);
|
||||||
|
|
||||||
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
|
||||||
var meResponse = await client.GetAsync("/api/me");
|
var meResponse = await client.GetAsync("/api/me");
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode);
|
||||||
@@ -136,27 +216,16 @@ public sealed class AuthEndpointTests
|
|||||||
await using var factory = new ApiTestFactory();
|
await using var factory = new ApiTestFactory();
|
||||||
var seed = await SeedLoginUserAsync(factory);
|
var seed = await SeedLoginUserAsync(factory);
|
||||||
using var client = factory.CreateClient();
|
using var client = factory.CreateClient();
|
||||||
var loginResponse = await client.PostAsJsonAsync(
|
var tokens = await client.LoginAsTenantAsync(seed.TenantId, seed.Phone);
|
||||||
"/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 logoutResponse = await client.PostAsJsonAsync(
|
var logoutResponse = await client.PostAsJsonAsync(
|
||||||
"/api/auth/logout",
|
"/api/auth/logout",
|
||||||
new RefreshSessionDto { RefreshToken = refreshToken! });
|
new RefreshSessionDto { RefreshToken = tokens.RefreshToken });
|
||||||
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
client.UseAccessToken(tokens);
|
||||||
var meResponse = await client.GetAsync("/api/me");
|
var meResponse = await client.GetAsync("/api/me");
|
||||||
var refreshResponse = await client.PostAsJsonAsync(
|
var refreshResponse = await client.PostAsJsonAsync(
|
||||||
"/api/auth/refresh",
|
"/api/auth/refresh",
|
||||||
new RefreshSessionDto { RefreshToken = refreshToken! });
|
new RefreshSessionDto { RefreshToken = tokens.RefreshToken });
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.NoContent, logoutResponse.StatusCode);
|
Assert.Equal(HttpStatusCode.NoContent, logoutResponse.StatusCode);
|
||||||
Assert.Equal(HttpStatusCode.Unauthorized, meResponse.StatusCode);
|
Assert.Equal(HttpStatusCode.Unauthorized, meResponse.StatusCode);
|
||||||
@@ -206,16 +275,19 @@ public sealed class AuthEndpointTests
|
|||||||
EncryptionTag = protectedSecret.Tag
|
EncryptionTag = protectedSecret.Tag
|
||||||
});
|
});
|
||||||
using var client = factory.CreateClient();
|
using var client = factory.CreateClient();
|
||||||
|
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
|
||||||
|
|
||||||
var loginResponse = await client.PostAsJsonAsync(
|
var loginResponse = await client.PostAsJsonAsync(
|
||||||
"/api/auth/oauth/wechat-miniapp",
|
"/api/auth/oauth/wechat-miniapp",
|
||||||
new OAuthCodeDto
|
new OAuthCodeDto
|
||||||
{
|
{
|
||||||
|
Realm = AuthRealm.Tenant,
|
||||||
TenantCode = tenantId.ToString("N"),
|
TenantCode = tenantId.ToString("N"),
|
||||||
Code = "wx-code"
|
Code = "wx-code"
|
||||||
});
|
});
|
||||||
var loginJson = await ReadJsonAsync(loginResponse);
|
var loginJson = await ReadJsonAsync(loginResponse);
|
||||||
var accessToken = loginJson.RootElement
|
var accessToken = loginJson.RootElement
|
||||||
|
.GetProperty("user")
|
||||||
.GetProperty("tokens")
|
.GetProperty("tokens")
|
||||||
.GetProperty("accessToken")
|
.GetProperty("accessToken")
|
||||||
.GetString();
|
.GetString();
|
||||||
@@ -231,6 +303,13 @@ public sealed class AuthEndpointTests
|
|||||||
identity.Provider == "wechat_miniapp" &&
|
identity.Provider == "wechat_miniapp" &&
|
||||||
identity.OpenId == "mini-open-id" &&
|
identity.OpenId == "mini-open-id" &&
|
||||||
identity.UnionId == "union-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(
|
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLoginUserAsync(
|
||||||
@@ -239,8 +318,6 @@ public sealed class AuthEndpointTests
|
|||||||
var tenantId = Guid.NewGuid();
|
var tenantId = Guid.NewGuid();
|
||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
var phone = "13800000000";
|
var phone = "13800000000";
|
||||||
var passwordHash = new PasswordHasher().Hash("passw0rd!");
|
|
||||||
|
|
||||||
await factory.SeedAsync(
|
await factory.SeedAsync(
|
||||||
new Tenant
|
new Tenant
|
||||||
{
|
{
|
||||||
@@ -253,21 +330,13 @@ public sealed class AuthEndpointTests
|
|||||||
Id = userId,
|
Id = userId,
|
||||||
Phone = phone,
|
Phone = phone,
|
||||||
Name = "Test User"
|
Name = "Test User"
|
||||||
},
|
}.WithTestPassword(),
|
||||||
new TenantMembership
|
new TenantMembership
|
||||||
{
|
{
|
||||||
TenantId = tenantId,
|
TenantId = tenantId,
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
Role = TenantRole.TenantAdmin,
|
Role = TenantRole.TenantAdmin,
|
||||||
Status = MembershipStatus.Active
|
Status = MembershipStatus.Active
|
||||||
},
|
|
||||||
new UserIdentity
|
|
||||||
{
|
|
||||||
UserId = userId,
|
|
||||||
Provider = "password",
|
|
||||||
ProviderSubject = phone,
|
|
||||||
Phone = phone,
|
|
||||||
SecretPayload = CreateSecretPayload(passwordHash)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (tenantId, userId, phone);
|
return (tenantId, userId, phone);
|
||||||
@@ -281,12 +350,18 @@ public sealed class AuthEndpointTests
|
|||||||
{
|
{
|
||||||
using var scope = factory.CreateSystemScope();
|
using var scope = factory.CreateSystemScope();
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
var smsOptions = scope.ServiceProvider.GetRequiredService<IOptions<SmsSecurityOptions>>().Value;
|
||||||
dbContext.SmsVerificationCodes.Add(new SmsVerificationCode
|
dbContext.SmsVerificationCodes.Add(new SmsVerificationCode
|
||||||
{
|
{
|
||||||
TenantId = tenantId,
|
TenantId = tenantId,
|
||||||
Phone = phone,
|
Phone = phone,
|
||||||
Purpose = SmsPurpose.Login,
|
Purpose = SmsPurpose.Login,
|
||||||
CodeHash = SmsCodeHashing.Hash(tenantId, phone, SmsPurpose.Login, code),
|
CodeHash = SmsCodeHashing.Hash(
|
||||||
|
tenantId,
|
||||||
|
phone,
|
||||||
|
SmsPurpose.Login,
|
||||||
|
code,
|
||||||
|
smsOptions.CodePepper),
|
||||||
Status = SmsVerificationStatus.Sent,
|
Status = SmsVerificationStatus.Sent,
|
||||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5)
|
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5)
|
||||||
});
|
});
|
||||||
@@ -299,13 +374,6 @@ public sealed class AuthEndpointTests
|
|||||||
return await JsonDocument.ParseAsync(stream);
|
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)
|
private static ProtectedTenantSecret ProtectTenantSecret(Guid tenantId, string secretRef, JsonElement payload)
|
||||||
{
|
{
|
||||||
var protector = new TenantSecretProtector(Options.Create(new TenantSecretEncryptionOptions
|
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"}"""));
|
"""{"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;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
|
||||||
using Tiku.Api.Contracts;
|
using Tiku.Api.Contracts;
|
||||||
using Tiku.Api.Options;
|
using Tiku.Api.Options;
|
||||||
using Tiku.Application.Auth;
|
using Tiku.Application.Auth;
|
||||||
@@ -21,13 +18,6 @@ namespace Tiku.IntegrationTests.Api;
|
|||||||
|
|
||||||
public sealed class CommerceEndpointTests
|
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]
|
[Fact]
|
||||||
public async Task Anonymous_commerce_request_returns_401()
|
public async Task Anonymous_commerce_request_returns_401()
|
||||||
{
|
{
|
||||||
@@ -60,11 +50,10 @@ public sealed class CommerceEndpointTests
|
|||||||
using var client = factory.CreateClient();
|
using var client = factory.CreateClient();
|
||||||
client.DefaultRequestHeaders.Authorization = new(
|
client.DefaultRequestHeaders.Authorization = new(
|
||||||
"Bearer",
|
"Bearer",
|
||||||
CreateToken([
|
TestJwtKeys.CreateToken([
|
||||||
new Claim(TikuClaimTypes.UserId, userId.ToString()),
|
new Claim(TikuClaimTypes.UserId, userId.ToString()),
|
||||||
new Claim(TikuClaimTypes.SessionId, sessionId.ToString()),
|
new Claim(TikuClaimTypes.SessionId, sessionId.ToString()),
|
||||||
new Claim(TikuClaimTypes.TenantId, tenantId.ToString()),
|
new Claim(TikuClaimTypes.TenantId, tenantId.ToString())
|
||||||
new Claim(TikuClaimTypes.TenantRole, TenantRole.Student.ToString())
|
|
||||||
]));
|
]));
|
||||||
|
|
||||||
var response = await client.PostAsJsonAsync(
|
var response = await client.PostAsJsonAsync(
|
||||||
@@ -343,7 +332,6 @@ public sealed class CommerceEndpointTests
|
|||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
var planId = Guid.NewGuid();
|
var planId = Guid.NewGuid();
|
||||||
var phone = "13800000000";
|
var phone = "13800000000";
|
||||||
var passwordHash = new PasswordHasher().Hash("passw0rd!");
|
|
||||||
var entities = new List<object>
|
var entities = new List<object>
|
||||||
{
|
{
|
||||||
new Tenant
|
new Tenant
|
||||||
@@ -357,15 +345,7 @@ public sealed class CommerceEndpointTests
|
|||||||
Id = userId,
|
Id = userId,
|
||||||
Phone = phone,
|
Phone = phone,
|
||||||
Name = "Commerce User"
|
Name = "Commerce User"
|
||||||
},
|
}.WithTestPassword(),
|
||||||
new UserIdentity
|
|
||||||
{
|
|
||||||
UserId = userId,
|
|
||||||
Provider = "password",
|
|
||||||
ProviderSubject = phone,
|
|
||||||
Phone = phone,
|
|
||||||
SecretPayload = CreateSecretPayload(passwordHash)
|
|
||||||
},
|
|
||||||
new SvipPlan
|
new SvipPlan
|
||||||
{
|
{
|
||||||
Id = planId,
|
Id = planId,
|
||||||
@@ -401,44 +381,7 @@ public sealed class CommerceEndpointTests
|
|||||||
|
|
||||||
private static async Task LoginAsync(HttpClient client, LoginSeed seed)
|
private static async Task LoginAsync(HttpClient client, LoginSeed seed)
|
||||||
{
|
{
|
||||||
var loginResponse = await client.PostAsJsonAsync(
|
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
|
||||||
"/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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record LoginSeed(Guid TenantId, Guid UserId, Guid PlanId, string 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(admin, "tenant_admin"),
|
||||||
User(referrer, "sales"),
|
User(referrer, "sales"),
|
||||||
User(student, "student"),
|
User(student, "student"),
|
||||||
Identity(admin),
|
|
||||||
Identity(referrer),
|
|
||||||
Identity(student),
|
|
||||||
Membership(admin, TenantRole.TenantAdmin),
|
Membership(admin, TenantRole.TenantAdmin),
|
||||||
Membership(referrer, TenantRole.Sales),
|
Membership(referrer, TenantRole.Sales),
|
||||||
Membership(student, TenantRole.Student),
|
Membership(student, TenantRole.Student),
|
||||||
@@ -156,22 +153,13 @@ public sealed class CommissionEndpointTests
|
|||||||
return new CommissionSeed(tenantId, admin, referrer, student);
|
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 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)
|
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!" });
|
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record LoginSeed(Guid TenantId, Guid UserId, string 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);
|
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)
|
private static async Task<Guid> CreateEntryAsync(HttpClient client)
|
||||||
{
|
{
|
||||||
using var response = await client.PostAsJsonAsync(
|
using var response = await client.PostAsJsonAsync(
|
||||||
@@ -187,8 +260,6 @@ public sealed class ContentManagementEndpointTests
|
|||||||
var tenantId = Guid.NewGuid();
|
var tenantId = Guid.NewGuid();
|
||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
var phone = "13700000000";
|
var phone = "13700000000";
|
||||||
var passwordHash = new PasswordHasher().Hash("passw0rd!");
|
|
||||||
|
|
||||||
await factory.SeedAsync(
|
await factory.SeedAsync(
|
||||||
new Tenant
|
new Tenant
|
||||||
{
|
{
|
||||||
@@ -203,21 +274,13 @@ public sealed class ContentManagementEndpointTests
|
|||||||
Id = userId,
|
Id = userId,
|
||||||
Phone = phone,
|
Phone = phone,
|
||||||
Name = "Tenant Admin"
|
Name = "Tenant Admin"
|
||||||
},
|
}.WithTestPassword(),
|
||||||
new TenantMembership
|
new TenantMembership
|
||||||
{
|
{
|
||||||
TenantId = tenantId,
|
TenantId = tenantId,
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
Role = TenantRole.TenantAdmin,
|
Role = TenantRole.TenantAdmin,
|
||||||
Status = MembershipStatus.Active
|
Status = MembershipStatus.Active
|
||||||
},
|
|
||||||
new UserIdentity
|
|
||||||
{
|
|
||||||
UserId = userId,
|
|
||||||
Provider = "password",
|
|
||||||
ProviderSubject = phone,
|
|
||||||
Phone = phone,
|
|
||||||
SecretPayload = CreateSecretPayload(passwordHash)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (tenantId, userId, phone);
|
return (tenantId, userId, phone);
|
||||||
@@ -227,20 +290,17 @@ public sealed class ContentManagementEndpointTests
|
|||||||
HttpClient client,
|
HttpClient client,
|
||||||
(Guid TenantId, Guid UserId, string Phone) seed)
|
(Guid TenantId, Guid UserId, string Phone) seed)
|
||||||
{
|
{
|
||||||
var loginResponse = await client.PostAsJsonAsync(
|
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
|
||||||
"/api/auth/login/password",
|
}
|
||||||
new PasswordLoginDto
|
|
||||||
{
|
private static async Task SetDataScopeAsync(ApiTestFactory factory, Guid tenantId, object value)
|
||||||
TenantCode = seed.TenantId.ToString("N"),
|
{
|
||||||
Phone = seed.Phone,
|
using var scope = factory.CreateSystemScope();
|
||||||
Password = "passw0rd!"
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
});
|
var role = dbContext.TenantBackendRoles.Single(item =>
|
||||||
var loginJson = await ReadJsonAsync(loginResponse);
|
item.TenantId == tenantId && item.Code == "integration_test_admin");
|
||||||
var accessToken = loginJson.RootElement
|
role.DataScope = JsonSerializer.SerializeToElement(value);
|
||||||
.GetProperty("tokens")
|
await dbContext.SaveChangesAsync();
|
||||||
.GetProperty("accessToken")
|
|
||||||
.GetString();
|
|
||||||
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
|
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
|
||||||
@@ -249,10 +309,4 @@ public sealed class ContentManagementEndpointTests
|
|||||||
return await JsonDocument.ParseAsync(stream);
|
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";
|
var phone = "13800002001";
|
||||||
await factory.SeedAsync(
|
await factory.SeedAsync(
|
||||||
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "CRM Tenant" },
|
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
|
new TenantMembership
|
||||||
{
|
{
|
||||||
TenantId = tenantId,
|
TenantId = tenantId,
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
Role = TenantRole.TenantAdmin,
|
Role = TenantRole.TenantAdmin,
|
||||||
Status = MembershipStatus.Active
|
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);
|
return new LoginSeed(tenantId, userId, phone);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task LoginAsync(HttpClient client, LoginSeed seed)
|
private static async Task LoginAsync(HttpClient client, LoginSeed seed)
|
||||||
{
|
{
|
||||||
var loginResponse = await client.PostAsJsonAsync(
|
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
|
||||||
"/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 sealed record LoginSeed(Guid TenantId, Guid UserId, string 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