feat: harden SaaS authentication and authorization

This commit is contained in:
2026-07-28 12:15:51 +08:00
parent f22f329d33
commit 5d2248efee
123 changed files with 9090 additions and 2822 deletions

View File

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

View File

@@ -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 注册;检测到已有平台管理员时命令会拒绝重复引导。不要把临时密码写入仓库配置或命令行参数。

View File

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

View File

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

View File

@@ -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/unionidupsert 用户身份并创建应用会话。")] [EndpointDescription("使用微信网页授权 code 换取 openid/unionidupsert 用户身份并创建应用会话。")]
[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_keyupsert 用户身份并创建应用会话。")] [EndpointDescription("使用小程序 wx.login 返回的 code 换取 openid/session_keyupsert 用户身份并创建应用会话。")]
[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.");
}
}
} }

View File

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

View File

@@ -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 async Task<BackofficeActor> ResolvePlatformActorAsync(CancellationToken cancellationToken)
}
private BackofficeActor ResolvePlatformActor()
{ {
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);
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

@@ -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; options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
if (rateLimitOptions.Enabled)
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext => options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
{ {
var partitionKey = var partitionKey =
@@ -139,6 +148,44 @@ try
Window = TimeSpan.FromSeconds(rateLimitOptions.WindowSeconds) Window = TimeSpan.FromSeconds(rateLimitOptions.WindowSeconds)
}); });
}); });
}
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) => options.OnRejected = async (context, cancellationToken) =>
{ {
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
@@ -159,13 +206,55 @@ try
await context.HttpContext.Response.WriteAsJsonAsync(problem, cancellationToken); 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,26 +339,76 @@ 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>();
if (realm == Tiku.Domain.Tenancy.AuthRealm.Platform)
{
if (!isPlatformHost || resolvedTenantContext.IsResolved)
{
context.HttpContext.Items["tenant_context_conflict"] = true;
context.Fail("Platform tokens are only valid on a platform host.");
return;
}
}
else
{
if (isPlatformHost && !resolvedTenantContext.IsResolved)
{
context.HttpContext.Items["tenant_context_conflict"] = true;
context.Fail("Tenant tokens on a platform host require a matching tenant code.");
return;
}
try try
{ {
tenantInitializer.Initialize(tenantId, null, TenantResolutionSource.Jwt); tenantInitializer.Initialize(tenantId!.Value, null, TenantResolutionSource.Jwt);
} }
catch (TenantContextConflictException) catch (TenantContextConflictException)
{ {
@@ -264,30 +416,16 @@ try
context.Fail("Authenticated tenant does not match the request host."); context.Fail("Authenticated tenant does not match the request host.");
return; return;
} }
if (!jwtOptions.ValidateSessions)
{
return;
} }
var sessionIdValue = context.Principal?.FindFirst(TikuClaimTypes.SessionId)?.Value; var sessionStore = context.HttpContext.RequestServices.GetRequiredService<IAuthSessionStore>();
if (!Guid.TryParse(sessionIdValue, out var sessionId)) 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)
{ {
context.Fail("Missing session claim."); context.Fail("Session, identity, membership, tenant, role or MFA state is no longer valid.");
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();

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

View File

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

View File

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

View File

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

View File

@@ -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": "*"

View File

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

View File

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

View File

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

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

View File

@@ -1,7 +0,0 @@
namespace Tiku.Application.Auth;
public interface IPasswordHasher
{
string Hash(string password);
bool Verify(string password, string passwordHash);
}

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

@@ -0,0 +1,9 @@
using Microsoft.IdentityModel.Tokens;
namespace Tiku.Application.Security;
public interface IJwtKeyRing
{
SigningCredentials SigningCredentials { get; }
IReadOnlyCollection<SecurityKey> ValidationKeys { get; }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,4 +6,8 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Identity.Stores" />
</ItemGroup>
</Project> </Project>

View File

@@ -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(
entity =>
entity.UserId == user.Id &&
entity.Provider == PasswordProvider &&
entity.ProviderSubject == phone,
cancellationToken); cancellationToken);
var passwordResult = user is null || user.Status != UserStatus.Active
? SignInResult.Failed
: await signInManager.CheckPasswordSignInAsync(user, request.Password, lockoutOnFailure: true);
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);
}
public async Task LogoutAllAsync(Guid userId, CancellationToken cancellationToken = default)
{ {
return; var user = await userManager.FindByIdAsync(userId.ToString())
} ?? throw new InvalidCredentialsException();
var stampResult = await userManager.UpdateSecurityStampAsync(user);
var tokenHash = sessionService.HashRefreshToken(request.RefreshToken); if (!stampResult.Succeeded)
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; throw new InvalidOperationException("Unable to update the user's security stamp.");
} }
session.RevokedAt = DateTimeOffset.UtcNow; await sessionStore.RevokeAllAsync(userId, "logout_all", cancellationToken);
await AddLoginEventAsync(
session.TenantId,
session.UserId,
"logout",
null,
AuthLoginResult.Success,
null,
null,
null,
cancellationToken);
} }
private async Task<AuthenticatedUser> CompleteSuccessfulLoginAsync( public async Task<MfaSetupResult> SetupTotpAsync(
Guid tenantId, 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, realm, tenantId, AuthChallengePurpose.PasswordChange, provider,
AuthenticationStatus.PasswordChangeRequired, ipAddress, userAgent, cancellationToken);
}
var requiresMfa = await HasBackendPermissionsAsync(realm, tenantId, user.Id, cancellationToken);
if (requiresMfa)
{
var hasAuthenticator = user.TwoFactorEnabled &&
!string.IsNullOrWhiteSpace(await userManager.GetAuthenticatorKeyAsync(user));
return await CreateChallengeResultAsync(
user, realm, tenantId,
hasAuthenticator ? AuthChallengePurpose.MfaVerification : AuthChallengePurpose.MfaEnrollment,
provider,
hasAuthenticator ? AuthenticationStatus.MfaRequired : AuthenticationStatus.MfaEnrollmentRequired,
ipAddress, userAgent, cancellationToken);
}
return await IssueAuthenticatedResultAsync(
user, realm, tenant, membership, provider, mfaSatisfied: false,
identifier, ipAddress, userAgent, cancellationToken);
}
private async Task<AuthenticationResult> IssueAuthenticatedResultAsync(
User user,
AuthRealm realm,
Tenant? tenant,
TenantMembership? membership,
string provider,
bool mfaSatisfied,
string? identifier,
string? ipAddress,
string? userAgent,
CancellationToken cancellationToken)
{
var tokens = await sessionStore.IssueAsync(
new AuthSessionIssueRequest(
user.Id, user.Id,
user.Phone, user.Phone,
user.Email, user.Email,
membership, user.SecurityStamp ?? string.Empty,
realm,
tenant?.Id,
provider, provider,
mfaSatisfied,
ipAddress, ipAddress,
userAgent, 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);
}
} }

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View 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

View File

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

View File

@@ -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)
{ {
Id = userId, entities.Add(new TenantMembership
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)

View File

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

View File

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

View File

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

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

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

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

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

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

View File

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

View File

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

View File

@@ -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"), using var scope = factory.CreateSystemScope();
Phone = seed.Phone, var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
Password = "passw0rd!" var role = dbContext.TenantBackendRoles.Single(item =>
}); item.TenantId == tenantId && item.Code == "integration_test_admin");
var loginJson = await ReadJsonAsync(loginResponse); role.DataScope = JsonSerializer.SerializeToElement(value);
var accessToken = loginJson.RootElement await dbContext.SaveChangesAsync();
.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)
@@ -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();
}
} }

View File

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