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.Authentication.JwtBearer" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -24,6 +26,7 @@
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="10.0.10" />
<PackageVersion Include="AlibabaCloud.OSS.V2" Version="0.2.0" />
<PackageVersion Include="AlibabaCloud.SDK.Dysmsapi20170525" Version="4.4.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />

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-2-engineering-foundation.md`](docs/migration/phase-2-engineering-foundation.md)
- [`docs/migration/phase-3-tenant-isolation-and-shared-question-bank.md`](docs/migration/phase-3-tenant-isolation-and-shared-question-bank.md)
@@ -59,7 +60,7 @@ Tiku.IntegrationTests # API / EF 模型集成测试
- ASP.NET Authorization 负责权限策略。
- JWT + 数据库 `auth_sessions` 负责 access/refresh/session 闭环。
- `ICurrentUser` / 只读 `ITenantContext` 统一当前用户和请求租户上下文。
- Refresh Token 采用 `v1.{tenantId}.{sessionId}.{secret}` 结构,刷新和退出先解析租户再按 `TenantId + SessionId + TokenHash` 定位
- Refresh Token 采用 `v2.{t|p}.{tenantId|-}.{sessionId}.{secret}` 结构,刷新和退出先验证 realm/Host/tenant再通过统一 Session Store 定位并撤销 token family
- EF Core Query Filter、写入拦截器和 PostgreSQL 组合约束共同阻断跨租户读写。
- PostgreSQL FK / unique / check / index 负责数据完整性底线。
- 审计事件表记录关键行为。
@@ -147,7 +148,7 @@ Tiku.IntegrationTests # API / EF 模型集成测试
数据库模型迁移已经完成到 greenfield 初始 schema
```text
Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.cs
Tiku.Infrastructure/Persistence/Migrations/20260728031410_InitialSchema.cs
```
当前 EF 模型覆盖:
@@ -171,12 +172,14 @@ Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.cs
已完成:
- JWT Bearer 认证
- 数据库 Session 校验;登出/撤销后旧 token 会被拒绝
- 手机号 + 密码登录
- 短信验证码登录
- 微信网页 OAuth 登录
- 微信小程序登录
- ASP.NET Core Identity 密码、锁定、SecurityStamp、强制改密、TOTP 和恢复码
- `kid` 的 RSA JWT Bearer 认证和旧公钥轮换验证
- tenant/platform 双 realm 与 Host、tenant claim、数据库 Session 联合校验
- Session family 原子 refresh、重放撤销、logout 和 logout-all
- 手机号/邮箱/用户名 + 密码登录、短信验证码登录和安全短信发送入口
- 微信网页 OAuth 和微信小程序登录,外部身份不保存 `session_key`
- 数据库 tenant/platform RBAC、MFA policy、资源型授权和 DataScope SQL。
- 按有效权限生成 tenant/platform UI 菜单 bootstrap菜单不作为 API 授权依据。
- 租户级身份 Provider 配置解析。
- 当前用户 `/api/me`
- 当前租户 `/api/tenants/current`
@@ -184,6 +187,8 @@ Tiku.Infrastructure/Persistence/Migrations/20260727093301_InitialSchema.cs
- Host 解析的前端运行时配置 `/api/runtime/bootstrap`
- 统一异常响应和请求日志。
完整安全策略、Host 判定矩阵和登录/Session 文字流程图见 [`docs/architecture/authentication-authorization-security.md`](docs/architecture/authentication-authorization-security.md)。
### 已迁移 API
2026-07-27 运行时 OpenAPI 基线包含 192 个路径、237 个操作。已完成的业务/API 闭环包括:
@@ -250,3 +255,14 @@ dotnet ef migrations script \
--project Tiku.Infrastructure \
--startup-project Tiku.DbMigrator
```
首次部署可在迁移完成后一次性创建平台超级管理员:
```bash
export TIKU_BOOTSTRAP_PLATFORM_ADMIN_EMAIL='admin@example.com'
export TIKU_BOOTSTRAP_PLATFORM_ADMIN_PASSWORD='replace-with-a-strong-temporary-password'
export TIKU_BOOTSTRAP_PLATFORM_ADMIN_NAME='Platform Administrator'
dotnet run --project Tiku.DbMigrator -- --bootstrap-platform-admin
```
该命令只允许在不存在任何平台角色用户绑定时执行。创建的账号必须在首次登录时修改临时密码并完成 TOTP MFA 注册;检测到已有平台管理员时命令会拒绝重复引导。不要把临时密码写入仓库配置或命令行参数。

View File

@@ -1,6 +1,7 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using Tiku.Application.Auth;
using Tiku.Domain.Tenancy;
namespace Tiku.Api.Contracts;
@@ -9,6 +10,8 @@ namespace Tiku.Api.Contracts;
/// </summary>
public sealed class PasswordLoginDto
{
[Required]
public AuthRealm? Realm { get; set; }
/// <summary>
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略。
/// </summary>
@@ -17,18 +20,21 @@ public sealed class PasswordLoginDto
public string? TenantCode { get; set; }
/// <summary>
/// 手机号,建议前端提交规范化后的中国大陆手机号
/// 账号标识。tenant 可使用手机号platform 可使用邮箱或用户名
/// </summary>
[Required]
[StringLength(320)]
[Description("账号标识。tenant 可使用手机号platform 可使用邮箱或用户名。")]
public string? Identifier { get; set; }
[StringLength(32)]
[Description("手机号,建议前端提交规范化后的中国大陆手机号。")]
public string Phone { get; set; } = string.Empty;
[Description("兼容手机号字段;新客户端应使用 identifier。")]
public string? Phone { get; set; }
/// <summary>
/// 用户密码。
/// </summary>
[Required]
[StringLength(128, MinimumLength = 6)]
[StringLength(128, MinimumLength = 10)]
[Description("用户密码。")]
public string Password { get; set; } = string.Empty;
}
@@ -38,6 +44,8 @@ public sealed class PasswordLoginDto
/// </summary>
public sealed class SmsLoginDto
{
[Required]
public AuthRealm? Realm { get; set; }
/// <summary>
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略。
/// </summary>
@@ -62,11 +70,29 @@ public sealed class SmsLoginDto
public string Code { get; set; } = string.Empty;
}
public sealed class SendSmsCodeDto
{
[Required]
public AuthRealm? Realm { get; set; }
[StringLength(100)]
public string? TenantCode { get; set; }
[Required]
[StringLength(32)]
public string Phone { get; set; } = string.Empty;
[StringLength(256)]
public string? DeviceId { get; set; }
}
/// <summary>
/// OAuth code 登录请求。
/// </summary>
public sealed class OAuthCodeDto
{
[Required]
public AuthRealm? Realm { get; set; }
/// <summary>
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略。
/// </summary>
@@ -135,10 +161,12 @@ public sealed class AuthenticatedUserDto
/// </summary>
public string? Name { get; init; }
public AuthRealm Realm { get; init; }
/// <summary>
/// 当前登录租户成员摘要。
/// </summary>
public TenantMembershipSummary Tenant { get; init; } = default!;
public TenantMembershipSummary? Tenant { get; init; }
/// <summary>
/// access token 和 refresh token。
@@ -153,8 +181,52 @@ public sealed class AuthenticatedUserDto
Phone = user.Phone,
Email = user.Email,
Name = user.Name,
Realm = user.Realm,
Tenant = user.Tenant,
Tokens = user.Tokens
};
}
}
public sealed class AuthenticationResultDto
{
public AuthenticationStatus Status { get; init; }
public AuthenticatedUserDto? User { get; init; }
public string? ChallengeToken { get; init; }
public DateTimeOffset? ChallengeExpiresAt { get; init; }
public static AuthenticationResultDto FromApplication(AuthenticationResult result) => new()
{
Status = result.Status,
User = result.User is null ? null : AuthenticatedUserDto.FromApplication(result.User),
ChallengeToken = result.ChallengeToken,
ChallengeExpiresAt = result.ChallengeExpiresAt
};
}
public sealed class MfaChallengeDto
{
[Required]
[StringLength(2048)]
public string ChallengeToken { get; set; } = string.Empty;
[StringLength(64)]
public string? Code { get; set; }
}
public sealed class MfaConfirmDto
{
public AuthenticationResultDto Authentication { get; init; } = default!;
public IReadOnlyList<string> RecoveryCodes { get; init; } = [];
}
public sealed class RequiredPasswordChangeDto
{
[Required]
[StringLength(2048)]
public string ChallengeToken { get; set; } = string.Empty;
[Required]
[StringLength(128, MinimumLength = 10)]
public string NewPassword { get; set; } = string.Empty;
}

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 string? Category { get; set; }
@@ -324,13 +311,11 @@ public sealed class UpsertTenantAdminMemberDto
public TenantAdminUserLookupDto User { get; set; } = new();
public string? Role { get; set; }
public string? Status { get; set; }
public Guid? RoleTemplateId { get; set; }
public JsonElement Permissions { get; set; } = JsonDefaults.Object();
public string? PrimaryRole { get; set; }
public UpsertTenantAdminMemberCommand ToCommand()
{
return new UpsertTenantAdminMemberCommand(MembershipId, User.ToCommand(), Role, Status, RoleTemplateId, Permissions, PrimaryRole);
return new UpsertTenantAdminMemberCommand(MembershipId, User.ToCommand(), Role, Status, PrimaryRole);
}
}
@@ -340,45 +325,6 @@ public sealed class DisableTenantAdminMemberDto
public Guid MembershipId { get; set; }
}
public sealed class UpsertTenantAdminRoleTemplateDto
{
public Guid? Id { get; set; }
public string? Code { get; set; }
public required string Name { get; set; }
public string? Description { get; set; }
public string? BaseRole { get; set; }
public string? Status { get; set; }
public JsonElement Permissions { get; set; } = JsonDefaults.Object();
public JsonElement MenuPermissions { get; set; } = JsonDefaults.Object();
public JsonElement ModulePermissions { get; set; } = JsonDefaults.Object();
public JsonElement FieldPermissions { get; set; } = JsonDefaults.Object();
public JsonElement DataScope { get; set; } = JsonDefaults.Object();
public int? Order { get; set; }
public UpsertTenantAdminRoleTemplateCommand ToCommand()
{
return new UpsertTenantAdminRoleTemplateCommand(
Id,
Code,
Name,
Description,
BaseRole,
Status,
Permissions,
MenuPermissions,
ModulePermissions,
FieldPermissions,
DataScope,
Order);
}
}
public sealed class DisableTenantAdminRoleTemplateDto
{
[Required]
public Guid RoleTemplateId { get; set; }
}
public sealed class UpsertTenantBrandingDto
{
public required string BrandName { get; set; }

View File

@@ -1,10 +1,14 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
using Tiku.Application.Auth;
using Tiku.Api.Contracts;
using Tiku.Api.Options;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Infrastructure.Content;
using Tiku.Domain.Tenancy;
namespace Tiku.Api.Controllers;
@@ -13,97 +17,144 @@ namespace Tiku.Api.Controllers;
[Produces("application/json")]
public sealed class AuthController(
IAuthService authService,
ISessionService sessionService,
ISmsVerificationService smsVerificationService,
IAuthSessionStore sessionStore,
ITenantContext tenantContext,
ITenantContextInitializer tenantContextInitializer,
ITenantDirectory tenantDirectory) : ControllerBase
ITenantDirectory tenantDirectory,
ICurrentUser currentUser,
IOptions<TenantResolutionOptions> tenantResolutionOptions) : ControllerBase
{
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
[HttpPost("sms/send")]
[ProducesResponseType<SmsSendResult>(StatusCodes.Status202Accepted)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status429TooManyRequests)]
public async Task<ActionResult<SmsSendResult>> SendSmsCode(
[FromBody] SendSmsCodeDto request,
CancellationToken cancellationToken)
{
var realm = request.Realm!.Value;
if (realm != AuthRealm.Tenant)
{
throw new RequiredFieldException("SMS authentication is only available in the tenant realm.");
}
var tenantId = await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken)
?? throw new RequiredFieldException("tenantCode is required for SMS authentication.");
var result = await smsVerificationService.CreateCodeAsync(
new SendSmsCodeRequest(
tenantId,
request.Phone,
SmsPurpose.Login,
GetIpAddress(),
Request.Headers.UserAgent.ToString(),
request.DeviceId),
cancellationToken);
return Accepted(result);
}
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
[HttpPost("login/password")]
[EndpointSummary("手机号密码登录")]
[EndpointDescription("使用本地手机号和密码登录,签发 JWT access token 与数据库 refresh/session。")]
[ProducesResponseType<AuthenticatedUserDto>(StatusCodes.Status200OK)]
[ProducesResponseType<AuthenticationResultDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<AuthenticatedUserDto>> LoginWithPassword(
public async Task<ActionResult<AuthenticationResultDto>> LoginWithPassword(
[FromBody] PasswordLoginDto request,
CancellationToken cancellationToken)
{
var realm = request.Realm!.Value;
var identifier = request.Identifier ?? request.Phone;
if (string.IsNullOrWhiteSpace(identifier))
{
throw new RequiredFieldException("identifier is required.");
}
var result = await authService.LoginWithPasswordAsync(
new PasswordLoginRequest(
await ResolveTenantIdAsync(request.TenantCode, cancellationToken),
request.Phone,
realm,
await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
identifier,
request.Password,
GetIpAddress(),
Request.Headers.UserAgent.ToString()),
cancellationToken);
return Ok(AuthenticatedUserDto.FromApplication(result));
return Ok(AuthenticationResultDto.FromApplication(result));
}
[AllowAnonymous]
[HttpPost("login/sms")]
[EndpointSummary("短信验证码登录")]
[EndpointDescription("校验已发送的登录用途短信验证码,成功后签发 JWT access token 与数据库 refresh/session。")]
[ProducesResponseType<AuthenticatedUserDto>(StatusCodes.Status200OK)]
[ProducesResponseType<AuthenticationResultDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<AuthenticatedUserDto>> LoginWithSms(
public async Task<ActionResult<AuthenticationResultDto>> LoginWithSms(
[FromBody] SmsLoginDto request,
CancellationToken cancellationToken)
{
var realm = request.Realm!.Value;
var result = await authService.LoginWithSmsAsync(
new SmsLoginRequest(
await ResolveTenantIdAsync(request.TenantCode, cancellationToken),
realm,
await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
request.Phone,
request.Code,
GetIpAddress(),
Request.Headers.UserAgent.ToString()),
cancellationToken);
return Ok(AuthenticatedUserDto.FromApplication(result));
return Ok(AuthenticationResultDto.FromApplication(result));
}
[AllowAnonymous]
[HttpPost("oauth/wechat")]
[EndpointSummary("微信网页 OAuth 登录")]
[EndpointDescription("使用微信网页授权 code 换取 openid/unionidupsert 用户身份并创建应用会话。")]
[ProducesResponseType<AuthenticatedUserDto>(StatusCodes.Status200OK)]
[ProducesResponseType<AuthenticationResultDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status503ServiceUnavailable)]
public async Task<ActionResult<AuthenticatedUserDto>> LoginWithWechatWeb(
public async Task<ActionResult<AuthenticationResultDto>> LoginWithWechatWeb(
[FromBody] OAuthCodeDto request,
CancellationToken cancellationToken)
{
var realm = request.Realm!.Value;
var result = await authService.LoginWithWechatWebAsync(
new WechatLoginRequest(
await ResolveTenantIdAsync(request.TenantCode, cancellationToken),
realm,
await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
request.Code,
GetIpAddress(),
Request.Headers.UserAgent.ToString()),
cancellationToken);
return Ok(AuthenticatedUserDto.FromApplication(result));
return Ok(AuthenticationResultDto.FromApplication(result));
}
[AllowAnonymous]
[HttpPost("oauth/wechat-miniapp")]
[EndpointSummary("微信小程序登录")]
[EndpointDescription("使用小程序 wx.login 返回的 code 换取 openid/session_keyupsert 用户身份并创建应用会话。")]
[ProducesResponseType<AuthenticatedUserDto>(StatusCodes.Status200OK)]
[ProducesResponseType<AuthenticationResultDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status503ServiceUnavailable)]
public async Task<ActionResult<AuthenticatedUserDto>> LoginWithWechatMiniApp(
public async Task<ActionResult<AuthenticationResultDto>> LoginWithWechatMiniApp(
[FromBody] OAuthCodeDto request,
CancellationToken cancellationToken)
{
var realm = request.Realm!.Value;
var result = await authService.LoginWithWechatMiniAppAsync(
new WechatLoginRequest(
await ResolveTenantIdAsync(request.TenantCode, cancellationToken),
realm,
await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
request.Code,
GetIpAddress(),
Request.Headers.UserAgent.ToString()),
cancellationToken);
return Ok(AuthenticatedUserDto.FromApplication(result));
return Ok(AuthenticationResultDto.FromApplication(result));
}
[AllowAnonymous]
@@ -142,14 +193,135 @@ public sealed class AuthController(
return NoContent();
}
[HttpPost("logout-all")]
[Authorize]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> LogoutAll(CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
{
return Unauthorized();
}
await authService.LogoutAllAsync(userId, cancellationToken);
return NoContent();
}
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Mfa)]
[HttpPost("mfa/totp/setup")]
public async Task<ActionResult<MfaSetupResult>> SetupTotp(
[FromBody] MfaChallengeDto request,
CancellationToken cancellationToken)
{
ResolveAuthChallengeTenant(request.ChallengeToken);
var result = await authService.SetupTotpAsync(
new MfaChallengeRequest(
request.ChallengeToken, null, GetIpAddress(), Request.Headers.UserAgent.ToString()),
cancellationToken);
return Ok(result);
}
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Mfa)]
[HttpPost("mfa/totp/confirm")]
public async Task<ActionResult<MfaConfirmDto>> ConfirmTotp(
[FromBody] MfaChallengeDto request,
CancellationToken cancellationToken)
{
ResolveAuthChallengeTenant(request.ChallengeToken);
var result = await authService.ConfirmTotpAsync(
new MfaChallengeRequest(
request.ChallengeToken, request.Code, GetIpAddress(), Request.Headers.UserAgent.ToString()),
cancellationToken);
return Ok(new MfaConfirmDto
{
Authentication = AuthenticationResultDto.FromApplication(result.Authentication),
RecoveryCodes = result.RecoveryCodes
});
}
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Mfa)]
[HttpPost("mfa/totp/verify")]
public async Task<ActionResult<AuthenticationResultDto>> VerifyTotp(
[FromBody] MfaChallengeDto request,
CancellationToken cancellationToken)
{
ResolveAuthChallengeTenant(request.ChallengeToken);
var result = await authService.VerifyTotpAsync(
new MfaChallengeRequest(
request.ChallengeToken, request.Code, GetIpAddress(), Request.Headers.UserAgent.ToString()),
cancellationToken);
return Ok(AuthenticationResultDto.FromApplication(result));
}
[AllowAnonymous]
[HttpPost("password/change-required")]
[EnableRateLimiting(AuthRateLimitPolicies.Mfa)]
public async Task<ActionResult<AuthenticationResultDto>> ChangeRequiredPassword(
[FromBody] RequiredPasswordChangeDto request,
CancellationToken cancellationToken)
{
ResolveAuthChallengeTenant(request.ChallengeToken);
var result = await authService.ChangeRequiredPasswordAsync(
new PasswordChangeChallengeRequest(
request.ChallengeToken, request.NewPassword, GetIpAddress(), Request.Headers.UserAgent.ToString()),
cancellationToken);
return Ok(AuthenticationResultDto.FromApplication(result));
}
private void ResolveRefreshTokenTenant(string refreshToken)
{
if (!sessionService.TryParseRefreshToken(refreshToken, out var locator))
if (!sessionStore.TryParseRefreshToken(refreshToken, out var locator))
{
return;
}
tenantContextInitializer.Initialize(locator.TenantId, null, TenantResolutionSource.RefreshToken);
if (locator.Realm == AuthRealm.Platform)
{
EnsurePlatformHost();
if (tenantContext.IsResolved)
{
throw new TenantContextConflictException(tenantContext.TenantId!.Value, Guid.Empty);
}
return;
}
if (!tenantContext.IsResolved)
{
throw new RequiredFieldException(
"tenant refresh/logout requires a tenant host or x-tenant-code matching the refresh token.");
}
tenantContextInitializer.Initialize(locator.TenantId!.Value, null, TenantResolutionSource.RefreshToken);
}
private void ResolveAuthChallengeTenant(string challengeToken)
{
var parts = challengeToken.Split('.', 4, StringSplitOptions.None);
if (parts.Length != 4 || parts[0] != "c1")
{
return;
}
if (parts[1] == "p" && parts[2] == "-")
{
EnsurePlatformHost();
if (tenantContext.IsResolved)
{
throw new TenantContextConflictException(tenantContext.TenantId!.Value, Guid.Empty);
}
return;
}
if (parts[1] != "t" || !Guid.TryParseExact(parts[2], "N", out var tenantId) || !tenantContext.IsResolved)
{
throw new RequiredFieldException(
"tenant authentication challenge requires a tenant host or x-tenant-code.");
}
tenantContextInitializer.Initialize(tenantId, null, TenantResolutionSource.RefreshToken);
}
private string? GetIpAddress()
@@ -157,8 +329,22 @@ public sealed class AuthController(
return HttpContext.Connection.RemoteIpAddress?.ToString();
}
private async Task<Guid> ResolveTenantIdAsync(string? tenantCode, CancellationToken cancellationToken)
private async Task<Guid?> ResolveRealmTenantIdAsync(
AuthRealm realm,
string? tenantCode,
CancellationToken cancellationToken)
{
if (realm == AuthRealm.Platform)
{
EnsurePlatformHost();
if (tenantContext.IsResolved || !string.IsNullOrWhiteSpace(tenantCode))
{
throw new RequiredFieldException("platform realm does not accept tenantCode and must use a platform host.");
}
return null;
}
if (tenantContext.TenantId.HasValue)
{
if (!string.IsNullOrWhiteSpace(tenantCode) &&
@@ -189,4 +375,17 @@ public sealed class AuthController(
TenantResolutionSource.TenantCode);
return tenant.TenantId;
}
private void EnsurePlatformHost()
{
var requestHost = Request.Host.Host.Trim().TrimEnd('.');
if (!tenantResolutionOptions.Value.PlatformHosts.Any(host =>
string.Equals(
host.Trim().TrimEnd('.'),
requestHost,
StringComparison.OrdinalIgnoreCase)))
{
throw new RequiredFieldException("platform realm is only available on a configured platform host.");
}
}
}

View File

@@ -8,7 +8,7 @@ namespace Tiku.Api.Controllers;
[ApiController]
[Route("api/backoffice/tenant/jobs")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantJobManage)]
public sealed class BackgroundJobsController(
IBackgroundJobService backgroundJobService,
ITenantContext tenantContext) : ControllerBase

View File

@@ -10,113 +10,117 @@ namespace Tiku.Api.Controllers;
[Route("api/backoffice")]
public sealed class BackofficeController(
IBackofficeService backofficeService,
ICurrentUser currentUser,
ITenantContext tenantContext) : ControllerBase
ICurrentAccessContext currentAccessContext) : ControllerBase
{
[HttpGet("tenant/ui-bootstrap")]
[Authorize(Policy = TikuPolicies.TenantBackofficeBootstrap)]
[ProducesResponseType<BackofficeUiBootstrap>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeUiBootstrap>> GetTenantUiBootstrap(CancellationToken cancellationToken)
{
return Ok(await backofficeService.GetTenantUiBootstrapAsync(
await currentAccessContext.GetAsync(cancellationToken),
cancellationToken));
}
[HttpGet("tenant/bootstrap")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
[ProducesResponseType<BackofficeBootstrap>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeBootstrap>> GetTenantBootstrap(CancellationToken cancellationToken)
{
return Ok(await backofficeService.GetTenantBootstrapAsync(ResolveTenantActor(), cancellationToken));
return Ok(await backofficeService.GetTenantBootstrapAsync(await ResolveTenantActorAsync(cancellationToken), cancellationToken));
}
[HttpGet("platform/ui-bootstrap")]
[Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)]
[ProducesResponseType<BackofficeUiBootstrap>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeUiBootstrap>> GetPlatformUiBootstrap(CancellationToken cancellationToken)
{
return Ok(await backofficeService.GetPlatformUiBootstrapAsync(
await currentAccessContext.GetAsync(cancellationToken),
cancellationToken));
}
[HttpPost("tenant/roles")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeRoleItem>> UpsertTenantRole(
UpsertBackofficeRoleDto request,
CancellationToken cancellationToken)
{
return Ok(await backofficeService.UpsertTenantRoleAsync(ResolveTenantActor(), request.ToCommand(), cancellationToken));
return Ok(await backofficeService.UpsertTenantRoleAsync(await ResolveTenantActorAsync(cancellationToken), request.ToCommand(), cancellationToken));
}
[HttpPut("tenant/roles/{roleId:guid}/bindings")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeRoleItem>> ReplaceTenantRoleBindings(
Guid roleId,
ReplaceRoleBindingsDto request,
CancellationToken cancellationToken)
{
return Ok(await backofficeService.ReplaceTenantRoleBindingsAsync(ResolveTenantActor(), request.ToCommand(roleId), cancellationToken));
return Ok(await backofficeService.ReplaceTenantRoleBindingsAsync(await ResolveTenantActorAsync(cancellationToken), request.ToCommand(roleId), cancellationToken));
}
[HttpPut("tenant/users/{userId:guid}/roles")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> ReplaceTenantUserRoles(
Guid userId,
ReplaceUserRolesDto request,
CancellationToken cancellationToken)
{
await backofficeService.ReplaceTenantUserRolesAsync(ResolveTenantActor(), request.ToCommand(userId), cancellationToken);
await backofficeService.ReplaceTenantUserRolesAsync(await ResolveTenantActorAsync(cancellationToken), request.ToCommand(userId), cancellationToken);
return NoContent();
}
[HttpGet("platform/bootstrap")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
[ProducesResponseType<BackofficeBootstrap>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeBootstrap>> GetPlatformBootstrap(CancellationToken cancellationToken)
{
return Ok(await backofficeService.GetPlatformBootstrapAsync(ResolvePlatformActor(), cancellationToken));
return Ok(await backofficeService.GetPlatformBootstrapAsync(await ResolvePlatformActorAsync(cancellationToken), cancellationToken));
}
[HttpPost("platform/roles")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeRoleItem>> UpsertPlatformRole(
UpsertBackofficeRoleDto request,
CancellationToken cancellationToken)
{
return Ok(await backofficeService.UpsertPlatformRoleAsync(ResolvePlatformActor(), request.ToCommand(), cancellationToken));
return Ok(await backofficeService.UpsertPlatformRoleAsync(await ResolvePlatformActorAsync(cancellationToken), request.ToCommand(), cancellationToken));
}
[HttpPut("platform/roles/{roleId:guid}/bindings")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<BackofficeRoleItem>> ReplacePlatformRoleBindings(
Guid roleId,
ReplaceRoleBindingsDto request,
CancellationToken cancellationToken)
{
return Ok(await backofficeService.ReplacePlatformRoleBindingsAsync(ResolvePlatformActor(), request.ToCommand(roleId), cancellationToken));
return Ok(await backofficeService.ReplacePlatformRoleBindingsAsync(await ResolvePlatformActorAsync(cancellationToken), request.ToCommand(roleId), cancellationToken));
}
[HttpPut("platform/users/{userId:guid}/roles")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> ReplacePlatformUserRoles(
Guid userId,
ReplaceUserRolesDto request,
CancellationToken cancellationToken)
{
await backofficeService.ReplacePlatformUserRolesAsync(ResolvePlatformActor(), request.ToCommand(userId), cancellationToken);
await backofficeService.ReplacePlatformUserRolesAsync(await ResolvePlatformActorAsync(cancellationToken), request.ToCommand(userId), cancellationToken);
return NoContent();
}
private BackofficeActor ResolveTenantActor()
private async Task<BackofficeActor> ResolveTenantActorAsync(CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId || tenantContext.TenantId is not { } tenantId)
{
throw new InvalidOperationException("Tenant backoffice actor was not resolved.");
}
return new BackofficeActor(userId, tenantId, IsPlatformAdmin());
return BackofficeActor.FromTenantAccess(await currentAccessContext.GetAsync(cancellationToken));
}
private BackofficeActor ResolvePlatformActor()
private async Task<BackofficeActor> ResolvePlatformActorAsync(CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
{
throw new InvalidOperationException("Platform backoffice actor was not resolved.");
}
return new BackofficeActor(userId, tenantContext.TenantId, IsPlatformAdmin());
}
private bool IsPlatformAdmin()
{
return string.Equals(currentUser.TenantRole, "PlatformAdmin", StringComparison.OrdinalIgnoreCase);
return BackofficeActor.FromPlatformAccess(await currentAccessContext.GetAsync(cancellationToken));
}
}

View File

@@ -7,7 +7,7 @@ using Tiku.Application.Security;
namespace Tiku.Api.Controllers;
[ApiController]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantCommissionManage)]
[Produces("application/json")]
[Route("api/commission")]
public sealed class CommissionController(

View File

@@ -7,7 +7,7 @@ using Tiku.Application.Security;
namespace Tiku.Api.Controllers;
[ApiController]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
[Produces("application/json")]
[Route("api/crm")]
public sealed class CrmController(

View File

@@ -95,7 +95,7 @@ public sealed class ReferralController(
}
[HttpGet("stats")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
[EndpointSummary("查询推荐人个人统计")]
[ProducesResponseType<ReferralStatsItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<ReferralStatsItem>> Stats(
@@ -109,7 +109,7 @@ public sealed class ReferralController(
}
[HttpGet("sales-stats")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
[EndpointSummary("查询销售推荐统计排行")]
[ProducesResponseType<ReferralList<ReferralStatsItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ReferralList<ReferralStatsItem>>> SalesStats(
@@ -123,7 +123,7 @@ public sealed class ReferralController(
}
[HttpGet("conversion-report")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
[EndpointSummary("查询推荐转化报告")]
[ProducesResponseType<ReferralConversionReport>(StatusCodes.Status200OK)]
public async Task<ActionResult<ReferralConversionReport>> ConversionReport(
@@ -137,7 +137,7 @@ public sealed class ReferralController(
}
[HttpGet("sales-clients")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
[EndpointSummary("查询推荐人名下客户")]
[ProducesResponseType<ReferralList<ReferralLeadItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ReferralList<ReferralLeadItem>>> SalesClients(
@@ -151,7 +151,7 @@ public sealed class ReferralController(
}
[HttpPost("manual-bind")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
[EndpointSummary("人工调整学生推荐归属")]
[ProducesResponseType<ReferralBindResult>(StatusCodes.Status200OK)]
public async Task<ActionResult<ReferralBindResult>> ManualBind(
@@ -165,7 +165,7 @@ public sealed class ReferralController(
}
[HttpGet("team")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
[EndpointSummary("查询推荐团队成员")]
[ProducesResponseType<ReferralList<ReferralTeamItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ReferralList<ReferralTeamItem>>> Team(
@@ -179,7 +179,7 @@ public sealed class ReferralController(
}
[HttpPut("team")]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
[EndpointSummary("新增或更新推荐团队关系")]
[ProducesResponseType<ReferralTeamItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<ReferralTeamItem>> UpsertTeam(

View File

@@ -28,8 +28,7 @@ public sealed class SecurityDiagnosticsController(
{
return Ok(new
{
currentTenant.TenantId,
currentUser.TenantRole
currentTenant.TenantId
});
}
@@ -39,8 +38,7 @@ public sealed class SecurityDiagnosticsController(
{
return Ok(new
{
currentTenant.TenantId,
currentUser.TenantRole
currentTenant.TenantId
});
}
}

View File

@@ -21,7 +21,7 @@ public sealed class TaxonomyController(
}
[HttpPost]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantContentManage)]
public Task<TaxonomyNodeItem> Create(
CreateTaxonomyNodeDto request,
CancellationToken cancellationToken)

View File

@@ -10,7 +10,6 @@ using Tiku.Domain.Tenancy;
namespace Tiku.Api.Controllers;
[ApiController]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Produces("application/json")]
[Route("api/tenant-admin")]
public sealed class TenantAdminDirectController(
@@ -19,6 +18,7 @@ public sealed class TenantAdminDirectController(
ITenantContext currentTenant) : ControllerBase
{
[HttpGet("classes")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("查询租户班级")]
[ProducesResponseType<TenantAdminClassList>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantAdminClassList>> GetClasses(
@@ -29,6 +29,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("classes")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("新增或更新租户班级")]
[ProducesResponseType<ContentManagementResult<TenantAdminClassItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminClassItem>>> UpsertClass(
@@ -39,6 +40,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPost("classes/disable")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("停用租户班级")]
[ProducesResponseType<ContentManagementResult<TenantAdminClassItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminClassItem>>> DisableClass(
@@ -49,6 +51,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("classes/members")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("查询班级成员")]
[ProducesResponseType<CatalogList<TenantAdminClassMemberItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminClassMemberItem>>> GetClassMembers(
@@ -59,6 +62,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("classes/members")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("新增或更新班级成员")]
[ProducesResponseType<ContentManagementResult<TenantAdminClassMemberItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminClassMemberItem>>> UpsertClassMember(
@@ -69,6 +73,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPost("classes/members/remove")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("移除班级成员")]
[ProducesResponseType<ContentManagementResult<TenantAdminClassMemberItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminClassMemberItem>>> RemoveClassMember(
@@ -79,6 +84,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("students")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("查询租户学生")]
[ProducesResponseType<TenantAdminStudentList>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantAdminStudentList>> GetStudents(
@@ -89,6 +95,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("students")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("新增或更新租户学生档案")]
[ProducesResponseType<ContentManagementResult<TenantAdminStudentItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminStudentItem>>> UpsertStudent(
@@ -99,6 +106,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPost("students/status")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("更新租户学生状态")]
[ProducesResponseType<ContentManagementResult<TenantAdminStudentStatusItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminStudentStatusItem>>> UpdateStudentStatus(
@@ -109,6 +117,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("student-notes")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("查询学生备注")]
[ProducesResponseType<CatalogList<TenantAdminStudentNoteItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminStudentNoteItem>>> GetStudentNotes(
@@ -119,6 +128,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("student-notes")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("新增或更新学生备注")]
[ProducesResponseType<ContentManagementResult<TenantAdminStudentNoteItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminStudentNoteItem>>> UpsertStudentNote(
@@ -129,6 +139,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("student-followups")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("查询学生跟进")]
[ProducesResponseType<CatalogList<TenantAdminStudentFollowupItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminStudentFollowupItem>>> GetStudentFollowups(
@@ -139,6 +150,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("student-followups")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("新增或更新学生跟进")]
[ProducesResponseType<ContentManagementResult<TenantAdminStudentFollowupItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminStudentFollowupItem>>> UpsertStudentFollowup(
@@ -149,6 +161,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("members")]
[Authorize(Policy = BackendPermissions.TenantStaffManage)]
[EndpointSummary("查询租户成员")]
[ProducesResponseType<CatalogList<TenantAdminMemberItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminMemberItem>>> GetMembers(
@@ -159,6 +172,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("members")]
[Authorize(Policy = BackendPermissions.TenantStaffManage)]
[EndpointSummary("新增或更新租户成员")]
[ProducesResponseType<ContentManagementResult<TenantAdminMemberItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminMemberItem>>> UpsertMember(
@@ -169,6 +183,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPost("members/disable")]
[Authorize(Policy = BackendPermissions.TenantStaffManage)]
[EndpointSummary("停用租户成员并撤销会话")]
[ProducesResponseType<ContentManagementResult<TenantAdminMemberItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminMemberItem>>> DisableMember(
@@ -179,6 +194,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("audit-logs")]
[Authorize(Policy = BackendPermissions.TenantStaffManage)]
[EndpointSummary("查询租户审计日志")]
[ProducesResponseType<CatalogList<TenantAdminAuditLogItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminAuditLogItem>>> GetAuditLogs(
@@ -188,45 +204,8 @@ public sealed class TenantAdminDirectController(
return Ok(await tenantAdminService.GetAuditLogsAsync(ResolveActor(), query.ToFilter(), cancellationToken));
}
[HttpGet("permissions")]
[EndpointSummary("查询租户后台权限矩阵")]
[ProducesResponseType<TenantAdminPermissionMatrix>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantAdminPermissionMatrix>> GetPermissions(CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.GetPermissionMatrixAsync(ResolveActor(), cancellationToken));
}
[HttpGet("role-templates")]
[EndpointSummary("查询租户角色模板")]
[ProducesResponseType<CatalogList<TenantAdminRoleTemplateItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminRoleTemplateItem>>> GetRoleTemplates(
[FromQuery] TenantAdminRoleTemplateQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.GetRoleTemplatesAsync(ResolveActor(), query.ToFilter(), cancellationToken));
}
[HttpPut("role-templates")]
[EndpointSummary("新增或更新租户角色模板")]
[ProducesResponseType<ContentManagementResult<TenantAdminRoleTemplateItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminRoleTemplateItem>>> UpsertRoleTemplate(
UpsertTenantAdminRoleTemplateDto request,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.UpsertRoleTemplateAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
[HttpPost("role-templates/disable")]
[EndpointSummary("停用租户角色模板")]
[ProducesResponseType<ContentManagementResult<TenantAdminRoleTemplateItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminRoleTemplateItem>>> DisableRoleTemplate(
DisableTenantAdminRoleTemplateDto request,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.DisableRoleTemplateAsync(ResolveActor(), request.RoleTemplateId, cancellationToken));
}
[HttpPut("branding")]
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
[EndpointSummary("更新租户品牌信息")]
[ProducesResponseType<ContentManagementResult<TenantBrandingItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantBrandingItem>>> UpsertBranding(
@@ -237,6 +216,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("settings")]
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
[EndpointSummary("更新租户公开设置与功能开关")]
[ProducesResponseType<ContentManagementResult<TenantSettingsItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantSettingsItem>>> UpsertSettings(
@@ -247,6 +227,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("theme-templates")]
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
[EndpointSummary("查询可用租户主题模板")]
[ProducesResponseType<CatalogList<TenantThemeTemplateItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantThemeTemplateItem>>> GetThemeTemplates(CancellationToken cancellationToken)
@@ -255,6 +236,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("theme")]
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
[EndpointSummary("查询租户当前主题与草稿")]
[ProducesResponseType<ContentManagementResult<TenantThemeItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantThemeItem>>> GetTheme(CancellationToken cancellationToken)
@@ -263,6 +245,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPost("theme/preview")]
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
[EndpointSummary("生成租户主题草稿")]
[ProducesResponseType<ContentManagementResult<TenantThemeItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantThemeItem>>> PreviewTheme(
@@ -273,6 +256,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPost("theme/publish")]
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
[EndpointSummary("发布租户主题")]
[ProducesResponseType<ContentManagementResult<TenantThemeItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantThemeItem>>> PublishTheme(
@@ -283,6 +267,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("domains")]
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
[EndpointSummary("查询租户域名")]
[ProducesResponseType<CatalogList<TenantDomainItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantDomainItem>>> GetDomains(CancellationToken cancellationToken)
@@ -291,6 +276,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPost("domains")]
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
[EndpointSummary("添加租户域名")]
[ProducesResponseType<ContentManagementResult<TenantDomainItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantDomainItem>>> CreateDomain(
@@ -301,6 +287,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("auth-providers")]
[Authorize(Policy = BackendPermissions.TenantProviderManage)]
[EndpointSummary("查询租户登录 Provider 公开配置")]
[ProducesResponseType<CatalogList<TenantIdentityProviderItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantIdentityProviderItem>>> GetAuthProviders(CancellationToken cancellationToken)
@@ -309,6 +296,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("auth-providers")]
[Authorize(Policy = BackendPermissions.TenantProviderManage)]
[EndpointSummary("新增或更新租户登录 Provider")]
[ProducesResponseType<ContentManagementResult<TenantIdentityProviderItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantIdentityProviderItem>>> UpsertAuthProvider(
@@ -319,6 +307,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("badges")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("查询租户勋章")]
[ProducesResponseType<CatalogList<TenantAdminBadgeItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminBadgeItem>>> GetBadges(
@@ -329,6 +318,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("badges")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("新增或更新租户勋章")]
[ProducesResponseType<ContentManagementResult<TenantAdminBadgeItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminBadgeItem>>> UpsertBadge(
@@ -339,6 +329,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("badge-grants")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("查询勋章发放记录")]
[ProducesResponseType<CatalogList<TenantAdminBadgeGrantItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminBadgeGrantItem>>> GetBadgeGrants(
@@ -349,6 +340,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPost("badge-grants")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("向租户成员发放勋章")]
[ProducesResponseType<ContentManagementResult<TenantAdminBadgeGrantItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminBadgeGrantItem>>> GrantBadge(
@@ -359,6 +351,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("notifications")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("查询用户站内通知")]
[ProducesResponseType<CatalogList<TenantAdminNotificationItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminNotificationItem>>> GetNotifications(
@@ -369,6 +362,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPut("notifications")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("新增或更新用户站内通知")]
[ProducesResponseType<ContentManagementResult<TenantAdminNotificationItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminNotificationItem>>> UpsertNotification(
@@ -379,6 +373,7 @@ public sealed class TenantAdminDirectController(
}
[HttpGet("feedbacks")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("查询用户反馈")]
[ProducesResponseType<CatalogList<TenantAdminFeedbackItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminFeedbackItem>>> GetFeedbacks(
@@ -389,6 +384,7 @@ public sealed class TenantAdminDirectController(
}
[HttpPost("feedbacks/status")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[EndpointSummary("处理用户反馈")]
[ProducesResponseType<ContentManagementResult<TenantAdminFeedbackItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminFeedbackItem>>> UpdateFeedback(
@@ -400,17 +396,13 @@ public sealed class TenantAdminDirectController(
private TenantAdminActor ResolveActor()
{
if (currentTenant.TenantId is null || currentUser.UserId is null)
try
{
throw new TenantAdminDirectException("Tenant admin actor was not resolved.", "tenant_admin_access_denied");
return TenantAdminActor.FromResolvedIdentity(currentTenant.TenantId, currentUser.UserId);
}
catch (InvalidOperationException exception)
{
throw new TenantAdminDirectException(exception.Message, "tenant_admin_access_denied");
}
var role = Enum.TryParse<TenantRole>(
currentUser.TenantRole?.Replace("_", string.Empty, StringComparison.Ordinal),
ignoreCase: true,
out var parsedRole)
? parsedRole
: TenantRole.TenantAdmin;
return new TenantAdminActor(currentTenant.TenantId.Value, currentUser.UserId.Value, role);
}
}

View File

@@ -8,7 +8,7 @@ using Tiku.Domain.Commerce;
namespace Tiku.Api.Controllers;
[ApiController]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantCommerceOperate)]
[Produces("application/json")]
[Route("api/tenant-commerce")]
public sealed class TenantCommerceController(
@@ -17,6 +17,7 @@ public sealed class TenantCommerceController(
ITenantContext currentTenant) : ControllerBase
{
[HttpGet("payment-accounts")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("查询租户支付账号")]
[ProducesResponseType<IReadOnlyCollection<TenantPaymentProviderItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyCollection<TenantPaymentProviderItem>>> PaymentAccounts(
@@ -30,6 +31,7 @@ public sealed class TenantCommerceController(
}
[HttpPut("payment-accounts")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("新增或更新租户支付账号")]
[ProducesResponseType<TenantPaymentProviderItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantPaymentProviderItem>> UpsertPaymentAccount(
@@ -43,6 +45,7 @@ public sealed class TenantCommerceController(
}
[HttpPut("secrets")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("写入或轮换租户密钥")]
[ProducesResponseType<TenantSecretItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantSecretItem>> UpsertSecret(
@@ -82,6 +85,7 @@ public sealed class TenantCommerceController(
}
[HttpPost("code-batches")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("创建兑换码批次")]
[ProducesResponseType<CodeBatchItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<CodeBatchItem>> CreateCodeBatch(
@@ -95,6 +99,7 @@ public sealed class TenantCommerceController(
}
[HttpGet("activation-codes")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("查询兑换码")]
[ProducesResponseType<ActivationCodeList>(StatusCodes.Status200OK)]
public async Task<ActionResult<ActivationCodeList>> ActivationCodes(
@@ -108,6 +113,7 @@ public sealed class TenantCommerceController(
}
[HttpPost("activation-codes/redeem")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("后台核销兑换码")]
[ProducesResponseType<ActivationCodeItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<ActivationCodeItem>> RedeemActivationCode(
@@ -121,6 +127,7 @@ public sealed class TenantCommerceController(
}
[HttpGet("point-activity-tasks")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("查询积分活动任务")]
[ProducesResponseType<TenantPointTaskList>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantPointTaskList>> PointTasks(
@@ -134,6 +141,7 @@ public sealed class TenantCommerceController(
}
[HttpPut("point-activity-tasks")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("新增或更新积分活动任务")]
[ProducesResponseType<object>(StatusCodes.Status200OK)]
public async Task<ActionResult<object>> UpsertPointTask(
@@ -147,6 +155,7 @@ public sealed class TenantCommerceController(
}
[HttpGet("point-activity-claims")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("查询积分任务领取记录")]
[ProducesResponseType<TenantPointClaimList>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantPointClaimList>> PointClaims(
@@ -160,6 +169,7 @@ public sealed class TenantCommerceController(
}
[HttpGet("point-exchange-items")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("查询积分兑换项")]
[ProducesResponseType<TenantPointExchangeItemList>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantPointExchangeItemList>> PointExchangeItems(
@@ -173,6 +183,7 @@ public sealed class TenantCommerceController(
}
[HttpPut("point-exchange-items")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("新增或更新积分兑换项")]
[ProducesResponseType<object>(StatusCodes.Status200OK)]
public async Task<ActionResult<object>> UpsertPointExchangeItem(
@@ -186,6 +197,7 @@ public sealed class TenantCommerceController(
}
[HttpGet("point-exchange-orders")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("查询积分兑换订单")]
[ProducesResponseType<TenantPointExchangeOrderList>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantPointExchangeOrderList>> PointExchangeOrders(
@@ -199,6 +211,7 @@ public sealed class TenantCommerceController(
}
[HttpPost("point-exchange-orders/status")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("更新积分兑换订单状态")]
[ProducesResponseType<object>(StatusCodes.Status200OK)]
public async Task<ActionResult<object>> UpdatePointExchangeOrderStatus(
@@ -212,6 +225,7 @@ public sealed class TenantCommerceController(
}
[HttpGet("coupons")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("查询租户优惠券")]
[ProducesResponseType<TenantCouponList>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantCouponList>> Coupons(
@@ -225,6 +239,7 @@ public sealed class TenantCommerceController(
}
[HttpPut("coupons")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("新增或更新租户优惠券")]
[ProducesResponseType<object>(StatusCodes.Status200OK)]
public async Task<ActionResult<object>> UpsertCoupon(
@@ -238,6 +253,7 @@ public sealed class TenantCommerceController(
}
[HttpGet("coupons/redemptions")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("查询优惠券领取和核销记录")]
[ProducesResponseType<TenantCouponRedemptionList>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantCouponRedemptionList>> CouponRedemptions(
@@ -251,6 +267,7 @@ public sealed class TenantCommerceController(
}
[HttpGet("coupons/report")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("查询优惠券基础报表")]
[ProducesResponseType<TenantCouponReport>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantCouponReport>> CouponReport(
@@ -316,6 +333,7 @@ public sealed class TenantCommerceController(
}
[HttpGet("reconciliation/batches")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("查询对账批次")]
[ProducesResponseType<TenantReconciliationBatchList>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantReconciliationBatchList>> ReconciliationBatches(
@@ -329,6 +347,7 @@ public sealed class TenantCommerceController(
}
[HttpPost("reconciliation/batches")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("创建对账批次")]
[ProducesResponseType<CommerceReconciliationBatch>(StatusCodes.Status200OK)]
public async Task<ActionResult<CommerceReconciliationBatch>> CreateReconciliationBatch(
@@ -342,6 +361,7 @@ public sealed class TenantCommerceController(
}
[HttpGet("reconciliation/issues")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("查询对账异常")]
[ProducesResponseType<TenantReconciliationIssueList>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantReconciliationIssueList>> ReconciliationIssues(
@@ -355,6 +375,7 @@ public sealed class TenantCommerceController(
}
[HttpPost("reconciliation/issues/status")]
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
[EndpointSummary("更新对账异常状态")]
[ProducesResponseType<CommerceReconciliationIssue>(StatusCodes.Status200OK)]
public async Task<ActionResult<CommerceReconciliationIssue>> UpdateReconciliationIssue(

View File

@@ -9,7 +9,7 @@ using Tiku.Application.Security;
namespace Tiku.Api.Controllers;
[ApiController]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantContentManage)]
[Produces("application/json")]
[Route("api/tenant-content")]
public sealed class TenantContentController(

View File

@@ -11,7 +11,7 @@ using Tiku.Domain.Content;
namespace Tiku.Api.Controllers;
[ApiController]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantContentManage)]
[Produces("application/json")]
[Route("api/tenant-content")]
public sealed class TenantContentDirectController(
@@ -20,6 +20,7 @@ public sealed class TenantContentDirectController(
ITenantContext currentTenant) : ControllerBase
{
[HttpPost("questions")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("创建题目及首个版本")]
[ProducesResponseType<ContentManagementResult<QuestionManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<QuestionManagementItem>>> CreateQuestion(
@@ -30,6 +31,7 @@ public sealed class TenantContentDirectController(
}
[HttpPatch("questions")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("更新题目并可选择创建新版本")]
[ProducesResponseType<ContentManagementResult<QuestionManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<QuestionManagementItem>>> UpdateQuestion(
@@ -60,6 +62,7 @@ public sealed class TenantContentDirectController(
}
[HttpGet("vocabulary-words")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("查询管理侧词汇")]
[ProducesResponseType<CatalogList<VocabularyWord>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<VocabularyWord>>> GetVocabularyWords(
@@ -70,6 +73,7 @@ public sealed class TenantContentDirectController(
}
[HttpPut("vocabulary-words")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("新增或更新词汇")]
[ProducesResponseType<ContentManagementResult<VocabularyWord>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<VocabularyWord>>> UpsertVocabularyWord(
@@ -100,6 +104,7 @@ public sealed class TenantContentDirectController(
}
[HttpGet("handbook-chapters")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("查询管理侧知识手册章节")]
[ProducesResponseType<CatalogList<HandbookChapter>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<HandbookChapter>>> GetHandbookChapters(
@@ -110,6 +115,7 @@ public sealed class TenantContentDirectController(
}
[HttpPut("handbook-chapters")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("新增或更新知识手册章节")]
[ProducesResponseType<ContentManagementResult<HandbookChapter>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<HandbookChapter>>> UpsertHandbookChapter(
@@ -120,6 +126,7 @@ public sealed class TenantContentDirectController(
}
[HttpGet("handbook-entries")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("查询管理侧知识手册条目")]
[ProducesResponseType<CatalogList<HandbookEntry>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<HandbookEntry>>> GetHandbookEntries(
@@ -130,6 +137,7 @@ public sealed class TenantContentDirectController(
}
[HttpPut("handbook-entries")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("新增或更新知识手册条目")]
[ProducesResponseType<ContentManagementResult<HandbookEntry>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<HandbookEntry>>> UpsertHandbookEntry(
@@ -240,6 +248,7 @@ public sealed class TenantContentDirectController(
}
[HttpGet("videos")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("查询租户视频解析")]
[ProducesResponseType<CatalogList<VideoManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<VideoManagementItem>>> GetVideos(
@@ -250,6 +259,7 @@ public sealed class TenantContentDirectController(
}
[HttpPut("videos")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("新增或更新视频解析")]
[ProducesResponseType<ContentManagementResult<VideoManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<VideoManagementItem>>> UpsertVideo(
@@ -260,6 +270,7 @@ public sealed class TenantContentDirectController(
}
[HttpPost("question-videos")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("绑定题目与解析视频")]
[ProducesResponseType<ContentManagementResult<QuestionVideoManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<QuestionVideoManagementItem>>> BindQuestionVideo(
@@ -270,6 +281,7 @@ public sealed class TenantContentDirectController(
}
[HttpGet("operations/{kind}")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("查询运营内容")]
[ProducesResponseType<CatalogList<OperationContentItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<OperationContentItem>>> GetOperationContent(
@@ -281,6 +293,7 @@ public sealed class TenantContentDirectController(
}
[HttpPut("operations/{kind}")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("新增或更新运营内容")]
[ProducesResponseType<ContentManagementResult<OperationContentItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<OperationContentItem>>> UpsertOperationContent(
@@ -292,6 +305,7 @@ public sealed class TenantContentDirectController(
}
[HttpPost("imports/preview/{importType}")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("预览内容导入数据")]
[ProducesResponseType<SimpleImportResult>(StatusCodes.Status200OK)]
public async Task<ActionResult<SimpleImportResult>> PreviewImport(
@@ -303,6 +317,7 @@ public sealed class TenantContentDirectController(
}
[HttpPost("imports/{importType}")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("执行同步内容导入")]
[ProducesResponseType<SimpleImportResult>(StatusCodes.Status200OK)]
public async Task<ActionResult<SimpleImportResult>> ExecuteImport(
@@ -314,6 +329,7 @@ public sealed class TenantContentDirectController(
}
[HttpGet("imports/issues")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("查询内容导入问题明细")]
[ProducesResponseType<CatalogList<ContentImportIssueModel>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<ContentImportIssueModel>>> GetImportIssues(
@@ -324,6 +340,7 @@ public sealed class TenantContentDirectController(
}
[HttpPost("imports/post-check")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("执行内容导入后完整性检查")]
[ProducesResponseType<ImportPostCheckResult>(StatusCodes.Status200OK)]
public async Task<ActionResult<ImportPostCheckResult>> RunImportPostCheck(
@@ -334,6 +351,7 @@ public sealed class TenantContentDirectController(
}
[HttpGet("imports/post-check")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("查询内容导入后检查状态")]
[ProducesResponseType<ImportPostCheckResult>(StatusCodes.Status200OK)]
public async Task<ActionResult<ImportPostCheckResult>> GetImportPostCheck(

View File

@@ -7,7 +7,7 @@ using Tiku.Application.Tenancy;
namespace Tiku.Api.Controllers;
[ApiController]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
[Produces("application/json")]
[Route("api/tenant-admin/frontend-config")]
public sealed class TenantFrontendConfigController(

View File

@@ -1,7 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using Tiku.Application.Auth;
using Tiku.Application.Security;
using Tiku.Domain.Tenancy;
@@ -39,8 +38,7 @@ public sealed class TenantsController(
tenant.Name,
tenant.Slug,
tenant.Status,
membership.Role,
membership.Permissions))
membership.Role))
.SingleOrDefaultAsync(cancellationToken);
if (result is null)
@@ -60,11 +58,9 @@ public sealed class TenantsController(
/// <param name="TenantSlug">租户编码。</param>
/// <param name="Status">租户状态。</param>
/// <param name="Role">当前用户在租户内的角色。</param>
/// <param name="Permissions">当前用户在租户内的权限扩展。</param>
public sealed record CurrentTenantResponse(
Guid TenantId,
string TenantName,
string TenantSlug,
TenantStatus Status,
TenantRole Role,
JsonElement Permissions);
TenantRole Role);

View File

@@ -26,7 +26,7 @@ public static class SerilogRequestLogging
SetClaim(diagnosticContext, "UserId", user, TikuClaimTypes.UserId);
SetClaim(diagnosticContext, "TenantId", user, TikuClaimTypes.TenantId);
SetClaim(diagnosticContext, "SessionId", user, TikuClaimTypes.SessionId);
SetClaim(diagnosticContext, "TenantRole", user, TikuClaimTypes.TenantRole);
SetClaim(diagnosticContext, "AuthRealm", user, TikuClaimTypes.Realm);
};
}

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.Application.TenantAdmin;
using Tiku.Application.Tenancy;
using Tiku.Infrastructure.Backoffice;
namespace Tiku.Api.Middleware;
@@ -224,6 +225,16 @@ public sealed class ExceptionHandlingMiddleware(
return;
}
if (exception is BackofficeException backofficeException)
{
await WriteProblemAsync(
context,
backofficeException.Message,
BackofficeStatusCode(backofficeException.Code),
backofficeException.Code);
return;
}
if (exception is CommerceException commerceException)
{
await WriteProblemAsync(
@@ -404,6 +415,7 @@ public sealed class ExceptionHandlingMiddleware(
"entry_not_found" or "node_not_found" or "collection_not_found" or "question_not_found" or
"import_type_invalid" => StatusCodes.Status404NotFound,
"tenant_content_access_denied" => StatusCodes.Status403Forbidden,
_ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound,
_ => StatusCodes.Status400BadRequest
};
}
@@ -425,8 +437,8 @@ public sealed class ExceptionHandlingMiddleware(
{
"tenant_admin_access_denied" => StatusCodes.Status403Forbidden,
"class_not_found" or "class_member_not_found" or "student_not_found" or "user_not_found" => StatusCodes.Status404NotFound,
"tenant_member_not_found" => StatusCodes.Status400BadRequest,
_ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status400BadRequest,
"tenant_member_not_found" => StatusCodes.Status404NotFound,
_ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound,
_ => StatusCodes.Status400BadRequest
};
}
@@ -442,6 +454,17 @@ public sealed class ExceptionHandlingMiddleware(
"payment_provider_not_configured" or "payment_secret_not_configured" => StatusCodes.Status503ServiceUnavailable,
"order_status_invalid" or "activation_code_used" or "payment_amount_mismatch" or
"coupon_usage_limit_reached" or "coupon_redemption_status_invalid" => StatusCodes.Status409Conflict,
_ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound,
_ => StatusCodes.Status400BadRequest
};
}
private static int BackofficeStatusCode(string code)
{
return code switch
{
"platform_access_denied" or "tenant_access_denied" => StatusCodes.Status403Forbidden,
_ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound,
_ => StatusCodes.Status400BadRequest
};
}

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 const string DevelopmentSigningKey = "development-only-tiku-signing-key-change-before-production";
public static string ResolveDatabaseConnectionString(
IConfiguration configuration,
bool isDevelopment)
@@ -30,11 +28,7 @@ public static class OptionsValidation
public static bool BeValidJwtOptions(JwtOptions options, bool isProduction)
{
return !isProduction ||
!string.Equals(
options.SigningKey,
DevelopmentSigningKey,
StringComparison.Ordinal);
return JwtOptions.BeValid(options, isProduction);
}
public static bool BeValidCorsOptions(CorsOptions options)

View File

@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.HttpOverrides;
@@ -8,7 +9,6 @@ using Microsoft.IdentityModel.Tokens;
using Scalar.AspNetCore;
using Serilog;
using Serilog.Events;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using Tiku.Api.Logging;
@@ -17,11 +17,13 @@ using Tiku.Api.OpenApi;
using Tiku.Api.Options;
using Tiku.Api.Security;
using Tiku.Application;
using Tiku.Application.Auth;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Infrastructure;
using Tiku.Infrastructure.Commerce;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Security;
using Tiku.Infrastructure.Storage;
Log.Logger = new LoggerConfiguration()
@@ -116,11 +118,18 @@ try
var rateLimitOptions = builder.Configuration
.GetSection(ApiRateLimitOptions.SectionName)
.Get<ApiRateLimitOptions>() ?? new ApiRateLimitOptions();
if (rateLimitOptions.Enabled)
builder.Services.AddOptions<AuthRateLimitOptions>()
.Bind(builder.Configuration.GetSection(AuthRateLimitOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
var authRateLimitOptions = builder.Configuration
.GetSection(AuthRateLimitOptions.SectionName)
.Get<AuthRateLimitOptions>() ?? new AuthRateLimitOptions();
builder.Services.AddRateLimiter(options =>
{
builder.Services.AddRateLimiter(options =>
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
if (rateLimitOptions.Enabled)
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
{
var partitionKey =
@@ -139,33 +148,113 @@ try
Window = TimeSpan.FromSeconds(rateLimitOptions.WindowSeconds)
});
});
options.OnRejected = async (context, cancellationToken) =>
}
options.AddPolicy(
AuthRateLimitPolicies.Password,
httpContext => RateLimitPartition.GetFixedWindowLimiter(
AuthRateLimitPartitionKey.Resolve(httpContext, AuthRateLimitPolicies.Password),
_ => new FixedWindowRateLimiterOptions
{
AutoReplenishment = true,
PermitLimit = authRateLimitOptions.PasswordPermitLimit,
QueueLimit = 0,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
Window = TimeSpan.FromSeconds(authRateLimitOptions.PasswordWindowSeconds)
}));
options.AddPolicy(
AuthRateLimitPolicies.Sms,
httpContext => RateLimitPartition.GetFixedWindowLimiter(
AuthRateLimitPartitionKey.Resolve(httpContext, AuthRateLimitPolicies.Sms),
_ => new FixedWindowRateLimiterOptions
{
AutoReplenishment = true,
PermitLimit = authRateLimitOptions.SmsPermitLimit,
QueueLimit = 0,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
Window = TimeSpan.FromSeconds(authRateLimitOptions.SmsWindowSeconds)
}));
options.AddPolicy(
AuthRateLimitPolicies.Mfa,
httpContext => RateLimitPartition.GetFixedWindowLimiter(
AuthRateLimitPartitionKey.Resolve(httpContext, AuthRateLimitPolicies.Mfa),
_ => new FixedWindowRateLimiterOptions
{
AutoReplenishment = true,
PermitLimit = authRateLimitOptions.MfaPermitLimit,
QueueLimit = 0,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
Window = TimeSpan.FromSeconds(authRateLimitOptions.MfaWindowSeconds)
}));
options.OnRejected = async (context, cancellationToken) =>
{
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
{
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
{
context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString();
}
context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString();
}
var problem = new ProblemDetails
{
Title = "Too many requests.",
Status = StatusCodes.Status429TooManyRequests,
Instance = context.HttpContext.Request.Path
};
problem.Extensions["code"] = "rate_limited";
problem.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await context.HttpContext.Response.WriteAsJsonAsync(problem, cancellationToken);
var problem = new ProblemDetails
{
Title = "Too many requests.",
Status = StatusCodes.Status429TooManyRequests,
Instance = context.HttpContext.Request.Path
};
});
}
problem.Extensions["code"] = "rate_limited";
problem.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await context.HttpContext.Response.WriteAsJsonAsync(problem, cancellationToken);
};
});
var connectionString = OptionsValidation.ResolveDatabaseConnectionString(
builder.Configuration,
builder.Environment.IsDevelopment());
builder.Services.AddInfrastructure(connectionString);
var requireProtectedDataProtectionKeys = !builder.Environment.IsDevelopment();
builder.Services.AddOptions<DataProtectionKeyRingOptions>()
.Bind(builder.Configuration.GetSection(DataProtectionKeyRingOptions.SectionName))
.PostConfigure(options =>
{
options.ApplicationName =
builder.Configuration["TIKU_DATA_PROTECTION_APPLICATION_NAME"] ?? options.ApplicationName;
options.CertificatePath =
builder.Configuration["TIKU_DATA_PROTECTION_CERTIFICATE_PATH"] ?? options.CertificatePath;
options.CertificatePassword =
builder.Configuration["TIKU_DATA_PROTECTION_CERTIFICATE_PASSWORD"] ?? options.CertificatePassword;
})
.Validate(
options => DataProtectionKeyRingOptions.BeValid(options, requireProtectedDataProtectionKeys),
"Data Protection requires an application name and, outside Development, an X509 certificate path.")
.ValidateOnStart();
var dataProtectionOptions = builder.Configuration
.GetSection(DataProtectionKeyRingOptions.SectionName)
.Get<DataProtectionKeyRingOptions>() ?? new DataProtectionKeyRingOptions();
dataProtectionOptions.ApplicationName =
builder.Configuration["TIKU_DATA_PROTECTION_APPLICATION_NAME"] ?? dataProtectionOptions.ApplicationName;
dataProtectionOptions.CertificatePath =
builder.Configuration["TIKU_DATA_PROTECTION_CERTIFICATE_PATH"] ?? dataProtectionOptions.CertificatePath;
dataProtectionOptions.CertificatePassword =
builder.Configuration["TIKU_DATA_PROTECTION_CERTIFICATE_PASSWORD"] ?? dataProtectionOptions.CertificatePassword;
if (!DataProtectionKeyRingOptions.BeValid(dataProtectionOptions, requireProtectedDataProtectionKeys))
{
throw new InvalidOperationException(
"Data Protection requires an application name and, outside Development, an X509 certificate path.");
}
var dataProtection = builder.Services
.AddDataProtection()
.SetApplicationName(dataProtectionOptions.ApplicationName.Trim())
.PersistKeysToDbContext<TikuDbContext>();
var dataProtectionCertificate = dataProtectionOptions.LoadCertificate(requireProtectedDataProtectionKeys);
if (dataProtectionCertificate is not null)
{
dataProtection.ProtectKeysWithCertificate(dataProtectionCertificate);
}
builder.Services.Configure<ObjectStorageOptions>(
builder.Configuration.GetSection(ObjectStorageOptions.SectionName));
builder.Services.Configure<AliyunOssOptions>(
@@ -215,6 +304,18 @@ try
"Production tenant secret encryption cannot use the development master key.")
.ValidateOnStart();
builder.Services.AddOptions<SmsSecurityOptions>()
.Bind(builder.Configuration.GetSection(SmsSecurityOptions.SectionName))
.PostConfigure(options =>
{
options.CodePepper = builder.Configuration["TIKU_SMS_CODE_PEPPER"] ?? options.CodePepper;
})
.Validate(
SmsSecurityOptions.BeValid,
"SMS security requires a pepper of at least 32 characters, exactly five verification attempts, " +
"and positive tenant, phone, IP, and device rate limits.")
.ValidateOnStart();
builder.Services.AddOptions<JwtOptions>()
.Bind(builder.Configuration.GetSection("Security:Jwt"))
.ValidateDataAnnotations()
@@ -230,6 +331,7 @@ try
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
@@ -237,57 +339,93 @@ try
ValidateAudience = true,
ValidAudience = jwtOptions.Audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)),
RequireSignedTokens = true,
ValidAlgorithms = [SecurityAlgorithms.RsaSha256],
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
RequireExpirationTime = true,
ClockSkew = TimeSpan.FromMinutes(1),
NameClaimType = TikuClaimTypes.UserId
};
options.Events = new JwtBearerEvents
{
OnTokenValidated = async context =>
{
var tenantIdValue = context.Principal?.FindFirst(TikuClaimTypes.TenantId)?.Value;
if (!Guid.TryParse(tenantIdValue, out var tenantId))
var principal = context.Principal;
if (!Guid.TryParse(principal?.FindFirst(TikuClaimTypes.UserId)?.Value, out var userId) ||
!Guid.TryParse(principal?.FindFirst(TikuClaimTypes.SessionId)?.Value, out var sessionId) ||
string.IsNullOrWhiteSpace(principal?.FindFirst(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Jti)?.Value) ||
!long.TryParse(
principal?.FindFirst(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Iat)?.Value,
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
out _))
{
context.Fail("Missing tenant claim.");
context.Fail("Missing or invalid subject/session/jti/iat claim.");
return;
}
var realmValue = principal.FindFirst(TikuClaimTypes.Realm)?.Value;
var realm = string.Equals(realmValue, "tenant", StringComparison.Ordinal)
? Tiku.Domain.Tenancy.AuthRealm.Tenant
: string.Equals(realmValue, "platform", StringComparison.Ordinal)
? Tiku.Domain.Tenancy.AuthRealm.Platform
: (Tiku.Domain.Tenancy.AuthRealm?)null;
var tenantIdValue = principal.FindFirst(TikuClaimTypes.TenantId)?.Value;
var tenantId = Guid.TryParse(tenantIdValue, out var parsedTenantId)
? parsedTenantId
: (Guid?)null;
if (realm is null || (realm == Tiku.Domain.Tenancy.AuthRealm.Tenant) != tenantId.HasValue)
{
context.Fail("Token scope and tenant claims are inconsistent.");
return;
}
var resolutionOptions = context.HttpContext.RequestServices
.GetRequiredService<Microsoft.Extensions.Options.IOptions<TenantResolutionOptions>>().Value;
var requestHost = context.HttpContext.Request.Host.Host.Trim().TrimEnd('.');
var isPlatformHost = resolutionOptions.PlatformHosts.Any(host =>
string.Equals(host.Trim().TrimEnd('.'), requestHost, StringComparison.OrdinalIgnoreCase));
var resolvedTenantContext = context.HttpContext.RequestServices.GetRequiredService<ITenantContext>();
var tenantInitializer = context.HttpContext.RequestServices
.GetRequiredService<ITenantContextInitializer>();
try
if (realm == Tiku.Domain.Tenancy.AuthRealm.Platform)
{
tenantInitializer.Initialize(tenantId, null, TenantResolutionSource.Jwt);
if (!isPlatformHost || resolvedTenantContext.IsResolved)
{
context.HttpContext.Items["tenant_context_conflict"] = true;
context.Fail("Platform tokens are only valid on a platform host.");
return;
}
}
catch (TenantContextConflictException)
else
{
context.HttpContext.Items["tenant_context_conflict"] = true;
context.Fail("Authenticated tenant does not match the request host.");
return;
if (isPlatformHost && !resolvedTenantContext.IsResolved)
{
context.HttpContext.Items["tenant_context_conflict"] = true;
context.Fail("Tenant tokens on a platform host require a matching tenant code.");
return;
}
try
{
tenantInitializer.Initialize(tenantId!.Value, null, TenantResolutionSource.Jwt);
}
catch (TenantContextConflictException)
{
context.HttpContext.Items["tenant_context_conflict"] = true;
context.Fail("Authenticated tenant does not match the request host.");
return;
}
}
if (!jwtOptions.ValidateSessions)
var sessionStore = context.HttpContext.RequestServices.GetRequiredService<IAuthSessionStore>();
var session = await sessionStore.ValidateAccessSessionAsync(
sessionId, userId, realm.Value, tenantId, context.HttpContext.RequestAborted);
var tokenMfaSatisfied = principal.FindAll(TikuClaimTypes.Mfa)
.Any(claim => string.Equals(claim.Value, "mfa", StringComparison.Ordinal));
if (session is null || session.MfaSatisfied != tokenMfaSatisfied)
{
return;
}
var sessionIdValue = context.Principal?.FindFirst(TikuClaimTypes.SessionId)?.Value;
if (!Guid.TryParse(sessionIdValue, out var sessionId))
{
context.Fail("Missing session claim.");
return;
}
var dbContext = context.HttpContext.RequestServices.GetRequiredService<TikuDbContext>();
var now = DateTimeOffset.UtcNow;
var isSessionActive = await dbContext.AuthSessions.AnyAsync(
session =>
session.Id == sessionId &&
session.RevokedAt == null &&
session.ExpiresAt > now);
if (!isSessionActive)
{
context.Fail("Session has been revoked or expired.");
context.Fail("Session, identity, membership, tenant, role or MFA state is no longer valid.");
}
},
OnChallenge = async context =>
@@ -307,32 +445,44 @@ try
};
});
builder.Services.AddOptions<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme)
.Configure<IJwtKeyRing>((options, keyRing) =>
{
options.TokenValidationParameters.IssuerSigningKeys = keyRing.ValidationKeys;
options.TokenValidationParameters.TryAllIssuerSigningKeys = false;
options.TokenValidationParameters.IssuerSigningKeyResolver = (_, _, kid, _) =>
string.IsNullOrWhiteSpace(kid)
? []
: keyRing.ValidationKeys.Where(key =>
string.Equals(key.KeyId, kid, StringComparison.Ordinal));
});
builder.Services.AddAuthorization(options =>
{
options.FallbackPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
options.AddPolicy(
TikuPolicies.AuthenticatedUser,
policy => policy.RequireAuthenticatedUser());
options.AddPolicy(
TikuPolicies.CurrentTenantMember,
policy => policy
.RequireAuthenticatedUser()
.RequireAssertion(context => TenantRoleAuthorization.IsTenantMember(context.User)));
options.AddPolicy(
TikuPolicies.TenantAdmin,
policy => policy
.RequireAuthenticatedUser()
.RequireAssertion(context => TenantRoleAuthorization.IsTenantAdmin(context.User)));
policy => policy.RequireAuthenticatedUser());
});
builder.Services.AddTikuRbacAuthorization();
builder.Services.AddSingleton<Microsoft.AspNetCore.Authorization.IAuthorizationMiddlewareResultHandler,
AuditingAuthorizationMiddlewareResultHandler>();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapOpenApi().AllowAnonymous();
app.MapScalarApiReference(options => options
.WithTitle("TIKU Backend API")
.AddPreferredSecuritySchemes("BearerAuth")
.EnablePersistentAuthentication());
.EnablePersistentAuthentication())
.AllowAnonymous();
}
app.UseSerilogRequestLogging(SerilogRequestLogging.ConfigureRequestLogging);
@@ -343,10 +493,8 @@ try
app.UseCors(CorsOptions.PolicyName);
app.UseMiddleware<TenantResolutionMiddleware>();
app.UseAuthentication();
if (rateLimitOptions.Enabled)
{
app.UseRateLimiter();
}
app.UseMiddleware<AuthRateLimitPartitionMiddleware>();
app.UseRateLimiter();
app.UseMiddleware<CurrentPrincipalMiddleware>();
app.UseAuthorization();

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,
"QueueLimit": 0
},
"Authentication": {
"Sms": {
"CodePepper": "development-only-sms-code-pepper-change-before-production"
}
},
"Security": {
"Jwt": {
"SigningKey": "development-only-tiku-signing-key-change-before-production"
"KeyId": "development-ephemeral"
},
"TenantSecrets": {
"KeyId": "development-v1",

View File

@@ -47,7 +47,25 @@
"Enabled": true,
"PermitLimit": 600,
"WindowSeconds": 60,
"QueueLimit": 0
"QueueLimit": 0,
"Authentication": {
"PasswordPermitLimit": 5,
"PasswordWindowSeconds": 900,
"SmsPermitLimit": 5,
"SmsWindowSeconds": 300,
"MfaPermitLimit": 5,
"MfaWindowSeconds": 300
}
},
"Authentication": {
"Sms": {
"CodePepper": "",
"MaxVerificationAttempts": 5,
"TenantRequestsPerHour": 100,
"PhoneRequestsPerHour": 5,
"IpRequestsPerHour": 20,
"DeviceRequestsPerHour": 10
}
},
"Storage": {
"DefaultProvider": "aliyun_oss",
@@ -84,14 +102,20 @@
"Jwt": {
"Issuer": "tiku-backend",
"Audience": "tiku-api",
"SigningKey": "",
"AccessTokenMinutes": 30,
"RefreshTokenDays": 30,
"ValidateSessions": true
"KeyId": "",
"PrivateKeyPem": "",
"PublicKeys": {},
"AccessTokenMinutes": 15,
"RefreshTokenDays": 30
},
"TenantSecrets": {
"KeyId": "",
"MasterKey": ""
},
"DataProtection": {
"ApplicationName": "Tiku.Api",
"CertificatePath": "",
"CertificatePassword": ""
}
},
"AllowedHosts": "*"

View File

@@ -1,3 +1,4 @@
using System.Text.Json.Serialization;
using Tiku.Domain.Tenancy;
namespace Tiku.Application.Auth;
@@ -35,6 +36,7 @@ public sealed record TenantMembershipSummary(
/// <param name="Phone">手机号。</param>
/// <param name="Email">邮箱。</param>
/// <param name="Name">用户显示名称。</param>
/// <param name="Realm">当前令牌的 tenant 或 platform 授权域。</param>
/// <param name="Tenant">当前登录租户成员摘要。</param>
/// <param name="Tokens">认证令牌对。</param>
public sealed record AuthenticatedUser(
@@ -42,25 +44,47 @@ public sealed record AuthenticatedUser(
string? Phone,
string? Email,
string? Name,
TenantMembershipSummary Tenant,
AuthRealm Realm,
TenantMembershipSummary? Tenant,
AuthTokenPair Tokens);
public enum AuthenticationStatus
{
[JsonStringEnumMemberName("authenticated")]
Authenticated,
[JsonStringEnumMemberName("mfa_required")]
MfaRequired,
[JsonStringEnumMemberName("mfa_enrollment_required")]
MfaEnrollmentRequired,
[JsonStringEnumMemberName("password_change_required")]
PasswordChangeRequired
}
public sealed record AuthenticationResult(
AuthenticationStatus Status,
AuthenticatedUser? User = null,
string? ChallengeToken = null,
DateTimeOffset? ChallengeExpiresAt = null);
public sealed record PasswordLoginRequest(
Guid TenantId,
AuthRealm Realm,
Guid? TenantId,
string Phone,
string Password,
string? IpAddress,
string? UserAgent);
public sealed record SmsLoginRequest(
Guid TenantId,
AuthRealm Realm,
Guid? TenantId,
string Phone,
string Code,
string? IpAddress,
string? UserAgent);
public sealed record WechatLoginRequest(
Guid TenantId,
AuthRealm Realm,
Guid? TenantId,
string Code,
string? IpAddress,
string? UserAgent);
@@ -73,6 +97,24 @@ public sealed record RefreshSessionRequest(
public sealed record LogoutSessionRequest(
string RefreshToken);
public sealed record MfaChallengeRequest(
string ChallengeToken,
string? Code,
string? IpAddress,
string? UserAgent);
public sealed record PasswordChangeChallengeRequest(
string ChallengeToken,
string NewPassword,
string? IpAddress,
string? UserAgent);
public sealed record MfaSetupResult(string SharedKey, string AuthenticatorUri);
public sealed record MfaConfirmResult(
AuthenticationResult Authentication,
IReadOnlyList<string> RecoveryCodes);
public sealed record SmsSendResult(
Guid VerificationId,
DateTimeOffset ExpiresAt);
@@ -82,4 +124,5 @@ public sealed record SendSmsCodeRequest(
string Phone,
SmsPurpose Purpose,
string? IpAddress,
string? UserAgent);
string? UserAgent,
string? DeviceId = null);

View File

@@ -19,3 +19,6 @@ public sealed class SmsRateLimitedException()
public sealed class AuthProviderNotConfiguredException(string provider)
: AuthException("auth_provider_not_configured", $"The {provider} auth provider is not configured.");
public sealed class InvalidAuthChallengeException(string code = "invalid_auth_challenge")
: AuthException(code, "The authentication challenge is invalid, consumed, or expired.");

View File

@@ -2,19 +2,19 @@ namespace Tiku.Application.Auth;
public interface IAuthService
{
Task<AuthenticatedUser> LoginWithPasswordAsync(
Task<AuthenticationResult> LoginWithPasswordAsync(
PasswordLoginRequest request,
CancellationToken cancellationToken = default);
Task<AuthenticatedUser> LoginWithSmsAsync(
Task<AuthenticationResult> LoginWithSmsAsync(
SmsLoginRequest request,
CancellationToken cancellationToken = default);
Task<AuthenticatedUser> LoginWithWechatWebAsync(
Task<AuthenticationResult> LoginWithWechatWebAsync(
WechatLoginRequest request,
CancellationToken cancellationToken = default);
Task<AuthenticatedUser> LoginWithWechatMiniAppAsync(
Task<AuthenticationResult> LoginWithWechatMiniAppAsync(
WechatLoginRequest request,
CancellationToken cancellationToken = default);
@@ -25,4 +25,22 @@ public interface IAuthService
Task LogoutAsync(
LogoutSessionRequest request,
CancellationToken cancellationToken = default);
Task LogoutAllAsync(Guid userId, CancellationToken cancellationToken = default);
Task<MfaSetupResult> SetupTotpAsync(
MfaChallengeRequest request,
CancellationToken cancellationToken = default);
Task<MfaConfirmResult> ConfirmTotpAsync(
MfaChallengeRequest request,
CancellationToken cancellationToken = default);
Task<AuthenticationResult> VerifyTotpAsync(
MfaChallengeRequest request,
CancellationToken cancellationToken = default);
Task<AuthenticationResult> ChangeRequiredPasswordAsync(
PasswordChangeChallengeRequest request,
CancellationToken cancellationToken = default);
}

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,
string? phone,
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 Tiku.Application.Security;
using Tiku.Domain.Operations;
namespace Tiku.Application.Backoffice;
public sealed record BackofficeActor(Guid UserId, Guid? TenantId, bool IsPlatform);
public sealed record BackofficeActor(Guid UserId, Guid? TenantId, bool IsPlatform)
{
public static BackofficeActor FromTenantAccess(CurrentAccessSnapshot access)
{
if (access.UserId is not { } userId ||
access.TenantId is not { } tenantId ||
!access.IsCurrentTenantMember)
{
throw new InvalidOperationException("Tenant backoffice actor was not resolved.");
}
return new BackofficeActor(userId, tenantId, false);
}
public static BackofficeActor FromPlatformAccess(CurrentAccessSnapshot access)
{
if (access.UserId is not { } userId || !access.IsUserActive)
{
throw new InvalidOperationException("Platform backoffice actor was not resolved.");
}
return new BackofficeActor(userId, null, true);
}
}
public sealed record BackofficeBootstrap(
IReadOnlyCollection<BackofficePermissionItem> Permissions,
IReadOnlyCollection<BackofficeMenuItem> Menus,
IReadOnlyCollection<BackofficeRoleItem> Roles);
public sealed record BackofficeUiBootstrap(
IReadOnlyCollection<string> PermissionCodes,
IReadOnlyCollection<BackofficeMenuItem> Menus);
public sealed record BackofficePermissionItem(
Guid Id,
string Code,

View File

@@ -1,7 +1,17 @@
using Tiku.Application.Security;
namespace Tiku.Application.Backoffice;
public interface IBackofficeService
{
Task<BackofficeUiBootstrap> GetTenantUiBootstrapAsync(
CurrentAccessSnapshot access,
CancellationToken cancellationToken = default);
Task<BackofficeUiBootstrap> GetPlatformUiBootstrapAsync(
CurrentAccessSnapshot access,
CancellationToken cancellationToken = default);
Task<BackofficeBootstrap> GetTenantBootstrapAsync(
BackofficeActor actor,
CancellationToken cancellationToken = default);

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 string? Phone { get; private set; }
public string? Email { get; private set; }
public string? TenantRole { get; private set; }
public bool IsAuthenticated { get; private set; }
public void Load(ClaimsPrincipal principal)
@@ -18,6 +17,5 @@ public sealed class CurrentUser : ICurrentUser
SessionId = principal.FindGuid(TikuClaimTypes.SessionId);
Phone = principal.FindValue(TikuClaimTypes.Phone);
Email = principal.FindValue(TikuClaimTypes.Email);
TenantRole = principal.FindValue(TikuClaimTypes.TenantRole);
}
}

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; }
string? Phone { get; }
string? Email { get; }
string? TenantRole { get; }
bool IsAuthenticated { get; }
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";
[System.ComponentModel.DataAnnotations.Required]
[System.ComponentModel.DataAnnotations.MinLength(32)]
public string SigningKey { get; set; } = string.Empty;
public string KeyId { get; set; } = "development-ephemeral";
public string PrivateKeyPem { get; set; } = string.Empty;
public Dictionary<string, string> PublicKeys { get; set; } = new(StringComparer.Ordinal);
[System.ComponentModel.DataAnnotations.Range(1, 1440)]
public int AccessTokenMinutes { get; set; } = 30;
public int AccessTokenMinutes { get; set; } = 15;
[System.ComponentModel.DataAnnotations.Range(1, 365)]
public int RefreshTokenDays { get; set; } = 30;
public bool ValidateSessions { get; set; } = true;
public static bool BeValid(JwtOptions options, bool isProduction)
{
if (string.IsNullOrWhiteSpace(options.Issuer) ||
string.IsNullOrWhiteSpace(options.Audience) ||
string.IsNullOrWhiteSpace(options.KeyId) ||
options.AccessTokenMinutes != 15 ||
(isProduction && string.Equals(
options.KeyId,
"development-ephemeral",
StringComparison.Ordinal)))
{
return false;
}
if (string.IsNullOrWhiteSpace(options.PrivateKeyPem))
{
if (isProduction)
{
return false;
}
}
else if (!IsValidRsaPem(options.PrivateKeyPem, requirePrivateKey: true))
{
return false;
}
return options.PublicKeys.All(pair =>
!string.IsNullOrWhiteSpace(pair.Key) &&
!string.Equals(pair.Key, options.KeyId, StringComparison.Ordinal) &&
IsValidRsaPem(pair.Value, requirePrivateKey: false));
}
private static bool IsValidRsaPem(string pem, bool requirePrivateKey)
{
try
{
using var rsa = System.Security.Cryptography.RSA.Create();
rsa.ImportFromPem(pem);
if (rsa.KeySize < 2048)
{
return false;
}
if (requirePrivateKey)
{
_ = rsa.ExportParameters(includePrivateParameters: true);
}
return true;
}
catch (Exception exception) when (
exception is ArgumentException or
System.Security.Cryptography.CryptographicException)
{
return false;
}
}
}

View File

@@ -4,10 +4,11 @@ namespace Tiku.Application.Security;
public static class TikuClaimTypes
{
public const string UserId = "tiku:user_id";
public const string TenantId = "tiku:tenant_id";
public const string SessionId = "tiku:session_id";
public const string TenantRole = "tiku:tenant_role";
public const string UserId = "sub";
public const string TenantId = "tid";
public const string SessionId = "sid";
public const string Realm = "scope";
public const string Mfa = "amr";
public const string Phone = ClaimTypes.MobilePhone;
public const string Email = ClaimTypes.Email;
}

View File

@@ -4,5 +4,23 @@ public static class TikuPolicies
{
public const string AuthenticatedUser = "authenticated_user";
public const string CurrentTenantMember = "current_tenant_member";
public const string TenantBackofficeBootstrap = "tenant_backoffice_bootstrap";
public const string PlatformBackofficeBootstrap = "platform_backoffice_bootstrap";
public const string Mfa = "mfa";
public const string TenantContentManageAllScope = "tenant:content:manage:all_scope";
public const string TenantCommerceOperateAllScope = "tenant:commerce:operate:all_scope";
public const string TenantAdmin = "tenant_admin";
public static string TenantPermission(string permissionCode)
{
BackendPermissions.EnsureTenant(permissionCode);
return permissionCode;
}
public static string PlatformPermission(string permissionCode)
{
BackendPermissions.EnsurePlatform(permissionCode);
return permissionCode;
}
}

View File

@@ -90,25 +90,6 @@ public interface ITenantAdminDirectService
TenantAdminAuditLogFilter filter,
CancellationToken cancellationToken = default);
Task<TenantAdminPermissionMatrix> GetPermissionMatrixAsync(
TenantAdminActor actor,
CancellationToken cancellationToken = default);
Task<CatalogList<TenantAdminRoleTemplateItem>> GetRoleTemplatesAsync(
TenantAdminActor actor,
TenantAdminRoleTemplateFilter filter,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantAdminRoleTemplateItem>> UpsertRoleTemplateAsync(
TenantAdminActor actor,
UpsertTenantAdminRoleTemplateCommand command,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantAdminRoleTemplateItem>> DisableRoleTemplateAsync(
TenantAdminActor actor,
Guid roleTemplateId,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantBrandingItem>> UpsertBrandingAsync(
TenantAdminActor actor,
UpsertTenantBrandingCommand command,

View File

@@ -6,7 +6,18 @@ using Tiku.Domain.Tenancy;
namespace Tiku.Application.TenantAdmin;
public sealed record TenantAdminActor(Guid TenantId, Guid UserId, TenantRole Role = TenantRole.TenantAdmin);
public sealed record TenantAdminActor(Guid TenantId, Guid UserId)
{
public static TenantAdminActor FromResolvedIdentity(Guid? tenantId, Guid? userId)
{
if (tenantId is null || userId is null)
{
throw new InvalidOperationException("Tenant admin actor was not resolved.");
}
return new(tenantId.Value, userId.Value);
}
}
public sealed record TenantAdminClassFilter(
Guid? RegionId = null,
@@ -44,10 +55,6 @@ public sealed record TenantAdminAuditLogFilter(
Guid? ActorUserId = null,
int? Limit = null);
public sealed record TenantAdminRoleTemplateFilter(
string? Status = null,
int? Limit = null);
public sealed record TenantAdminBadgeFilter(
string? Category = null,
bool IncludeInactive = false,
@@ -140,24 +147,8 @@ public sealed record UpsertTenantAdminMemberCommand(
UserLookupCommand User,
string? Role,
string? Status,
Guid? RoleTemplateId,
JsonElement Permissions,
string? PrimaryRole);
public sealed record UpsertTenantAdminRoleTemplateCommand(
Guid? Id,
string? Code,
string Name,
string? Description,
string? BaseRole,
string? Status,
JsonElement Permissions,
JsonElement MenuPermissions,
JsonElement ModulePermissions,
JsonElement FieldPermissions,
JsonElement DataScope,
int? Order);
public sealed record UpsertTenantBrandingCommand(
string BrandName,
string? ShortName,
@@ -357,10 +348,6 @@ public sealed record TenantAdminMemberItem(
Guid UserId,
TenantRole Role,
MembershipStatus Status,
JsonElement Permissions,
Guid? RoleTemplateId,
string? RoleTemplateCode,
string? RoleTemplateName,
string? LegacyRole,
TenantAdminUserSummary User,
DateTimeOffset CreatedAt,
@@ -379,37 +366,6 @@ public sealed record TenantAdminAuditLogItem(
string? ActorPhone,
DateTimeOffset CreatedAt);
public sealed record TenantAdminPermissionMatrix(
TenantAdminCurrentPermission Current,
IReadOnlyCollection<TenantAdminPermissionCatalogItem> Permissions,
IReadOnlyDictionary<string, IReadOnlyCollection<string>> RoleDefaults);
public sealed record TenantAdminCurrentPermission(
Guid UserId,
Guid TenantId,
TenantRole Role);
public sealed record TenantAdminPermissionCatalogItem(string Key, string Label);
public sealed record TenantAdminRoleTemplateItem(
Guid Id,
string Code,
string Name,
string? Description,
TenantRole BaseRole,
TenantRoleTemplateStatus Status,
JsonElement Permissions,
JsonElement MenuPermissions,
JsonElement ModulePermissions,
JsonElement FieldPermissions,
JsonElement DataScope,
bool IsSystem,
int Order,
Guid? CreatedBy,
Guid? UpdatedBy,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
public sealed record TenantBrandingItem(
Guid TenantId,
string BrandName,

View File

@@ -6,6 +6,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
</ItemGroup>
<PropertyGroup>

View File

@@ -1,12 +1,24 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Tiku.Infrastructure;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Bootstrap;
using Tiku.Application;
var builder = Host.CreateApplicationBuilder(args);
var bootstrapPlatformAdmin = args.Contains("--bootstrap-platform-admin", StringComparer.Ordinal);
PlatformAdminBootstrapOptions? bootstrapOptions = null;
if (bootstrapPlatformAdmin)
{
bootstrapOptions = new PlatformAdminBootstrapOptions(
RequiredBootstrapSetting(builder.Configuration, "TIKU_BOOTSTRAP_PLATFORM_ADMIN_EMAIL"),
RequiredBootstrapSetting(builder.Configuration, "TIKU_BOOTSTRAP_PLATFORM_ADMIN_PASSWORD"),
builder.Configuration["TIKU_BOOTSTRAP_PLATFORM_ADMIN_NAME"]);
}
var connectionString =
builder.Configuration.GetConnectionString("Database") ??
Environment.GetEnvironmentVariable("DATABASE_URL") ??
@@ -15,8 +27,26 @@ var connectionString =
builder.Services.AddApplication();
builder.Services.AddInfrastructure(connectionString);
// Resolving UserManager<User> also activates Identity's default token providers.
// Bootstrap never issues a reset token, so the migrator uses a process-local provider;
// the API remains the sole owner of the persisted, certificate-protected key ring.
builder.Services.AddDataProtection().UseEphemeralDataProtectionProvider();
using var host = builder.Build();
await using var scope = host.Services.CreateAsyncScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
await dbContext.Database.MigrateAsync();
if (bootstrapOptions is not null)
{
var bootstrapper = ActivatorUtilities.CreateInstance<PlatformAdminBootstrapper>(scope.ServiceProvider);
var result = await bootstrapper.BootstrapAsync(bootstrapOptions);
Console.WriteLine($"Platform administrator '{result.Email}' was created and must change the temporary password and enroll MFA at first sign-in.");
}
static string RequiredBootstrapSetting(IConfiguration configuration, string key)
{
return configuration[key] is { } value && !string.IsNullOrWhiteSpace(value)
? value
: throw new InvalidOperationException($"{key} is required with --bootstrap-platform-admin.");
}

View File

@@ -1,20 +1,35 @@
using System.Text.Json;
using Microsoft.AspNetCore.Identity;
using Tiku.Domain.Common;
namespace Tiku.Domain.Identity;
public sealed class User : AuditableEntity
public sealed class User : IdentityUser<Guid>, IHasTimestamps
{
public User()
{
Id = Guid.NewGuid();
SecurityStamp = Guid.NewGuid().ToString("N");
ConcurrencyStamp = Guid.NewGuid().ToString("N");
}
public string? LegacyId { get; set; }
public string? Username { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
public string? Name { get; set; }
public string? AvatarUrl { get; set; }
public string PrimaryRole { get; set; } = "student";
public int Score { get; set; }
public DateTimeOffset? LastSeenAt { get; set; }
public string? LegacyPasswordHash { get; set; }
public bool PasswordMigrationRequired { get; set; }
public UserStatus Status { get; set; } = UserStatus.Active;
public bool ForcePasswordChange { get; set; }
public JsonElement RawProfile { get; set; } = JsonDefaults.Object();
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public enum UserStatus
{
Active,
Disabled,
Archived
}

View File

@@ -1,4 +1,3 @@
using System.Text.Json;
using Tiku.Domain.Common;
namespace Tiku.Domain.Identity;
@@ -12,5 +11,4 @@ public sealed class UserIdentity : AuditableEntity
public string? OpenId { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public JsonElement SecretPayload { get; set; } = JsonDefaults.Object();
}

View File

@@ -1,4 +1,3 @@
using System.Text.Json;
using Tiku.Domain.Common;
namespace Tiku.Domain.Tenancy;
@@ -6,16 +5,13 @@ namespace Tiku.Domain.Tenancy;
public sealed class TenantMembership : AuditableTenantEntity
{
public Guid UserId { get; set; }
public Guid? RoleTemplateId { get; set; }
public TenantRole Role { get; set; } = TenantRole.Student;
public MembershipStatus Status { get; set; } = MembershipStatus.Active;
public JsonElement Permissions { get; set; } = JsonDefaults.Object();
public string? LegacyRole { get; set; }
}
public enum TenantRole
{
PlatformAdmin,
TenantOwner,
TenantAdmin,
TenantOperator,

View File

@@ -47,9 +47,9 @@ public sealed class SmsVerificationCode : Entity, ITenantOwned
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class AuthLoginEvent : Entity, ITenantOwned
public sealed class AuthLoginEvent : Entity
{
public Guid TenantId { get; set; }
public Guid? TenantId { get; set; }
public Guid? UserId { get; set; }
public string Provider { get; set; } = string.Empty;
public string? Identifier { get; set; }
@@ -61,18 +61,46 @@ public sealed class AuthLoginEvent : Entity, ITenantOwned
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class AuthSession : AuditableTenantEntity
public sealed class AuthSession : AuditableEntity
{
public AuthRealm Realm { get; set; } = AuthRealm.Tenant;
public Guid? TenantId { get; set; }
public Guid UserId { get; set; }
public Guid TokenFamilyId { get; set; }
public Guid? ParentSessionId { get; set; }
public Guid? ReplacedBySessionId { get; set; }
public string TokenHash { get; set; } = string.Empty;
public string SecurityStamp { get; set; } = string.Empty;
public bool MfaSatisfied { get; set; }
public string Provider { get; set; } = string.Empty;
public DateTimeOffset ExpiresAt { get; set; }
public DateTimeOffset? RevokedAt { get; set; }
public string? RevokedReason { get; set; }
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public enum AuthRealm { Tenant, Platform }
public sealed class AuthChallenge : Entity
{
public Guid UserId { get; set; }
public AuthRealm Realm { get; set; }
public Guid? TenantId { get; set; }
public AuthChallengePurpose Purpose { get; set; }
public string TokenHash { get; set; } = string.Empty;
public string SecurityStamp { get; set; } = string.Empty;
public string Provider { get; set; } = string.Empty;
public DateTimeOffset ExpiresAt { get; set; }
public DateTimeOffset? ConsumedAt { get; set; }
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public enum AuthChallengePurpose { MfaEnrollment, MfaVerification, PasswordChange }
public sealed class SmsSendRateLimit : ITenantOwned
{
public Guid TenantId { get; set; }
@@ -83,24 +111,6 @@ public sealed class SmsSendRateLimit : ITenantOwned
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class TenantRoleTemplate : AuditableTenantEntity
{
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public TenantRole BaseRole { get; set; } = TenantRole.TenantOperator;
public TenantRoleTemplateStatus Status { get; set; } = TenantRoleTemplateStatus.Active;
public JsonElement Permissions { get; set; } = JsonDefaults.Object();
public JsonElement MenuPermissions { get; set; } = JsonDefaults.Object();
public JsonElement ModulePermissions { get; set; } = JsonDefaults.Object();
public JsonElement FieldPermissions { get; set; } = JsonDefaults.Object();
public JsonElement DataScope { get; set; } = JsonDefaults.Object();
public bool IsSystem { get; set; }
public int SortOrder { get; set; } = 100;
public Guid? CreatedBy { get; set; }
public Guid? UpdatedBy { get; set; }
}
public sealed class TenantClass : AuditableTenantEntity
{
public Guid? RegionId { get; set; }
@@ -165,7 +175,6 @@ public enum SmsPurpose { Login, BindPhone, ResetPassword }
public enum SmsVerificationStatus { Pending, Sent, Verified, Expired, Blocked, Failed }
public enum AuthLoginResult { Sent, Success, Failed, Blocked }
public enum SmsRateLimitDimension { Tenant, Phone, Ip, Device }
public enum TenantRoleTemplateStatus { Active, Disabled, Archived }
public enum TenantRecordStatus { Active, Disabled, Archived }
public enum TenantClassMemberType { Student, Teacher, Assistant, HeadTeacher }
public enum TenantClassMemberStatus { Active, Disabled, Removed }

View File

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

View File

@@ -1,5 +1,9 @@
using System.Text.Json;
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Tokens;
using Tiku.Application.Auth;
using Tiku.Application.Tenancy;
using Tiku.Domain.Identity;
@@ -10,9 +14,10 @@ namespace Tiku.Infrastructure.Auth;
public sealed class AuthService(
TikuDbContext dbContext,
IPasswordHasher passwordHasher,
SignInManager<User> signInManager,
UserManager<User> userManager,
ISmsVerificationService smsVerificationService,
ISessionService sessionService,
IAuthSessionStore sessionStore,
IWechatOAuthClient wechatOAuthClient,
ITenantExternalProviderConfigService providerConfigService) : IAuthService
{
@@ -24,35 +29,38 @@ public sealed class AuthService(
private static readonly string[] WechatMiniAppProviderAliases = ["wechat-miniapp", "wechat_miniapp", "wechat-mini", "wechatMiniapp"];
private static readonly string[] WechatIdentityProviders = ["wechat_web", "wechat-web", "wechat", "wechat-miniapp", "wechat_miniapp", "wechat-mini", "wechatMiniapp"];
public async Task<AuthenticatedUser> LoginWithPasswordAsync(
public async Task<AuthenticationResult> LoginWithPasswordAsync(
PasswordLoginRequest request,
CancellationToken cancellationToken = default)
{
var phone = SmsCodeHashing.NormalizePhone(request.Phone);
var identifier = request.Phone.Trim();
var normalizedEmail = userManager.NormalizeEmail(identifier);
var normalizedUserName = userManager.NormalizeName(identifier);
var user = await dbContext.Users
.SingleOrDefaultAsync(entity => entity.Phone == phone, cancellationToken);
var identity = user is null
? null
: await dbContext.UserIdentities
.SingleOrDefaultAsync(
entity =>
entity.UserId == user.Id &&
entity.Provider == PasswordProvider &&
entity.ProviderSubject == phone,
cancellationToken);
.SingleOrDefaultAsync(entity =>
entity.Phone == identifier ||
entity.NormalizedEmail == normalizedEmail ||
entity.NormalizedUserName == normalizedUserName,
cancellationToken);
var passwordResult = user is null || user.Status != UserStatus.Active
? SignInResult.Failed
: await signInManager.CheckPasswordSignInAsync(user, request.Password, lockoutOnFailure: true);
if (user is null ||
identity is null ||
!TryGetPasswordHash(identity.SecretPayload, out var passwordHash) ||
!passwordHasher.Verify(request.Password, passwordHash))
if (!passwordResult.Succeeded)
{
var loginResult = passwordResult.IsLockedOut
? AuthLoginResult.Blocked
: AuthLoginResult.Failed;
var failureCode = passwordResult.IsLockedOut
? "account_locked"
: "invalid_credentials";
await AddLoginEventAsync(
request.TenantId,
user?.Id,
PasswordProvider,
phone,
AuthLoginResult.Failed,
"invalid_credentials",
identifier,
loginResult,
failureCode,
request.IpAddress,
request.UserAgent,
cancellationToken);
@@ -60,16 +68,17 @@ public sealed class AuthService(
}
return await CompleteSuccessfulLoginAsync(
request.Realm,
request.TenantId,
user,
user!,
PasswordProvider,
phone,
identifier,
request.IpAddress,
request.UserAgent,
cancellationToken);
}
public async Task<AuthenticatedUser> LoginWithSmsAsync(
public async Task<AuthenticationResult> LoginWithSmsAsync(
SmsLoginRequest request,
CancellationToken cancellationToken = default)
{
@@ -79,8 +88,12 @@ public sealed class AuthService(
try
{
if (!request.TenantId.HasValue)
{
throw new InvalidCredentialsException("tenant_required_for_sms");
}
await smsVerificationService.VerifyCodeAsync(
request.TenantId,
request.TenantId.Value,
phone,
SmsPurpose.Login,
request.Code,
@@ -117,6 +130,7 @@ public sealed class AuthService(
}
return await CompleteSuccessfulLoginAsync(
request.Realm,
request.TenantId,
user,
SmsProvider,
@@ -126,7 +140,7 @@ public sealed class AuthService(
cancellationToken);
}
public Task<AuthenticatedUser> LoginWithWechatWebAsync(
public Task<AuthenticationResult> LoginWithWechatWebAsync(
WechatLoginRequest request,
CancellationToken cancellationToken = default)
{
@@ -138,7 +152,7 @@ public sealed class AuthService(
cancellationToken);
}
public Task<AuthenticatedUser> LoginWithWechatMiniAppAsync(
public Task<AuthenticationResult> LoginWithWechatMiniAppAsync(
WechatLoginRequest request,
CancellationToken cancellationToken = default)
{
@@ -154,90 +168,218 @@ public sealed class AuthService(
RefreshSessionRequest request,
CancellationToken cancellationToken = default)
{
if (!sessionService.TryParseRefreshToken(request.RefreshToken, out var locator))
{
throw new SessionRevokedException();
}
var tokenHash = sessionService.HashRefreshToken(request.RefreshToken);
var now = DateTimeOffset.UtcNow;
var session = await dbContext.AuthSessions
.SingleOrDefaultAsync(entity =>
entity.Id == locator.SessionId &&
entity.TenantId == locator.TenantId &&
entity.TokenHash == tokenHash,
cancellationToken);
if (session is null || session.RevokedAt is not null || session.ExpiresAt <= now)
{
throw new SessionRevokedException();
}
var user = await dbContext.Users.FindAsync([session.UserId], cancellationToken)
?? throw new SessionRevokedException();
var membership = await FindActiveMembershipAsync(session.TenantId, session.UserId, cancellationToken)
?? throw new TenantAccessDeniedException();
session.RevokedAt = now;
await AddLoginEventAsync(
session.TenantId,
session.UserId,
"refresh",
user.Phone ?? user.Email,
AuthLoginResult.Success,
null,
request.IpAddress,
request.UserAgent,
cancellationToken);
return await sessionService.IssueAsync(
user.Id,
user.Phone,
user.Email,
membership,
"refresh",
request.IpAddress,
request.UserAgent,
cancellationToken);
return await sessionStore.RotateAsync(
request.RefreshToken, request.IpAddress, request.UserAgent, cancellationToken);
}
public async Task LogoutAsync(
LogoutSessionRequest request,
CancellationToken cancellationToken = default)
{
if (!sessionService.TryParseRefreshToken(request.RefreshToken, out var locator))
{
return;
}
var tokenHash = sessionService.HashRefreshToken(request.RefreshToken);
var session = await dbContext.AuthSessions
.SingleOrDefaultAsync(entity =>
entity.Id == locator.SessionId &&
entity.TenantId == locator.TenantId &&
entity.TokenHash == tokenHash,
cancellationToken);
if (session is null || session.RevokedAt is not null)
{
return;
}
session.RevokedAt = DateTimeOffset.UtcNow;
await AddLoginEventAsync(
session.TenantId,
session.UserId,
"logout",
null,
AuthLoginResult.Success,
null,
null,
null,
cancellationToken);
await sessionStore.RevokeFamilyAsync(request.RefreshToken, "logout", cancellationToken);
}
private async Task<AuthenticatedUser> CompleteSuccessfulLoginAsync(
Guid tenantId,
public async Task LogoutAllAsync(Guid userId, CancellationToken cancellationToken = default)
{
var user = await userManager.FindByIdAsync(userId.ToString())
?? throw new InvalidCredentialsException();
var stampResult = await userManager.UpdateSecurityStampAsync(user);
if (!stampResult.Succeeded)
{
throw new InvalidOperationException("Unable to update the user's security stamp.");
}
await sessionStore.RevokeAllAsync(userId, "logout_all", cancellationToken);
}
public async Task<MfaSetupResult> SetupTotpAsync(
MfaChallengeRequest request,
CancellationToken cancellationToken = default)
{
var challenge = await FindChallengeAsync(
request.ChallengeToken, AuthChallengePurpose.MfaEnrollment, cancellationToken);
var user = await userManager.FindByIdAsync(challenge.UserId.ToString())
?? throw new InvalidAuthChallengeException();
var reset = await userManager.ResetAuthenticatorKeyAsync(user);
if (!reset.Succeeded)
{
throw new InvalidOperationException("Unable to initialize the authenticator key.");
}
var key = await userManager.GetAuthenticatorKeyAsync(user)
?? throw new InvalidOperationException("Authenticator key was not generated.");
challenge.SecurityStamp = user.SecurityStamp ?? string.Empty;
var account = user.Email ?? user.Phone ?? user.Id.ToString();
var uri = $"otpauth://totp/{Uri.EscapeDataString("TIKU:" + account)}" +
$"?secret={Uri.EscapeDataString(key)}&issuer={Uri.EscapeDataString("TIKU")}&digits=6";
await AddSecurityAuditAsync(
user.Id, challenge.TenantId, "auth.mfa.enrollment_setup", null,
request.IpAddress, request.UserAgent, cancellationToken);
return new MfaSetupResult(key, uri);
}
public async Task<MfaConfirmResult> ConfirmTotpAsync(
MfaChallengeRequest request,
CancellationToken cancellationToken = default)
{
var challenge = await FindChallengeAsync(
request.ChallengeToken, AuthChallengePurpose.MfaEnrollment, cancellationToken);
var user = await userManager.FindByIdAsync(challenge.UserId.ToString())
?? throw new InvalidAuthChallengeException();
if (string.IsNullOrWhiteSpace(request.Code) ||
!await userManager.VerifyTwoFactorTokenAsync(
user, TokenOptions.DefaultAuthenticatorProvider, NormalizeTotp(request.Code)))
{
await AddSecurityAuditAsync(
user.Id, challenge.TenantId, "auth.mfa.enrollment_denied", "invalid_code",
request.IpAddress, request.UserAgent, cancellationToken);
throw new InvalidCredentialsException("invalid_mfa_code");
}
var enabled = await userManager.SetTwoFactorEnabledAsync(user, true);
if (!enabled.Succeeded)
{
throw new InvalidOperationException("Unable to enable two-factor authentication.");
}
await ConsumeChallengeAsync(challenge, cancellationToken);
await AddSecurityAuditAsync(
user.Id, challenge.TenantId, "auth.mfa.enrollment_confirmed", null,
request.IpAddress, request.UserAgent, cancellationToken);
var recoveryCodes = (await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10))?.ToArray() ?? [];
var authentication = await IssueFromChallengeAsync(
challenge, user, request.IpAddress, request.UserAgent, cancellationToken);
return new MfaConfirmResult(authentication, recoveryCodes);
}
public async Task<AuthenticationResult> VerifyTotpAsync(
MfaChallengeRequest request,
CancellationToken cancellationToken = default)
{
var challenge = await FindChallengeAsync(
request.ChallengeToken, AuthChallengePurpose.MfaVerification, cancellationToken);
var user = await userManager.FindByIdAsync(challenge.UserId.ToString())
?? throw new InvalidAuthChallengeException();
var recoveryCode = request.Code?.Trim();
var totpCode = NormalizeTotp(request.Code);
var verifiedByTotp = !string.IsNullOrWhiteSpace(totpCode) &&
await userManager.VerifyTwoFactorTokenAsync(
user, TokenOptions.DefaultAuthenticatorProvider, totpCode);
var verifiedByRecoveryCode = !verifiedByTotp &&
!string.IsNullOrWhiteSpace(recoveryCode) &&
(await userManager.RedeemTwoFactorRecoveryCodeAsync(user, recoveryCode)).Succeeded;
if (!verifiedByTotp && !verifiedByRecoveryCode)
{
await AddSecurityAuditAsync(
user.Id, challenge.TenantId, "auth.mfa.verification_denied", "invalid_code",
request.IpAddress, request.UserAgent, cancellationToken);
throw new InvalidCredentialsException("invalid_mfa_code");
}
await ConsumeChallengeAsync(challenge, cancellationToken);
await AddSecurityAuditAsync(
user.Id, challenge.TenantId, "auth.mfa.verified",
verifiedByRecoveryCode ? "recovery_code" : "totp",
request.IpAddress, request.UserAgent, cancellationToken);
return await IssueFromChallengeAsync(
challenge, user, request.IpAddress, request.UserAgent, cancellationToken);
}
public async Task<AuthenticationResult> ChangeRequiredPasswordAsync(
PasswordChangeChallengeRequest request,
CancellationToken cancellationToken = default)
{
var challenge = await FindChallengeAsync(
request.ChallengeToken, AuthChallengePurpose.PasswordChange, cancellationToken);
var user = await userManager.FindByIdAsync(challenge.UserId.ToString())
?? throw new InvalidAuthChallengeException();
var resetToken = await userManager.GeneratePasswordResetTokenAsync(user);
var reset = await userManager.ResetPasswordAsync(user, resetToken, request.NewPassword);
if (!reset.Succeeded)
{
throw new InvalidCredentialsException("invalid_new_password");
}
user.ForcePasswordChange = false;
var updated = await userManager.UpdateAsync(user);
if (!updated.Succeeded)
{
throw new InvalidOperationException("Unable to clear the password-change requirement.");
}
await sessionStore.RevokeAllAsync(user.Id, "password_changed", cancellationToken);
await ConsumeChallengeAsync(challenge, cancellationToken);
await AddSecurityAuditAsync(
user.Id, challenge.TenantId, "auth.password.changed", null,
request.IpAddress, request.UserAgent, cancellationToken);
return await CompleteSuccessfulLoginAsync(
challenge.Realm, challenge.TenantId, user, challenge.Provider, user.Email ?? user.Phone ?? user.Id.ToString(),
request.IpAddress, request.UserAgent, cancellationToken);
}
private async Task<AuthenticationResult> IssueFromChallengeAsync(
AuthChallenge challenge,
User user,
string? ipAddress,
string? userAgent,
CancellationToken cancellationToken)
{
if (!await HasBackendPermissionsAsync(
challenge.Realm, challenge.TenantId, user.Id, cancellationToken))
{
throw new InvalidAuthChallengeException("backend_access_revoked");
}
Tenant? tenant = null;
TenantMembership? membership = null;
if (challenge.Realm == AuthRealm.Tenant && challenge.TenantId.HasValue)
{
tenant = await dbContext.Tenants.SingleOrDefaultAsync(
item => item.Id == challenge.TenantId.Value && item.Status == TenantStatus.Active, cancellationToken);
membership = await FindActiveMembershipAsync(challenge.TenantId.Value, user.Id, cancellationToken);
if (tenant is null || membership is null)
{
throw new TenantAccessDeniedException();
}
}
return await IssueAuthenticatedResultAsync(
user, challenge.Realm, tenant, membership, challenge.Provider,
mfaSatisfied: true, null, ipAddress, userAgent, cancellationToken);
}
private async Task<AuthChallenge> FindChallengeAsync(
string token,
AuthChallengePurpose purpose,
CancellationToken cancellationToken)
{
var tokenHash = HashChallengeToken(token);
var now = DateTimeOffset.UtcNow;
return await dbContext.AuthChallenges.SingleOrDefaultAsync(
item => item.TokenHash == tokenHash && item.Purpose == purpose &&
item.ConsumedAt == null && item.ExpiresAt > now &&
dbContext.Users.Any(user =>
user.Id == item.UserId && user.Status == UserStatus.Active &&
user.SecurityStamp == item.SecurityStamp),
cancellationToken)
?? throw new InvalidAuthChallengeException();
}
private async Task ConsumeChallengeAsync(AuthChallenge challenge, CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
var consumed = await dbContext.AuthChallenges
.Where(item => item.Id == challenge.Id && item.ConsumedAt == null && item.ExpiresAt > now)
.ExecuteUpdateAsync(setters => setters.SetProperty(item => item.ConsumedAt, now), cancellationToken);
if (consumed != 1)
{
throw new InvalidAuthChallengeException();
}
}
private async Task<AuthenticationResult> CompleteSuccessfulLoginAsync(
AuthRealm realm,
Guid? tenantId,
User user,
string provider,
string identifier,
@@ -245,36 +387,96 @@ public sealed class AuthService(
string? userAgent,
CancellationToken cancellationToken)
{
var membership = await FindActiveMembershipAsync(tenantId, user.Id, cancellationToken);
if (membership is null)
if (user.Status != UserStatus.Active)
{
await AddLoginEventAsync(
tenantId,
user.Id,
provider,
identifier,
AuthLoginResult.Failed,
"tenant_access_denied",
ipAddress,
userAgent,
cancellationToken);
tenantId, user.Id, provider, identifier, AuthLoginResult.Failed,
"user_disabled", ipAddress, userAgent, cancellationToken);
throw new InvalidCredentialsException();
}
TenantMembership? membership = null;
Tenant? tenant = null;
if (realm == AuthRealm.Tenant && tenantId.HasValue)
{
membership = await FindActiveMembershipAsync(tenantId.Value, user.Id, cancellationToken);
tenant = await dbContext.Tenants.SingleOrDefaultAsync(
item => item.Id == tenantId.Value && item.Status == TenantStatus.Active, cancellationToken);
if (membership is null || tenant is null)
{
await AddLoginEventAsync(tenantId, user.Id, provider, identifier, AuthLoginResult.Failed,
"tenant_access_denied", ipAddress, userAgent, cancellationToken);
throw new TenantAccessDeniedException();
}
}
else if (realm == AuthRealm.Platform)
{
if (!await HasBackendPermissionsAsync(realm, tenantId, user.Id, cancellationToken))
{
await AddLoginEventAsync(
null, user.Id, provider, identifier, AuthLoginResult.Failed,
"platform_access_denied", ipAddress, userAgent, cancellationToken);
throw new TenantAccessDeniedException();
}
}
else
{
throw new TenantAccessDeniedException();
}
var tenant = await dbContext.Tenants.FindAsync([tenantId], cancellationToken)
?? throw new TenantAccessDeniedException();
var tokens = await sessionService.IssueAsync(
user.Id,
user.Phone,
user.Email,
membership,
provider,
ipAddress,
userAgent,
if (user.ForcePasswordChange)
{
return await CreateChallengeResultAsync(
user, realm, tenantId, AuthChallengePurpose.PasswordChange, provider,
AuthenticationStatus.PasswordChangeRequired, ipAddress, userAgent, cancellationToken);
}
var requiresMfa = await HasBackendPermissionsAsync(realm, tenantId, user.Id, cancellationToken);
if (requiresMfa)
{
var hasAuthenticator = user.TwoFactorEnabled &&
!string.IsNullOrWhiteSpace(await userManager.GetAuthenticatorKeyAsync(user));
return await CreateChallengeResultAsync(
user, realm, tenantId,
hasAuthenticator ? AuthChallengePurpose.MfaVerification : AuthChallengePurpose.MfaEnrollment,
provider,
hasAuthenticator ? AuthenticationStatus.MfaRequired : AuthenticationStatus.MfaEnrollmentRequired,
ipAddress, userAgent, cancellationToken);
}
return await IssueAuthenticatedResultAsync(
user, realm, tenant, membership, provider, mfaSatisfied: false,
identifier, ipAddress, userAgent, cancellationToken);
}
private async Task<AuthenticationResult> IssueAuthenticatedResultAsync(
User user,
AuthRealm realm,
Tenant? tenant,
TenantMembership? membership,
string provider,
bool mfaSatisfied,
string? identifier,
string? ipAddress,
string? userAgent,
CancellationToken cancellationToken)
{
var tokens = await sessionStore.IssueAsync(
new AuthSessionIssueRequest(
user.Id,
user.Phone,
user.Email,
user.SecurityStamp ?? string.Empty,
realm,
tenant?.Id,
provider,
mfaSatisfied,
ipAddress,
userAgent),
cancellationToken);
await AddLoginEventAsync(
tenantId,
tenant?.Id,
user.Id,
provider,
identifier,
@@ -284,28 +486,28 @@ public sealed class AuthService(
userAgent,
cancellationToken);
return new AuthenticatedUser(
user.Id,
user.Phone,
user.Email,
user.Name,
new TenantMembershipSummary(
tenant.Id,
tenant.Name,
membership.Role,
membership.Status),
tokens);
var tenantSummary = tenant is not null && membership is not null
? new TenantMembershipSummary(tenant.Id, tenant.Name, membership.Role, membership.Status)
: null;
return new AuthenticationResult(
AuthenticationStatus.Authenticated,
new AuthenticatedUser(user.Id, user.Phone, user.Email, user.Name, realm, tenantSummary, tokens));
}
private async Task<AuthenticatedUser> LoginWithWechatAsync(
private async Task<AuthenticationResult> LoginWithWechatAsync(
WechatLoginRequest request,
string provider,
IReadOnlyList<string> providerAliases,
Func<WechatProviderOptions, string, CancellationToken, Task<WechatIdentity>> exchangeCodeAsync,
CancellationToken cancellationToken)
{
if (request.Realm != AuthRealm.Tenant || !request.TenantId.HasValue)
{
throw new InvalidCredentialsException("tenant_realm_required_for_wechat");
}
var config = await LoadWechatProviderOptionsAsync(
request.TenantId,
request.TenantId.Value,
provider,
providerAliases,
cancellationToken);
@@ -338,11 +540,12 @@ public sealed class AuthService(
identity,
cancellationToken);
await EnsureTenantMembershipAsync(
request.TenantId,
request.TenantId.Value,
user.Id,
cancellationToken);
return await CompleteSuccessfulLoginAsync(
request.Realm,
request.TenantId,
user,
provider,
@@ -438,7 +641,6 @@ public sealed class AuthService(
existingIdentity.UserId = user.Id;
existingIdentity.OpenId = wechatIdentity.OpenId;
existingIdentity.UnionId = wechatIdentity.UnionId;
existingIdentity.SecretPayload = CreateWechatSecretPayload(appId, wechatIdentity);
await dbContext.SaveChangesAsync(cancellationToken);
return user;
@@ -521,8 +723,111 @@ public sealed class AuthService(
.FirstOrDefaultAsync(cancellationToken);
}
private async Task<AuthenticationResult> CreateChallengeResultAsync(
User user,
AuthRealm realm,
Guid? tenantId,
AuthChallengePurpose purpose,
string provider,
AuthenticationStatus status,
string? ipAddress,
string? userAgent,
CancellationToken cancellationToken)
{
var realmCode = realm == AuthRealm.Tenant ? "t" : "p";
var tenantCode = tenantId?.ToString("N") ?? "-";
var rawToken = $"c1.{realmCode}.{tenantCode}.{Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(48))}";
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(5);
dbContext.AuthChallenges.Add(new AuthChallenge
{
UserId = user.Id,
Realm = realm,
TenantId = tenantId,
Purpose = purpose,
TokenHash = HashChallengeToken(rawToken),
SecurityStamp = user.SecurityStamp ?? string.Empty,
Provider = provider,
ExpiresAt = expiresAt,
IpAddress = ipAddress,
UserAgent = userAgent
});
await dbContext.SaveChangesAsync(cancellationToken);
await AddSecurityAuditAsync(
user.Id, tenantId, "auth.challenge.issued", status.ToString(),
ipAddress, userAgent, cancellationToken);
return new AuthenticationResult(status, ChallengeToken: rawToken, ChallengeExpiresAt: expiresAt);
}
private async Task<bool> HasBackendPermissionsAsync(
AuthRealm realm,
Guid? tenantId,
Guid userId,
CancellationToken cancellationToken)
{
if (realm == AuthRealm.Platform)
{
return await (
from userRole in dbContext.PlatformBackendUserRoles
join role in dbContext.PlatformBackendRoles on userRole.RoleId equals role.Id
join binding in dbContext.PlatformBackendRolePermissions on role.Id equals binding.RoleId
join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code
where userRole.UserId == userId &&
role.Status == Tiku.Domain.Operations.BackendRoleStatus.Active &&
(permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Platform ||
permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Both)
select permission.Id).AnyAsync(cancellationToken);
}
if (!tenantId.HasValue)
{
return false;
}
return await (
from userRole in dbContext.TenantBackendUserRoles
join role in dbContext.TenantBackendRoles on userRole.RoleId equals role.Id
join binding in dbContext.TenantBackendRolePermissions on role.Id equals binding.RoleId
join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code
where userRole.TenantId == tenantId.Value && userRole.UserId == userId &&
binding.TenantId == tenantId.Value &&
role.Status == Tiku.Domain.Operations.BackendRoleStatus.Active &&
(permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Tenant ||
permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Both)
select permission.Id).AnyAsync(cancellationToken);
}
private static string HashChallengeToken(string token) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token ?? string.Empty))).ToLowerInvariant();
private static string NormalizeTotp(string? code) =>
(code ?? string.Empty).Replace(" ", string.Empty, StringComparison.Ordinal)
.Replace("-", string.Empty, StringComparison.Ordinal);
private async Task AddSecurityAuditAsync(
Guid userId,
Guid? tenantId,
string action,
string? reason,
string? ipAddress,
string? userAgent,
CancellationToken cancellationToken)
{
dbContext.AuditLogs.Add(new Tiku.Domain.Operations.AuditLog
{
TenantId = tenantId,
ActorUserId = userId,
Action = action,
TargetType = "user",
TargetId = userId.ToString(),
Details = JsonSerializer.SerializeToElement(new { reason }),
IpAddress = ipAddress,
UserAgent = userAgent
});
await dbContext.SaveChangesAsync(cancellationToken);
}
private async Task AddLoginEventAsync(
Guid tenantId,
Guid? tenantId,
Guid? userId,
string provider,
string? identifier,
@@ -547,15 +852,6 @@ public sealed class AuthService(
await dbContext.SaveChangesAsync(cancellationToken);
}
private static bool TryGetPasswordHash(JsonElement secretPayload, out string passwordHash)
{
passwordHash = string.Empty;
return secretPayload.ValueKind == JsonValueKind.Object &&
secretPayload.TryGetProperty("passwordHash", out var property) &&
property.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(passwordHash = property.GetString() ?? string.Empty);
}
private static string? GetJsonString(JsonElement element, params string[] names)
{
if (element.ValueKind != JsonValueKind.Object)
@@ -578,19 +874,13 @@ public sealed class AuthService(
private static JsonElement CreateWechatRawProfile(WechatIdentity identity)
{
using var document = JsonDocument.Parse(identity.RawJson);
return document.RootElement.Clone();
return JsonSerializer.SerializeToElement(new
{
openId = identity.OpenId,
unionId = identity.UnionId,
nickname = identity.Nickname,
avatarUrl = identity.AvatarUrl
});
}
private static JsonElement CreateWechatSecretPayload(string appId, WechatIdentity identity)
{
var payload = new
{
appId,
sessionKey = identity.SessionKey,
raw = JsonSerializer.Deserialize<JsonElement>(identity.RawJson),
updatedAt = DateTimeOffset.UtcNow
};
return JsonSerializer.SerializeToElement(payload);
}
}

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.Domain.Tenancy;
namespace Tiku.Infrastructure.Auth;
@@ -13,6 +14,7 @@ internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IId
{
"password" => await authService.LoginWithPasswordAsync(
new PasswordLoginRequest(
AuthRealm.Tenant,
request.TenantId,
request.Identifier,
request.Secret,
@@ -21,6 +23,7 @@ internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IId
cancellationToken),
"sms" => await authService.LoginWithSmsAsync(
new SmsLoginRequest(
AuthRealm.Tenant,
request.TenantId,
request.Identifier,
request.Secret,
@@ -29,6 +32,7 @@ internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IId
cancellationToken),
"wechat_web" => await authService.LoginWithWechatWebAsync(
new WechatLoginRequest(
AuthRealm.Tenant,
request.TenantId,
request.Secret,
request.IpAddress,
@@ -36,6 +40,7 @@ internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IId
cancellationToken),
"wechat_miniapp" => await authService.LoginWithWechatMiniAppAsync(
new WechatLoginRequest(
AuthRealm.Tenant,
request.TenantId,
request.Secret,
request.IpAddress,
@@ -44,11 +49,13 @@ internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IId
_ => throw new AuthProviderNotConfiguredException(provider)
};
var user = authenticated.User ?? throw new InvalidAuthChallengeException("interactive_authentication_required");
return new IdentityProviderResult(
provider,
authenticated.UserId.ToString("N"),
authenticated.Phone,
authenticated.Email,
authenticated.Name);
user.UserId.ToString("N"),
user.Phone,
user.Email,
user.Name);
}
}

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 string Hash(Guid tenantId, string phone, SmsPurpose purpose, string code)
public static string Hash(
Guid tenantId,
string phone,
SmsPurpose purpose,
string code,
string pepper)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pepper);
var normalized = $"{tenantId:N}:{NormalizePhone(phone)}:{purpose}:{code.Trim()}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalized));
var hash = HMACSHA256.HashData(
Encoding.UTF8.GetBytes(pepper),
Encoding.UTF8.GetBytes(normalized));
return Convert.ToHexString(hash).ToLowerInvariant();
}
public static string HashScope(string value, string pepper)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pepper);
var hash = HMACSHA256.HashData(
Encoding.UTF8.GetBytes(pepper),
Encoding.UTF8.GetBytes(value.Trim().ToLowerInvariant()));
return Convert.ToHexString(hash).ToLowerInvariant();
}

View File

@@ -1,6 +1,10 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Domain.Common;
using Microsoft.Extensions.Options;
using Tiku.Application.Auth;
using Tiku.Domain.Common;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
@@ -8,44 +12,33 @@ namespace Tiku.Infrastructure.Auth;
public sealed class SmsVerificationService(
TikuDbContext dbContext,
ISmsProvider smsProvider) : ISmsVerificationService
ISmsProvider smsProvider,
IOptions<SmsSecurityOptions> securityOptions) : ISmsVerificationService
{
private const int MaxPhoneRequestsPerHour = 5;
private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(10);
private static readonly SemaphoreSlim InMemoryRateLimitLock = new(1, 1);
private readonly SmsSecurityOptions options = securityOptions.Value;
public async Task<SmsSendResult> CreateCodeAsync(
SendSmsCodeRequest request,
CancellationToken cancellationToken = default)
{
EnsureValidOptions();
var phone = SmsCodeHashing.NormalizePhone(request.Phone);
var bucketStart = TruncateToHour(DateTimeOffset.UtcNow);
var scopeHash = SmsCodeHashing.Hash(request.TenantId, phone, request.Purpose, "phone-bucket");
var rateLimit = await dbContext.SmsSendRateLimits.FindAsync(
[request.TenantId, SmsRateLimitDimension.Phone, scopeHash, bucketStart],
cancellationToken);
var now = DateTimeOffset.UtcNow;
await ConsumeRateLimitsAsync(request, phone, now, cancellationToken);
if (rateLimit is null)
{
rateLimit = new SmsSendRateLimit
{
TenantId = request.TenantId,
Dimension = SmsRateLimitDimension.Phone,
ScopeHash = scopeHash,
BucketStart = bucketStart
};
dbContext.SmsSendRateLimits.Add(rateLimit);
}
var code = RandomNumberGenerator
.GetInt32(100000, 1000000)
.ToString(CultureInfo.InvariantCulture);
var codeHash = SmsCodeHashing.Hash(
request.TenantId,
phone,
request.Purpose,
code,
options.CodePepper);
if (rateLimit.RequestCount >= MaxPhoneRequestsPerHour)
{
throw new SmsRateLimitedException();
}
rateLimit.RequestCount++;
rateLimit.UpdatedAt = DateTimeOffset.UtcNow;
var code = Random.Shared.Next(100000, 999999).ToString(System.Globalization.CultureInfo.InvariantCulture);
var codeHash = SmsCodeHashing.Hash(request.TenantId, phone, request.Purpose, code);
SmsProviderSendResult sendResult;
try
{
@@ -69,18 +62,38 @@ public sealed class SmsVerificationService(
CodeHash = codeHash,
Provider = "failed",
Status = SmsVerificationStatus.Failed,
ExpiresAt = DateTimeOffset.UtcNow,
ExpiresAt = now,
IpAddress = request.IpAddress,
UserAgent = request.UserAgent,
Metadata = JsonDefaults.Object()
});
dbContext.AuthLoginEvents.Add(new AuthLoginEvent
{
TenantId = request.TenantId,
Provider = "sms",
Identifier = phone,
Result = AuthLoginResult.Failed,
FailureCode = "sms_provider_send_failed",
IpAddress = request.IpAddress,
UserAgent = request.UserAgent
});
await dbContext.SaveChangesAsync(cancellationToken);
throw exception is SmsProviderException
? exception
: new SmsProviderException("SMS provider failed to send the verification code.", "sms_provider_send_failed", exception);
: new SmsProviderException(
"SMS provider failed to send the verification code.",
"sms_provider_send_failed",
exception);
}
await ExpirePreviousCodesAsync(
request.TenantId,
phone,
request.Purpose,
now,
cancellationToken);
var verification = new SmsVerificationCode
{
TenantId = request.TenantId,
@@ -89,12 +102,29 @@ public sealed class SmsVerificationService(
CodeHash = codeHash,
Provider = sendResult.Provider,
Status = SmsVerificationStatus.Sent,
ExpiresAt = DateTimeOffset.UtcNow.Add(CodeLifetime),
ExpiresAt = now.Add(CodeLifetime),
IpAddress = request.IpAddress,
UserAgent = request.UserAgent
};
dbContext.SmsVerificationCodes.Add(verification);
dbContext.AuthLoginEvents.Add(new AuthLoginEvent
{
TenantId = request.TenantId,
Provider = "sms",
Identifier = phone,
Result = AuthLoginResult.Sent,
IpAddress = request.IpAddress,
UserAgent = request.UserAgent,
Metadata = JsonSerializer.SerializeToElement(new
{
verificationId = verification.Id,
sendResult.Provider,
sendResult.Status,
sendResult.MessageId,
request.DeviceId
})
});
await dbContext.SaveChangesAsync(cancellationToken);
return new SmsSendResult(verification.Id, verification.ExpiresAt);
@@ -107,35 +137,346 @@ public sealed class SmsVerificationService(
string code,
CancellationToken cancellationToken = default)
{
EnsureValidOptions();
var normalizedPhone = SmsCodeHashing.NormalizePhone(phone);
var now = DateTimeOffset.UtcNow;
var codeHash = SmsCodeHashing.Hash(tenantId, normalizedPhone, purpose, code);
var codeHash = SmsCodeHashing.Hash(
tenantId,
normalizedPhone,
purpose,
code,
options.CodePepper);
var verification = await dbContext.SmsVerificationCodes
.AsNoTracking()
.Where(entity =>
entity.TenantId == tenantId &&
entity.Phone == normalizedPhone &&
entity.Purpose == purpose &&
entity.ConsumedAt == null)
entity.ConsumedAt == null &&
entity.Status == SmsVerificationStatus.Sent)
.OrderByDescending(entity => entity.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (verification is null ||
verification.ExpiresAt <= now ||
verification.Status != SmsVerificationStatus.Sent)
if (verification is null)
{
throw new InvalidCredentialsException("invalid_sms_code");
}
verification.Attempts++;
if (!string.Equals(verification.CodeHash, codeHash, StringComparison.Ordinal))
if (verification.ExpiresAt <= now)
{
await dbContext.SaveChangesAsync(cancellationToken);
await MarkExpiredAsync(verification.Id, now, cancellationToken);
throw new InvalidCredentialsException("invalid_sms_code");
}
if (HashesMatch(verification.CodeHash, codeHash))
{
var consumed = await TryConsumeAsync(verification.Id, now, cancellationToken);
if (consumed)
{
return;
}
throw new InvalidCredentialsException("invalid_sms_code");
}
await RecordFailedAttemptAsync(verification.Id, now, cancellationToken);
throw new InvalidCredentialsException("invalid_sms_code");
}
private async Task ConsumeRateLimitsAsync(
SendSmsCodeRequest request,
string phone,
DateTimeOffset now,
CancellationToken cancellationToken)
{
var limits = BuildRateLimits(request, phone);
var bucketStart = TruncateToHour(now);
if (!dbContext.Database.IsRelational())
{
await ConsumeInMemoryRateLimitsAsync(limits, request.TenantId, bucketStart, now, cancellationToken);
return;
}
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
foreach (var limit in limits)
{
var dimension = ToSnakeCase(limit.Dimension);
var affected = await dbContext.Database.ExecuteSqlInterpolatedAsync($$"""
INSERT INTO sms_send_rate_limits
(tenant_id, dimension, scope_hash, bucket_start, request_count, updated_at)
VALUES
({{request.TenantId}}, {{dimension}}, {{limit.ScopeHash}}, {{bucketStart}}, 1, {{now}})
ON CONFLICT (tenant_id, dimension, scope_hash, bucket_start)
DO UPDATE SET
request_count = sms_send_rate_limits.request_count + 1,
updated_at = EXCLUDED.updated_at
WHERE sms_send_rate_limits.request_count < {{limit.Maximum}}
""", cancellationToken);
if (affected == 0)
{
await transaction.RollbackAsync(cancellationToken);
throw new SmsRateLimitedException();
}
}
await transaction.CommitAsync(cancellationToken);
}
private async Task ConsumeInMemoryRateLimitsAsync(
IReadOnlyCollection<RateLimitSpec> limits,
Guid tenantId,
DateTimeOffset bucketStart,
DateTimeOffset now,
CancellationToken cancellationToken)
{
await InMemoryRateLimitLock.WaitAsync(cancellationToken);
try
{
var counters = new List<(RateLimitSpec Limit, SmsSendRateLimit? Counter)>();
foreach (var limit in limits)
{
var counter = await dbContext.SmsSendRateLimits.FindAsync(
[tenantId, limit.Dimension, limit.ScopeHash, bucketStart],
cancellationToken);
if (counter?.RequestCount >= limit.Maximum)
{
throw new SmsRateLimitedException();
}
counters.Add((limit, counter));
}
foreach (var (limit, existingCounter) in counters)
{
var counter = existingCounter;
if (counter is null)
{
counter = new SmsSendRateLimit
{
TenantId = tenantId,
Dimension = limit.Dimension,
ScopeHash = limit.ScopeHash,
BucketStart = bucketStart
};
dbContext.SmsSendRateLimits.Add(counter);
}
counter.RequestCount++;
counter.UpdatedAt = now;
}
await dbContext.SaveChangesAsync(cancellationToken);
}
finally
{
InMemoryRateLimitLock.Release();
}
}
private IReadOnlyList<RateLimitSpec> BuildRateLimits(SendSmsCodeRequest request, string phone)
{
var limits = new List<RateLimitSpec>
{
CreateLimit(SmsRateLimitDimension.Tenant, $"tenant:{request.TenantId:N}", options.TenantRequestsPerHour),
CreateLimit(SmsRateLimitDimension.Phone, $"phone:{request.TenantId:N}:{phone}", options.PhoneRequestsPerHour)
};
if (!string.IsNullOrWhiteSpace(request.IpAddress))
{
limits.Add(CreateLimit(
SmsRateLimitDimension.Ip,
$"ip:{request.IpAddress.Trim()}",
options.IpRequestsPerHour));
}
var deviceKey = string.IsNullOrWhiteSpace(request.DeviceId)
? request.UserAgent
: request.DeviceId;
if (!string.IsNullOrWhiteSpace(deviceKey))
{
limits.Add(CreateLimit(
SmsRateLimitDimension.Device,
$"device:{deviceKey.Trim()}",
options.DeviceRequestsPerHour));
}
return limits;
}
private RateLimitSpec CreateLimit(SmsRateLimitDimension dimension, string scope, int maximum)
{
return new RateLimitSpec(
dimension,
SmsCodeHashing.HashScope(scope, options.CodePepper),
maximum);
}
private async Task ExpirePreviousCodesAsync(
Guid tenantId,
string phone,
SmsPurpose purpose,
DateTimeOffset now,
CancellationToken cancellationToken)
{
var query = dbContext.SmsVerificationCodes.Where(entity =>
entity.TenantId == tenantId &&
entity.Phone == phone &&
entity.Purpose == purpose &&
entity.ConsumedAt == null &&
(entity.Status == SmsVerificationStatus.Pending ||
entity.Status == SmsVerificationStatus.Sent));
if (dbContext.Database.IsRelational())
{
await query.ExecuteUpdateAsync(
setters => setters
.SetProperty(entity => entity.Status, SmsVerificationStatus.Expired)
.SetProperty(entity => entity.ExpiresAt, now),
cancellationToken);
return;
}
foreach (var verification in await query.ToListAsync(cancellationToken))
{
verification.Status = SmsVerificationStatus.Expired;
verification.ExpiresAt = now;
}
}
private async Task MarkExpiredAsync(Guid id, DateTimeOffset now, CancellationToken cancellationToken)
{
if (dbContext.Database.IsRelational())
{
await dbContext.SmsVerificationCodes
.Where(entity =>
entity.Id == id &&
entity.Status == SmsVerificationStatus.Sent &&
entity.ConsumedAt == null &&
entity.ExpiresAt <= now)
.ExecuteUpdateAsync(
setters => setters.SetProperty(entity => entity.Status, SmsVerificationStatus.Expired),
cancellationToken);
return;
}
var verification = await dbContext.SmsVerificationCodes.FindAsync([id], cancellationToken);
if (verification is not null &&
verification.Status == SmsVerificationStatus.Sent &&
verification.ConsumedAt is null &&
verification.ExpiresAt <= now)
{
verification.Status = SmsVerificationStatus.Expired;
await dbContext.SaveChangesAsync(cancellationToken);
}
}
private async Task<bool> TryConsumeAsync(Guid id, DateTimeOffset now, CancellationToken cancellationToken)
{
if (dbContext.Database.IsRelational())
{
var affected = await dbContext.SmsVerificationCodes
.Where(entity =>
entity.Id == id &&
entity.Status == SmsVerificationStatus.Sent &&
entity.ConsumedAt == null &&
entity.ExpiresAt > now &&
entity.Attempts < options.MaxVerificationAttempts)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(entity => entity.Status, SmsVerificationStatus.Verified)
.SetProperty(entity => entity.ConsumedAt, now),
cancellationToken);
return affected == 1;
}
var verification = await dbContext.SmsVerificationCodes.FindAsync([id], cancellationToken);
if (verification is null ||
verification.Status != SmsVerificationStatus.Sent ||
verification.ConsumedAt is not null ||
verification.ExpiresAt <= now ||
verification.Attempts >= options.MaxVerificationAttempts)
{
return false;
}
verification.Status = SmsVerificationStatus.Verified;
verification.ConsumedAt = now;
await dbContext.SaveChangesAsync(cancellationToken);
return true;
}
private async Task RecordFailedAttemptAsync(Guid id, DateTimeOffset now, CancellationToken cancellationToken)
{
if (dbContext.Database.IsRelational())
{
await dbContext.SmsVerificationCodes
.Where(entity =>
entity.Id == id &&
entity.Status == SmsVerificationStatus.Sent &&
entity.ConsumedAt == null &&
entity.ExpiresAt > now &&
entity.Attempts < options.MaxVerificationAttempts)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(entity => entity.Attempts, entity => entity.Attempts + 1)
.SetProperty(
entity => entity.Status,
entity => entity.Attempts + 1 >= options.MaxVerificationAttempts
? SmsVerificationStatus.Blocked
: SmsVerificationStatus.Sent),
cancellationToken);
return;
}
var verification = await dbContext.SmsVerificationCodes.FindAsync([id], cancellationToken);
if (verification is null ||
verification.Status != SmsVerificationStatus.Sent ||
verification.ConsumedAt is not null ||
verification.ExpiresAt <= now ||
verification.Attempts >= options.MaxVerificationAttempts)
{
return;
}
verification.Attempts++;
if (verification.Attempts >= options.MaxVerificationAttempts)
{
verification.Status = SmsVerificationStatus.Blocked;
}
await dbContext.SaveChangesAsync(cancellationToken);
}
private void EnsureValidOptions()
{
if (!SmsSecurityOptions.BeValid(options))
{
throw new InvalidOperationException(
$"{SmsSecurityOptions.SectionName} must contain a pepper of at least 32 characters, " +
"exactly five verification attempts, and positive rate limits.");
}
}
private static bool HashesMatch(string expected, string actual)
{
try
{
return CryptographicOperations.FixedTimeEquals(
Convert.FromHexString(expected),
Convert.FromHexString(actual));
}
catch (FormatException)
{
return false;
}
}
private static string ToSnakeCase(SmsRateLimitDimension dimension)
{
return dimension.ToString().ToLowerInvariant();
}
private static DateTimeOffset TruncateToHour(DateTimeOffset value)
@@ -149,4 +490,9 @@ public sealed class SmsVerificationService(
0,
value.Offset);
}
private sealed record RateLimitSpec(
SmsRateLimitDimension Dimension,
string ScopeHash,
int Maximum);
}

View File

@@ -1,15 +1,13 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Tiku.Application.Auth;
using Tiku.Application.Security;
using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Auth;
public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
public sealed class TokenService(IOptions<JwtOptions> options, IJwtKeyRing keyRing) : ITokenService
{
private readonly JwtOptions options = options.Value;
@@ -18,17 +16,30 @@ public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
Guid sessionId,
string? phone,
string? email,
TenantMembership membership)
AuthRealm realm,
Guid? tenantId,
bool mfaSatisfied)
{
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(options.AccessTokenMinutes);
var claims = new List<Claim>
{
new(TikuClaimTypes.UserId, userId.ToString()),
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(TikuClaimTypes.SessionId, sessionId.ToString()),
new(TikuClaimTypes.TenantId, membership.TenantId.ToString()),
new(TikuClaimTypes.TenantRole, membership.Role.ToString())
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")),
new(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64),
new(TikuClaimTypes.Realm, realm.ToString().ToLowerInvariant())
};
if (tenantId.HasValue)
{
claims.Add(new Claim(TikuClaimTypes.TenantId, tenantId.Value.ToString()));
}
if (mfaSatisfied)
{
claims.Add(new Claim(TikuClaimTypes.Mfa, "mfa"));
}
if (!string.IsNullOrWhiteSpace(phone))
{
claims.Add(new Claim(TikuClaimTypes.Phone, phone));
@@ -39,15 +50,12 @@ public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
claims.Add(new Claim(TikuClaimTypes.Email, email));
}
var credentials = new SigningCredentials(
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.SigningKey)),
SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
options.Issuer,
options.Audience,
claims,
expires: expiresAt.UtcDateTime,
signingCredentials: credentials);
signingCredentials: keyRing.SigningCredentials);
return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt);
}

View File

@@ -1,6 +1,7 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Backoffice;
using Tiku.Application.Security;
using Tiku.Domain.Common;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
@@ -14,19 +15,23 @@ internal sealed class BackofficeService(
{
private static readonly BuiltinPermission[] BuiltinPermissions =
[
new("tenant:dashboard:view", "租户总览", BackendPermissionArea.Tenant, "tenant_dashboard"),
new("tenant:staff:manage", "租户员工管理", BackendPermissionArea.Tenant, "tenant_staff"),
new("tenant:role:manage", "租户角色权限管理", BackendPermissionArea.Tenant, "tenant_staff"),
new("tenant:student:manage", "学生与班级管理", BackendPermissionArea.Tenant, "tenant_student"),
new("tenant:content:manage", "租户内容管理", BackendPermissionArea.Tenant, "tenant_content"),
new("tenant:provider:manage", "租户外部服务配置", BackendPermissionArea.Tenant, "tenant_provider"),
new("tenant:commerce:operate", "租户交易运营", BackendPermissionArea.Tenant, "tenant_commerce"),
new("platform:dashboard:view", "平台总览", BackendPermissionArea.Platform, "platform_dashboard"),
new("platform:tenant:manage", "平台租户管理", BackendPermissionArea.Platform, "platform_tenant"),
new("platform:staff:manage", "平台员工管理", BackendPermissionArea.Platform, "platform_staff"),
new("platform:role:manage", "平台角色权限管理", BackendPermissionArea.Platform, "platform_staff"),
new("platform:question-bank:manage", "平台公共题库运营", BackendPermissionArea.Platform, "platform_content"),
new("platform:audit:view", "平台审计查询", BackendPermissionArea.Platform, "platform_audit"),
new(BackendPermissions.TenantDashboardView, "租户总览", BackendPermissionArea.Tenant, "tenant_dashboard"),
new(BackendPermissions.TenantStaffManage, "租户员工管理", BackendPermissionArea.Tenant, "tenant_staff"),
new(BackendPermissions.TenantRoleManage, "租户角色权限管理", BackendPermissionArea.Tenant, "tenant_staff"),
new(BackendPermissions.TenantStudentManage, "学生与班级管理", BackendPermissionArea.Tenant, "tenant_student"),
new(BackendPermissions.TenantContentManage, "租户内容管理", BackendPermissionArea.Tenant, "tenant_content"),
new(BackendPermissions.TenantSettingsManage, "租户设置管理", BackendPermissionArea.Tenant, "tenant_settings"),
new(BackendPermissions.TenantProviderManage, "租户外部服务配置", BackendPermissionArea.Tenant, "tenant_provider"),
new(BackendPermissions.TenantCommerceOperate, "租户交易运营", BackendPermissionArea.Tenant, "tenant_commerce"),
new(BackendPermissions.TenantCrmManage, "租户客户管理", BackendPermissionArea.Tenant, "tenant_crm"),
new(BackendPermissions.TenantCommissionManage, "租户佣金管理", BackendPermissionArea.Tenant, "tenant_commission"),
new(BackendPermissions.TenantJobManage, "租户任务管理", BackendPermissionArea.Tenant, "tenant_job"),
new(BackendPermissions.PlatformDashboardView, "平台总览", BackendPermissionArea.Platform, "platform_dashboard"),
new(BackendPermissions.PlatformTenantManage, "平台租户管理", BackendPermissionArea.Platform, "platform_tenant"),
new(BackendPermissions.PlatformStaffManage, "平台员工管理", BackendPermissionArea.Platform, "platform_staff"),
new(BackendPermissions.PlatformRoleManage, "平台角色权限管理", BackendPermissionArea.Platform, "platform_staff"),
new(BackendPermissions.PlatformQuestionBankManage, "平台公共题库运营", BackendPermissionArea.Platform, "platform_content"),
new(BackendPermissions.PlatformAuditView, "平台审计查询", BackendPermissionArea.Platform, "platform_audit"),
new("commerce:refund:approve", "退款审核", BackendPermissionArea.Both, "commerce"),
new("commerce:reconciliation:manage", "对账管理", BackendPermissionArea.Both, "commerce"),
new("commerce:adjustment:manage", "调账管理", BackendPermissionArea.Both, "commerce")
@@ -47,6 +52,43 @@ internal sealed class BackofficeService(
new("platform.audit", null, "平台审计", BackendPermissionArea.Platform, "/platform/audit", "platform:audit:view", 50)
];
public async Task<BackofficeUiBootstrap> GetTenantUiBootstrapAsync(
CurrentAccessSnapshot access,
CancellationToken cancellationToken = default)
{
if (!access.IsUserActive || !access.IsCurrentTenantMember ||
access.UserId is null || access.TenantId is null)
{
throw new BackofficeException("Tenant backoffice access is denied.", "tenant_access_denied");
}
await EnsureCatalogAsync(cancellationToken);
var permissionCodes = access.TenantPermissions.Order(StringComparer.Ordinal).ToArray();
var menus = await LoadEffectiveMenusAsync(
BackendPermissionArea.Tenant,
permissionCodes,
cancellationToken);
return new BackofficeUiBootstrap(permissionCodes, menus);
}
public async Task<BackofficeUiBootstrap> GetPlatformUiBootstrapAsync(
CurrentAccessSnapshot access,
CancellationToken cancellationToken = default)
{
if (!access.IsUserActive || access.UserId is null || access.PlatformPermissions.Count == 0)
{
throw new BackofficeException("Platform backoffice access is denied.", "platform_access_denied");
}
await EnsureCatalogAsync(cancellationToken);
var permissionCodes = access.PlatformPermissions.Order(StringComparer.Ordinal).ToArray();
var menus = await LoadEffectiveMenusAsync(
BackendPermissionArea.Platform,
permissionCodes,
cancellationToken);
return new BackofficeUiBootstrap(permissionCodes, menus);
}
public async Task<BackofficeBootstrap> GetTenantBootstrapAsync(
BackofficeActor actor,
CancellationToken cancellationToken = default)
@@ -193,6 +235,21 @@ internal sealed class BackofficeService(
{
var tenantId = RequireTenantAdmin(actor);
var roleIds = command.RoleIds.Distinct().ToArray();
var ownerRoleId = await dbContext.TenantBackendRoles
.Where(item => item.TenantId == tenantId && item.Code == "tenant_owner" && item.IsSystem)
.Select(item => (Guid?)item.Id)
.SingleOrDefaultAsync(cancellationToken);
var isActiveOwner = await dbContext.TenantMemberships.AnyAsync(
item => item.TenantId == tenantId &&
item.UserId == command.UserId &&
item.Role == TenantRole.TenantOwner &&
item.Status == MembershipStatus.Active,
cancellationToken);
if (isActiveOwner && ownerRoleId.HasValue && !roleIds.Contains(ownerRoleId.Value))
{
throw new BackofficeException("Tenant owner system role cannot be removed.", "system_role_locked");
}
var count = await dbContext.TenantBackendRoles.CountAsync(
item => item.TenantId == tenantId && roleIds.Contains(item.Id) && item.Status == BackendRoleStatus.Active,
cancellationToken);
@@ -329,6 +386,21 @@ internal sealed class BackofficeService(
}
}
private async Task<BackofficeMenuItem[]> LoadEffectiveMenusAsync(
BackendPermissionArea area,
IReadOnlyCollection<string> permissionCodes,
CancellationToken cancellationToken)
{
var codes = permissionCodes.ToArray();
var menus = await dbContext.BackendMenus.AsNoTracking()
.Where(item => item.IsActive && item.Area == area &&
(item.PermissionCode == null || codes.Contains(item.PermissionCode)))
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Code)
.ToArrayAsync(cancellationToken);
return menus.Select(ToMenuItem).ToArray();
}
private async Task ValidateMenuCodesAsync(string[] codes, BackendPermissionArea area, CancellationToken cancellationToken)
{
var count = await dbContext.BackendMenus.CountAsync(

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 Microsoft.EntityFrameworkCore;
using Tiku.Application.Commerce;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Domain.Catalog;
using Tiku.Domain.Commerce;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Security;
namespace Tiku.Infrastructure.Commerce;
internal sealed class CommerceAdminService(
TikuDbContext dbContext,
ITenantSecretProtector tenantSecretProtector,
ITenantExternalProviderConfigService providerConfigService) : ICommerceAdminService
ITenantExternalProviderConfigService providerConfigService,
ICurrentAccessContext currentAccessContext) : ICommerceAdminService
{
public async Task<IReadOnlyCollection<TenantPaymentProviderItem>> GetPaymentAccountsAsync(
CommerceAdminActor actor,
@@ -105,8 +108,14 @@ internal sealed class CommerceAdminService(
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var orders = dbContext.Orders.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId);
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(
scope,
item => item.UserId == actor.UserId,
item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
if (!string.IsNullOrWhiteSpace(query.Status))
{
orders = orders.Where(item => item.Status == ParseOrderStatus(query.Status));
@@ -125,8 +134,16 @@ internal sealed class CommerceAdminService(
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var scopedOrders = dbContext.Orders.AsNoTracking()
.Where(order => order.TenantId == actor.TenantId)
.ApplyDataScope(
scope,
order => order.UserId == actor.UserId,
order => order.RegionId.HasValue && regionIds.Contains(order.RegionId.Value));
var payments = from payment in dbContext.Payments.AsNoTracking()
join order in dbContext.Orders.AsNoTracking()
join order in scopedOrders
on new { payment.TenantId, payment.OrderId } equals new { order.TenantId, OrderId = order.Id }
where payment.TenantId == actor.TenantId
select new { payment, order.OrderNo };
@@ -568,8 +585,19 @@ internal sealed class CommerceAdminService(
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var refunds = dbContext.CommerceRefundRequests.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId);
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(
scope,
item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order =>
order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId),
item => dbContext.Orders.Any(order =>
order.TenantId == actor.TenantId &&
order.Id == item.OrderId &&
order.RegionId.HasValue &&
regionIds.Contains(order.RegionId.Value)));
if (!string.IsNullOrWhiteSpace(query.Status))
{
refunds = refunds.Where(item => item.Status == ParseRefundStatus(query.Status));
@@ -588,9 +616,16 @@ internal sealed class CommerceAdminService(
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var order = await dbContext.Orders.SingleOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.Id == command.OrderId,
cancellationToken) ?? throw new CommerceException("Order was not found.", "order_not_found");
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var order = await dbContext.Orders
.Where(item => item.TenantId == actor.TenantId && item.Id == command.OrderId)
.ApplyDataScope(
scope,
item => item.UserId == actor.UserId,
item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))
.SingleOrDefaultAsync(cancellationToken)
?? throw new CommerceException("Order was not found.", "order_not_found");
if (order.Status is not (OrderStatus.Paid or OrderStatus.PartiallyRefunded))
{
throw new CommerceException("Only paid orders can be refunded.", "order_not_refundable");
@@ -639,9 +674,21 @@ internal sealed class CommerceAdminService(
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var refund = await dbContext.CommerceRefundRequests.SingleOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.Id == command.RefundRequestId,
cancellationToken) ?? throw new CommerceException("Refund request was not found.", "refund_not_found");
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var refund = await dbContext.CommerceRefundRequests
.Where(item => item.TenantId == actor.TenantId && item.Id == command.RefundRequestId)
.ApplyDataScope(
scope,
item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order =>
order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId),
item => dbContext.Orders.Any(order =>
order.TenantId == actor.TenantId &&
order.Id == item.OrderId &&
order.RegionId.HasValue &&
regionIds.Contains(order.RegionId.Value)))
.SingleOrDefaultAsync(cancellationToken)
?? throw new CommerceException("Refund request was not found.", "refund_not_found");
var fromStatus = refund.Status;
if (!IsAllowedRefundTransition(fromStatus, command.Status))
{
@@ -687,6 +734,25 @@ internal sealed class CommerceAdminService(
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var refundExists = await dbContext.CommerceRefundRequests
.Where(item => item.TenantId == actor.TenantId && item.Id == refundRequestId)
.ApplyDataScope(
scope,
item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order =>
order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId),
item => dbContext.Orders.Any(order =>
order.TenantId == actor.TenantId &&
order.Id == item.OrderId &&
order.RegionId.HasValue &&
regionIds.Contains(order.RegionId.Value)))
.AnyAsync(cancellationToken);
if (!refundExists)
{
throw new CommerceException("Refund request was not found.", "refund_not_found");
}
var items = await dbContext.CommerceRefundEvents.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.RefundRequestId == refundRequestId)
.OrderBy(item => item.CreatedAt)
@@ -807,20 +873,24 @@ internal sealed class CommerceAdminService(
private async Task AssertAdminAsync(CommerceAdminActor actor, CancellationToken cancellationToken)
{
var isAdmin = await dbContext.TenantMemberships.AnyAsync(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.Status == MembershipStatus.Active &&
(item.Role == TenantRole.PlatformAdmin ||
item.Role == TenantRole.TenantOwner ||
item.Role == TenantRole.TenantAdmin),
cancellationToken);
if (!isAdmin)
var access = await currentAccessContext.GetAsync(cancellationToken);
if (!access.IsCurrentTenantMember ||
access.UserId != actor.UserId ||
access.TenantId != actor.TenantId ||
!access.HasTenantPermission(BackendPermissions.TenantCommerceOperate))
{
throw new CommerceException("Tenant admin access is required.", "tenant_admin_access_denied");
}
}
private async Task<CurrentDataScope> RequireDataScopeAsync(
CommerceAdminActor actor,
CancellationToken cancellationToken)
{
await AssertAdminAsync(actor, cancellationToken);
return (await currentAccessContext.GetAsync(cancellationToken)).DataScope;
}
private static TenantPaymentProviderItem ToPaymentAccountItem(TenantExternalProviderItem item) =>
new(
item.Id,

View File

@@ -4,17 +4,20 @@ using Microsoft.EntityFrameworkCore;
using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Application.QuestionBanks;
using Tiku.Application.Security;
using Tiku.Domain.Catalog;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.QuestionBanks;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Security;
namespace Tiku.Infrastructure.Content;
public sealed class ContentManagementService(
TikuDbContext dbContext,
IQuestionReferenceService questionReferenceService) : IContentManagementService
IQuestionReferenceService questionReferenceService,
ICurrentAccessContext currentAccessContext) : IContentManagementService
{
private const int DefaultLimit = 100;
private const int MaxLimit = 1000;
@@ -24,9 +27,15 @@ public sealed class ContentManagementService(
ContentManagementFilter filter,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.ContentEntries
.AsNoTracking()
.Where(entry => entry.TenantId == actor.TenantId);
.Where(entry => entry.TenantId == actor.TenantId)
.ApplyDataScope(
scope,
entry => entry.CreatedBy == actor.UserId,
entry => entry.RegionId.HasValue && regionIds.Contains(entry.RegionId.Value));
if (!filter.IncludeInactive)
{
@@ -67,6 +76,7 @@ public sealed class ContentManagementService(
UpsertContentEntryCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken);
@@ -81,6 +91,21 @@ public sealed class ContentManagementService(
cancellationToken);
var isNew = entry is null;
if (command.Id.HasValue && (entry is null || entry.Id != command.Id.Value))
{
throw new ContentManagementException("Content entry was not found.", "entry_not_found");
}
if (entry is not null && !scope.AllowsResource(actor.UserId, entry.CreatedBy, entry.RegionId))
{
throw new ContentManagementException("Content entry was not found.", "entry_not_found");
}
if (entry is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId))
{
throw new ContentManagementException("Content entry was not found.", "entry_not_found");
}
entry ??= new ContentEntry
{
Id = command.Id ?? Guid.NewGuid(),
@@ -117,14 +142,21 @@ public sealed class ContentManagementService(
ContentManagementFilter filter,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
if (!filter.EntryId.HasValue)
{
throw new ContentManagementException("entryId is required.", "entry_id_required");
}
await AssertEntryAsync(actor, scope, filter.EntryId, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.ContentNodes
.AsNoTracking()
.Where(node => node.TenantId == actor.TenantId && node.EntryId == filter.EntryId.Value);
.Where(node => node.TenantId == actor.TenantId && node.EntryId == filter.EntryId.Value)
.ApplyDataScope(
scope,
node => node.CreatedBy == actor.UserId,
node => node.RegionId.HasValue && regionIds.Contains(node.RegionId.Value));
if (!filter.IncludeInactive)
{
@@ -178,9 +210,11 @@ public sealed class ContentManagementService(
UpsertContentNodeCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
await AssertEntryAsync(actor.TenantId, command.EntryId, cancellationToken);
await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken);
await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken);
await AssertNodeAsync(actor, scope, command.ParentId, cancellationToken);
var nodeKey = Normalize(command.NodeKey) ??
Normalize(command.Id?.ToString("N")) ??
@@ -193,6 +227,21 @@ public sealed class ContentManagementService(
cancellationToken);
var isNew = node is null;
if (command.Id.HasValue && (node is null || node.Id != command.Id.Value))
{
throw new ContentManagementException("Content node was not found.", "node_not_found");
}
if (node is not null && !scope.AllowsResource(actor.UserId, node.CreatedBy, node.RegionId))
{
throw new ContentManagementException("Content node was not found.", "node_not_found");
}
if (node is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId))
{
throw new ContentManagementException("Content node was not found.", "node_not_found");
}
node ??= new ContentNode
{
Id = command.Id ?? Guid.NewGuid(),
@@ -246,9 +295,15 @@ public sealed class ContentManagementService(
ContentManagementFilter filter,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.QuestionCollections
.AsNoTracking()
.Where(collection => collection.TenantId == actor.TenantId);
.Where(collection => collection.TenantId == actor.TenantId)
.ApplyDataScope(
scope,
collection => collection.CreatedBy == actor.UserId,
collection => collection.RegionId.HasValue && regionIds.Contains(collection.RegionId.Value));
if (!filter.IncludeInactive)
{
@@ -296,10 +351,11 @@ public sealed class ContentManagementService(
UpsertQuestionCollectionCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken);
await AssertEntryAsync(actor.TenantId, command.EntryId, cancellationToken);
await AssertNodeAsync(actor.TenantId, command.NodeId, cancellationToken);
await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken);
await AssertNodeAsync(actor, scope, command.NodeId, cancellationToken);
await AssertReferenceAsync<Subject>(actor.TenantId, command.SubjectId, "subject_not_found", cancellationToken);
await AssertReferenceAsync<Category>(actor.TenantId, command.CategoryId, "category_not_found", cancellationToken);
await AssertReferenceAsync<QuestionBank>(actor.TenantId, command.QuestionBankId, "question_bank_not_found", cancellationToken);
@@ -312,6 +368,21 @@ public sealed class ContentManagementService(
cancellationToken);
var isNew = collection is null;
if (command.Id.HasValue && (collection is null || collection.Id != command.Id.Value))
{
throw new ContentManagementException("Collection was not found.", "collection_not_found");
}
if (collection is not null && !scope.AllowsResource(actor.UserId, collection.CreatedBy, collection.RegionId))
{
throw new ContentManagementException("Collection was not found.", "collection_not_found");
}
if (collection is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId))
{
throw new ContentManagementException("Collection was not found.", "collection_not_found");
}
collection ??= new QuestionCollection
{
Id = command.Id ?? Guid.NewGuid(),
@@ -352,9 +423,15 @@ public sealed class ContentManagementService(
ReplaceCollectionItemsCommand command,
CancellationToken cancellationToken = default)
{
var collection = await dbContext.QuestionCollections.SingleOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.Id == command.CollectionId,
cancellationToken);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var collection = await dbContext.QuestionCollections
.Where(item => item.TenantId == actor.TenantId && item.Id == command.CollectionId)
.ApplyDataScope(
scope,
item => item.CreatedBy == actor.UserId,
item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))
.SingleOrDefaultAsync(cancellationToken);
if (collection is null)
{
@@ -409,9 +486,15 @@ public sealed class ContentManagementService(
ContentManagementFilter filter,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.PracticeBlueprints
.AsNoTracking()
.Where(blueprint => blueprint.TenantId == actor.TenantId);
.Where(blueprint => blueprint.TenantId == actor.TenantId)
.ApplyDataScope(
scope,
blueprint => blueprint.CreatedBy == actor.UserId,
blueprint => blueprint.RegionId.HasValue && regionIds.Contains(blueprint.RegionId.Value));
if (!filter.IncludeInactive)
{
@@ -464,10 +547,11 @@ public sealed class ContentManagementService(
UpsertPracticeBlueprintCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken);
await AssertEntryAsync(actor.TenantId, command.EntryId, cancellationToken);
await AssertNodeAsync(actor.TenantId, command.NodeId, cancellationToken);
await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken);
await AssertNodeAsync(actor, scope, command.NodeId, cancellationToken);
await AssertReferenceAsync<QuestionCollection>(actor.TenantId, command.CollectionId, "collection_not_found", cancellationToken);
var blueprint = await ResolveEntityByIdOrLegacyAsync(
@@ -478,6 +562,21 @@ public sealed class ContentManagementService(
cancellationToken);
var isNew = blueprint is null;
if (command.Id.HasValue && (blueprint is null || blueprint.Id != command.Id.Value))
{
throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found");
}
if (blueprint is not null && !scope.AllowsResource(actor.UserId, blueprint.CreatedBy, blueprint.RegionId))
{
throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found");
}
if (blueprint is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId))
{
throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found");
}
blueprint ??= new PracticeBlueprint
{
Id = command.Id ?? Guid.NewGuid(),
@@ -584,11 +683,74 @@ public sealed class ContentManagementService(
await AssertReferenceAsync<ContentEntry>(tenantId, entryId, "entry_not_found", cancellationToken);
}
private async Task AssertEntryAsync(
ContentManagementActor actor,
CurrentDataScope scope,
Guid? entryId,
CancellationToken cancellationToken)
{
if (!entryId.HasValue)
{
return;
}
var regionIds = scope.RegionIds.ToArray();
var exists = await dbContext.ContentEntries
.Where(entry => entry.TenantId == actor.TenantId && entry.Id == entryId.Value)
.ApplyDataScope(
scope,
entry => entry.CreatedBy == actor.UserId,
entry => entry.RegionId.HasValue && regionIds.Contains(entry.RegionId.Value))
.AnyAsync(cancellationToken);
if (!exists)
{
throw new ContentManagementException("Content entry was not found.", "entry_not_found");
}
}
private async Task AssertNodeAsync(Guid tenantId, Guid? nodeId, CancellationToken cancellationToken)
{
await AssertReferenceAsync<ContentNode>(tenantId, nodeId, "node_not_found", cancellationToken);
}
private async Task AssertNodeAsync(
ContentManagementActor actor,
CurrentDataScope scope,
Guid? nodeId,
CancellationToken cancellationToken)
{
if (!nodeId.HasValue)
{
return;
}
var regionIds = scope.RegionIds.ToArray();
var exists = await dbContext.ContentNodes
.Where(node => node.TenantId == actor.TenantId && node.Id == nodeId.Value)
.ApplyDataScope(
scope,
node => node.CreatedBy == actor.UserId,
node => node.RegionId.HasValue && regionIds.Contains(node.RegionId.Value))
.AnyAsync(cancellationToken);
if (!exists)
{
throw new ContentManagementException("Content node was not found.", "node_not_found");
}
}
private async Task<CurrentDataScope> RequireDataScopeAsync(
ContentManagementActor actor,
CancellationToken cancellationToken)
{
var access = await currentAccessContext.GetAsync(cancellationToken);
if (!access.IsCurrentTenantMember || access.UserId != actor.UserId || access.TenantId != actor.TenantId)
{
throw new ContentManagementException("Content resource was not found.", "content_not_found");
}
return access.DataScope;
}
private async Task AssertReferenceAsync<TEntity>(
Guid tenantId,
Guid? id,

View File

@@ -5,6 +5,7 @@ using Tiku.Application.Assets;
using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Application.QuestionBanks;
using Tiku.Application.Security;
using Tiku.Domain.Catalog;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
@@ -12,12 +13,14 @@ using Tiku.Domain.Learning;
using Tiku.Domain.Operations;
using Tiku.Domain.QuestionBanks;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Security;
namespace Tiku.Infrastructure.Content;
public sealed class DirectContentService(
TikuDbContext dbContext,
IQuestionReferenceService questionReferenceService) : IDirectContentService
IQuestionReferenceService questionReferenceService,
ICurrentAccessContext currentAccessContext) : IDirectContentService
{
private const int DefaultLimit = 100;
private const int MaxLimit = 1000;
@@ -120,7 +123,11 @@ public sealed class DirectContentService(
AdminLimitFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.VocabularyUnits.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.VocabularyUnits.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
if (filter.RegionId.HasValue)
{
query = query.Where(item => item.RegionId == filter.RegionId.Value);
@@ -159,6 +166,7 @@ public sealed class DirectContentService(
VocabularyUnitCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
await AssertReferenceAsync<ContentEntry>(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken);
@@ -166,6 +174,7 @@ public sealed class DirectContentService(
var item = await ResolveByIdOrLegacyAsync(dbContext.VocabularyUnits, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
var isNew = item is null;
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "vocabulary_unit_not_found");
item ??= new VocabularyUnit { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
item.RegionId = command.RegionId;
item.EntryId = command.EntryId;
@@ -272,7 +281,11 @@ public sealed class DirectContentService(
AdminLimitFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.HandbookSubjects.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.HandbookSubjects.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
if (filter.RegionId.HasValue)
{
query = query.Where(item => item.RegionId == filter.RegionId.Value);
@@ -321,6 +334,7 @@ public sealed class DirectContentService(
HandbookSubjectCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
await AssertReferenceAsync<School>(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken);
@@ -330,6 +344,7 @@ public sealed class DirectContentService(
var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookSubjects, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
var isNew = item is null;
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "handbook_subject_not_found");
item ??= new HandbookSubject { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
item.RegionId = command.RegionId;
item.SchoolId = command.SchoolId;
@@ -513,7 +528,11 @@ public sealed class DirectContentService(
AdminLimitFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.Schools.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.Schools.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
if (filter.RegionId.HasValue)
{
query = query.Where(item => item.RegionId == filter.RegionId.Value);
@@ -536,10 +555,12 @@ public sealed class DirectContentService(
SchoolCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
var item = await ResolveByIdOrLegacyAsync(dbContext.Schools, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
var isNew = item is null;
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "school_not_found");
item ??= new School { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
item.RegionId = command.RegionId;
item.LegacyId = Normalize(command.LegacyId);
@@ -560,7 +581,11 @@ public sealed class DirectContentService(
AdminLimitFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.Majors.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.Majors.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
if (filter.RegionId.HasValue)
{
query = query.Where(item => item.RegionId == filter.RegionId.Value);
@@ -594,11 +619,13 @@ public sealed class DirectContentService(
MajorCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
await AssertReferenceAsync<School>(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken);
var item = await ResolveByIdOrLegacyAsync(dbContext.Majors, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
var isNew = item is null;
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "major_not_found");
item ??= new Major { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
item.RegionId = command.RegionId;
item.SchoolId = command.SchoolId;
@@ -622,7 +649,11 @@ public sealed class DirectContentService(
AdminLimitFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.ScorelineFields.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.ScorelineFields.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
if (filter.RegionId.HasValue)
{
query = query.Where(item => item.RegionId == filter.RegionId.Value || item.RegionId == null);
@@ -646,6 +677,7 @@ public sealed class DirectContentService(
ScorelineFieldCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.FieldKey);
ArgumentException.ThrowIfNullOrWhiteSpace(command.FieldName);
if (!ScorelineFieldKeyRegex.IsMatch(command.FieldKey.Trim()))
@@ -656,6 +688,7 @@ public sealed class DirectContentService(
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
var item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineFields, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
var isNew = item is null;
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "scoreline_field_not_found");
item ??= new ScorelineField { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
item.RegionId = command.RegionId;
item.LegacyId = Normalize(command.LegacyId);
@@ -685,7 +718,11 @@ public sealed class DirectContentService(
AdminLimitFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.ScorelineRecords.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.ScorelineRecords.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
if (filter.RegionId.HasValue)
{
query = query.Where(item => item.RegionId == filter.RegionId.Value);
@@ -727,6 +764,7 @@ public sealed class DirectContentService(
ScorelineRecordCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
if (command.Year is < 1900 or > 3000)
{
throw new ContentManagementException("Scoreline record year is invalid.", "scoreline_year_invalid");
@@ -737,6 +775,7 @@ public sealed class DirectContentService(
await AssertReferenceAsync<Major>(actor.TenantId, command.MajorId, "major_not_found", cancellationToken);
var item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineRecords, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
var isNew = item is null;
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "scoreline_record_not_found");
item ??= new ScorelineRecord { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
item.RegionId = command.RegionId;
item.SchoolId = command.SchoolId;
@@ -760,8 +799,11 @@ public sealed class DirectContentService(
AdminLimitFilter filter,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.ScorelineRecords.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId);
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
if (filter.RegionId.HasValue)
{
query = query.Where(item => item.RegionId == filter.RegionId.Value);
@@ -1720,6 +1762,38 @@ public sealed class DirectContentService(
item.IssuesCount);
}
private async Task<CurrentDataScope> RequireDataScopeAsync(
DirectContentActor actor,
CancellationToken cancellationToken)
{
var access = await currentAccessContext.GetAsync(cancellationToken);
if (!access.IsCurrentTenantMember ||
access.UserId != actor.UserId ||
access.TenantId != actor.TenantId ||
!access.HasTenantPermission(BackendPermissions.TenantContentManage))
{
throw new ContentManagementException("Tenant content access was denied.", "content_access_denied");
}
return access.DataScope;
}
private static void EnsureRegionWriteAllowed(
CurrentDataScope scope,
DirectContentActor actor,
Guid? currentRegionId,
Guid? targetRegionId,
bool isNew,
string notFoundCode)
{
var canAccessCurrent = isNew || scope.AllowsResource(actor.UserId, regionId: currentRegionId);
var canAccessTarget = scope.AllowsResource(actor.UserId, regionId: targetRegionId);
if (!canAccessCurrent || !canAccessTarget)
{
throw new ContentManagementException("Content resource was not found.", notFoundCode);
}
}
private async Task<TEntity?> ResolveByIdOrLegacyAsync<TEntity>(
DbSet<TEntity> set,
Guid tenantId,

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using Tiku.Application.Assets;
@@ -35,10 +36,12 @@ using Tiku.Infrastructure.Profile;
using Tiku.Infrastructure.Points;
using Tiku.Infrastructure.QuestionBanks;
using Tiku.Infrastructure.Scoreline;
using Tiku.Infrastructure.Security;
using Tiku.Infrastructure.Storage;
using Tiku.Infrastructure.StudyContent;
using Tiku.Infrastructure.TenantAdmin;
using Tiku.Infrastructure.Tenancy;
using Tiku.Domain.Identity;
namespace Tiku.Infrastructure;
@@ -59,6 +62,21 @@ public static class DependencyInjection
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName));
options.AddInterceptors(serviceProvider.GetRequiredService<TenantIsolationSaveChangesInterceptor>());
});
services.AddIdentityCore<User>(options =>
{
options.Password.RequiredLength = 10;
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
options.User.RequireUniqueEmail = false;
})
.AddEntityFrameworkStores<TikuDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders();
services.Configure<PasswordHasherOptions>(options => options.IterationCount = 210_000);
services.AddScoped<ITenantDirectory, TenantDirectory>();
services.AddMemoryCache();
services.AddScoped<ITenantFrontendConfigService, TenantFrontendConfigService>();
@@ -69,9 +87,10 @@ public static class DependencyInjection
services.AddScoped<ITenantDomainLifecycleService, TenantDomainLifecycleService>();
services.AddOptions<DomainLifecycleOptions>();
services.AddSingleton<ITenantExecutionScope, TenantExecutionScope>();
services.AddScoped<IPasswordHasher, PasswordHasher>();
services.AddSingleton<IJwtKeyRing, JwtKeyRing>();
services.AddScoped<ITokenService, TokenService>();
services.AddScoped<ISessionService, SessionService>();
services.AddScoped<AuthSessionStore>();
services.AddScoped<IAuthSessionStore>(provider => provider.GetRequiredService<AuthSessionStore>());
services.AddScoped<ISmsProvider, AliyunSmsProvider>();
services.AddScoped<ISmsVerificationService, SmsVerificationService>();
services.AddScoped<IWechatOAuthClient, WechatOAuthClient>();
@@ -94,6 +113,7 @@ public static class DependencyInjection
services.AddScoped<ILearningActivityService, LearningActivityService>();
services.AddScoped<ITenantAdminDirectService, TenantAdminDirectService>();
services.AddScoped<IBackofficeService, BackofficeService>();
services.AddScoped<ICurrentAccessContext, CurrentAccessContext>();
services.AddScoped<IOperationAuditService, OperationAuditService>();
services.AddScoped<IBackgroundJobService, BackgroundJobService>();
services.AddScoped<ICommerceService, CommerceService>();

View File

@@ -4,6 +4,7 @@ using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Growth;
using Tiku.Application.Security;
using Tiku.Domain.Commerce;
using Tiku.Domain.Common;
using Tiku.Domain.Growth;
@@ -13,7 +14,9 @@ using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Growth;
public sealed class CommissionService(TikuDbContext dbContext) : ICommissionService
public sealed class CommissionService(
TikuDbContext dbContext,
ICurrentAccessContext currentAccessContext) : ICommissionService
{
public async Task<CommissionSettingsItem> GetSettingsAsync(CommissionAdminActor actor, CancellationToken cancellationToken = default)
{
@@ -40,11 +43,20 @@ public sealed class CommissionService(TikuDbContext dbContext) : ICommissionServ
var member = await dbContext.TenantMemberships
.FirstOrDefaultAsync(item => item.TenantId == actor.TenantId && item.UserId == command.UserId, cancellationToken)
?? throw new CommissionException("Commission member was not found.", "commission_member_not_found");
member.Permissions = JsonSerializer.SerializeToElement(new
var settings = await GetSettingsCoreAsync(actor.TenantId, cancellationToken);
var config = settings.Config.ValueKind == JsonValueKind.Object
? JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(settings.Config.GetRawText()) ?? []
: [];
var memberRates = config.TryGetValue("memberRates", out var existingRates) && existingRates.ValueKind == JsonValueKind.Object
? JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(existingRates.GetRawText()) ?? []
: [];
memberRates[member.UserId.ToString("N")] = JsonSerializer.SerializeToElement(new
{
commissionRate = command.CommissionRate,
commissionConfig = command.CommissionConfig
});
config["memberRates"] = JsonSerializer.SerializeToElement(memberRates);
settings.Config = JsonSerializer.SerializeToElement(config);
await dbContext.SaveChangesAsync(cancellationToken);
return new { member.UserId, commissionRate = command.CommissionRate, commissionConfig = command.CommissionConfig };
}
@@ -265,7 +277,7 @@ public sealed class CommissionService(TikuDbContext dbContext) : ICommissionServ
foreach (var row in orders)
{
if (query.ReferrerUserId.HasValue && row.lead.ReferrerUserId != query.ReferrerUserId) continue;
var rate = GetMemberRate(await GetMembershipPermissionsAsync(tenantId, row.lead.ReferrerUserId!.Value, cancellationToken)) ?? settings.DefaultRate;
var rate = GetMemberRate(settings.Config, row.lead.ReferrerUserId!.Value) ?? settings.DefaultRate;
var settled = existing.FirstOrDefault(item => item.SourceType == CommissionSourceType.Order && item.SourceId == row.order.Id)?.SettlementId;
result.Add(new SourceCandidate(CommissionSourceType.Order, row.order.Id, row.order.OrderNo, row.lead.ReferrerUserId.Value, row.order.UserId, row.order.AmountCents, rate, (int)Math.Round(row.order.AmountCents * rate), CommissionRateSource.Member, settled, row.order.PaidAt, "protected_lead"));
}
@@ -282,7 +294,7 @@ public sealed class CommissionService(TikuDbContext dbContext) : ICommissionServ
if (!row.code.AgentUserId.HasValue) continue;
var agentUserId = row.code.AgentUserId.Value;
if (query.ReferrerUserId.HasValue && agentUserId != query.ReferrerUserId) continue;
var rate = row.batch?.CommissionRate ?? GetMemberRate(await GetMembershipPermissionsAsync(tenantId, agentUserId, cancellationToken)) ?? settings.DefaultRate;
var rate = row.batch?.CommissionRate ?? GetMemberRate(settings.Config, agentUserId) ?? settings.DefaultRate;
var sourceAmount = row.code.UnitPriceCents ?? row.batch?.DefaultUnitPriceCents ?? 0;
var settled = existing.FirstOrDefault(item => item.SourceType == CommissionSourceType.ActivationCode && item.SourceId == row.code.Id)?.SettlementId;
result.Add(new SourceCandidate(CommissionSourceType.ActivationCode, row.code.Id, row.code.Code, agentUserId, row.code.UsedBy, sourceAmount, rate, (int)Math.Round(sourceAmount * rate), row.batch?.CommissionRate is null ? CommissionRateSource.Member : CommissionRateSource.Batch, settled, row.code.UsedAt, "activation_code_agent"));
@@ -290,11 +302,22 @@ public sealed class CommissionService(TikuDbContext dbContext) : ICommissionServ
return result.OrderBy(item => item.SourcePaidAt).ToArray();
}
private async Task<JsonElement> GetMembershipPermissionsAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken) =>
await dbContext.TenantMemberships.AsNoTracking().Where(item => item.TenantId == tenantId && item.UserId == userId).Select(item => item.Permissions).FirstOrDefaultAsync(cancellationToken);
private static decimal? GetMemberRate(JsonElement config, Guid userId)
{
if (config.ValueKind != JsonValueKind.Object ||
!config.TryGetProperty("memberRates", out var memberRates) ||
memberRates.ValueKind != JsonValueKind.Object ||
!memberRates.TryGetProperty(userId.ToString("N"), out var memberRate) ||
memberRate.ValueKind != JsonValueKind.Object ||
!memberRate.TryGetProperty("commissionRate", out var value) ||
value.ValueKind != JsonValueKind.Number ||
!value.TryGetDecimal(out var rate))
{
return null;
}
private static decimal? GetMemberRate(JsonElement permissions) =>
permissions.ValueKind == JsonValueKind.Object && permissions.TryGetProperty("commissionRate", out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetDecimal(out var rate) ? rate : null;
return rate;
}
private async Task<TenantCommissionSetting> GetSettingsCoreAsync(Guid tenantId, CancellationToken cancellationToken)
{
@@ -311,8 +334,14 @@ public sealed class CommissionService(TikuDbContext dbContext) : ICommissionServ
private async Task AssertAdminAsync(CommissionAdminActor actor, CancellationToken cancellationToken)
{
var ok = await dbContext.TenantMemberships.AnyAsync(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.Status == MembershipStatus.Active && (item.Role == TenantRole.PlatformAdmin || item.Role == TenantRole.TenantOwner || item.Role == TenantRole.TenantAdmin), cancellationToken);
if (!ok) throw new CommissionException("Commission admin access was denied.", "commission_access_denied");
var access = await currentAccessContext.GetAsync(cancellationToken);
if (!access.IsCurrentTenantMember ||
access.TenantId != actor.TenantId ||
access.UserId != actor.UserId ||
!access.HasTenantPermission(BackendPermissions.TenantCommissionManage))
{
throw new CommissionException("Commission admin access was denied.", "commission_access_denied");
}
}
private async Task AddAuditAsync(CommissionAdminActor actor, string action, string targetType, Guid targetId, object details, CancellationToken cancellationToken)

View File

@@ -1,6 +1,7 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Growth;
using Tiku.Application.Security;
using Tiku.Domain.Common;
using Tiku.Domain.Growth;
using Tiku.Domain.Tenancy;
@@ -11,7 +12,8 @@ namespace Tiku.Infrastructure.Growth;
internal sealed class CrmService(
TikuDbContext dbContext,
ITenantSecretProtector tenantSecretProtector) : ICrmService
ITenantSecretProtector tenantSecretProtector,
ICurrentAccessContext currentAccessContext) : ICrmService
{
private static readonly HashSet<string> SensitiveKeys = new(StringComparer.OrdinalIgnoreCase)
{
@@ -270,16 +272,12 @@ internal sealed class CrmService(
private async Task AssertAdminAsync(CrmAdminActor actor, CancellationToken cancellationToken)
{
var isAdmin = await dbContext.TenantMemberships.AnyAsync(
item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.Status == MembershipStatus.Active &&
(item.Role == TenantRole.PlatformAdmin ||
item.Role == TenantRole.TenantOwner ||
item.Role == TenantRole.TenantAdmin),
cancellationToken);
if (!isAdmin)
var access = await currentAccessContext.GetAsync(cancellationToken);
if (!access.IsCurrentTenantMember ||
access.UserId != actor.UserId ||
access.TenantId != actor.TenantId ||
!access.HasTenantPermission(BackendPermissions.TenantCrmManage) ||
access.DataScope.Mode != DataScopeMode.All)
{
throw new CrmException("CRM admin access was denied.", "crm_access_denied");
}

View File

@@ -3,6 +3,7 @@ using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Growth;
using Tiku.Application.Security;
using Tiku.Domain.Commerce;
using Tiku.Domain.Common;
using Tiku.Domain.Growth;
@@ -14,7 +15,8 @@ namespace Tiku.Infrastructure.Growth;
public sealed class ReferralService(
TikuDbContext dbContext,
IReferralQrcodeGenerator qrcodeGenerator) : IReferralService
IReferralQrcodeGenerator qrcodeGenerator,
ICurrentAccessContext currentAccessContext) : IReferralService
{
private static readonly HashSet<string> AllowedEventTypes = new(StringComparer.OrdinalIgnoreCase)
{
@@ -109,7 +111,7 @@ public sealed class ReferralService(
referralCode.UserId,
membership.Role,
user.Name,
user.Username,
user.UserName,
user.Phone
})
.FirstOrDefaultAsync(cancellationToken);
@@ -121,7 +123,7 @@ public sealed class ReferralService(
row.UserId,
row.Code,
row.Role.ToString(),
FirstNonBlank(row.Name, row.Username, row.Phone));
FirstNonBlank(row.Name, row.UserName, row.Phone));
}
public async Task<ReferralTrackResult> TrackEventAsync(
@@ -655,16 +657,11 @@ public sealed class ReferralService(
private async Task AssertAdminAsync(ReferralAdminActor actor, CancellationToken cancellationToken)
{
var isAdmin = await dbContext.TenantMemberships.AnyAsync(
item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.Status == MembershipStatus.Active &&
(item.Role == TenantRole.PlatformAdmin ||
item.Role == TenantRole.TenantOwner ||
item.Role == TenantRole.TenantAdmin),
cancellationToken);
if (!isAdmin)
var access = await currentAccessContext.GetAsync(cancellationToken);
if (!access.IsCurrentTenantMember ||
access.TenantId != actor.TenantId ||
access.UserId != actor.UserId ||
!access.HasTenantPermission(BackendPermissions.TenantCrmManage))
{
throw new ReferralException("Referral admin access was denied.", "referral_access_denied");
}
@@ -677,7 +674,7 @@ public sealed class ReferralService(
{
var user = await dbContext.Users.AsNoTracking()
.Where(item => item.Id == referrerUserId)
.Select(item => new { item.Name, item.Username, item.Phone })
.Select(item => new { item.Name, item.UserName, item.Phone })
.FirstOrDefaultAsync(cancellationToken);
var membership = await dbContext.TenantMemberships.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.UserId == referrerUserId)
@@ -714,7 +711,7 @@ public sealed class ReferralService(
return new ReferralStatsItem(
referrerUserId,
FirstNonBlank(user?.Name, user?.Username, user?.Phone),
FirstNonBlank(user?.Name, user?.UserName, user?.Phone),
membership.ToString(),
inviteCode,
leads.Length,

View File

@@ -9,22 +9,30 @@ internal sealed class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.ConfigureEntity("users");
builder.ToTable("users");
builder.HasKey(entity => entity.Id);
builder.Property(entity => entity.Id).HasDefaultValueSql("gen_random_uuid()");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.Username).HasMaxLength(100);
builder.Property(entity => entity.UserName).HasMaxLength(100);
builder.Property(entity => entity.NormalizedUserName).HasMaxLength(100);
builder.Property(entity => entity.Email).HasColumnType("citext").HasMaxLength(320);
builder.Property(entity => entity.NormalizedEmail).HasMaxLength(320);
builder.Property(entity => entity.Phone).HasMaxLength(32);
builder.Property(entity => entity.PhoneNumber).HasMaxLength(32);
builder.Property(entity => entity.PasswordHash).HasMaxLength(1024);
builder.Property(entity => entity.SecurityStamp).HasMaxLength(64);
builder.Property(entity => entity.ConcurrencyStamp).HasMaxLength(64).IsConcurrencyToken();
builder.Property(entity => entity.Name).HasMaxLength(200);
builder.Property(entity => entity.AvatarUrl).HasMaxLength(2048);
builder.Property(entity => entity.PrimaryRole).HasMaxLength(50);
builder.Property(entity => entity.LegacyPasswordHash).HasMaxLength(512);
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.RawProfile).IsJson("{}");
builder.HasIndex(entity => entity.LegacyId).IsUnique();
builder.HasIndex(entity => entity.Username).IsUnique();
builder.HasIndex(entity => entity.Email).IsUnique();
builder.HasIndex(entity => entity.NormalizedUserName).IsUnique();
builder.HasIndex(entity => entity.NormalizedEmail);
builder.HasIndex(entity => entity.Phone).IsUnique();
}
}
@@ -42,8 +50,6 @@ internal sealed class UserIdentityConfiguration : IEntityTypeConfiguration<UserI
builder.Property(entity => entity.OpenId).HasMaxLength(255);
builder.Property(entity => entity.Phone).HasMaxLength(32);
builder.Property(entity => entity.Email).HasColumnType("citext").HasMaxLength(320);
builder.Property(entity => entity.SecretPayload).IsJson("{}");
builder.HasIndex(entity => new { entity.Provider, entity.ProviderSubject }).IsUnique();
builder.HasOne<User>()
.WithMany()

View File

@@ -43,7 +43,6 @@ internal sealed class TenantMembershipConfiguration : IEntityTypeConfiguration<T
builder.Property(entity => entity.Role).HasSnakeCaseEnum();
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.Permissions).IsJson("{}");
builder.Property(entity => entity.LegacyRole).HasMaxLength(50);
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.Role }).IsUnique();
@@ -52,11 +51,6 @@ internal sealed class TenantMembershipConfiguration : IEntityTypeConfiguration<T
.WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<TenantRoleTemplate>()
.WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RoleTemplateId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}

View File

@@ -51,7 +51,6 @@ internal sealed class SmsVerificationCodeConfiguration : IEntityTypeConfiguratio
public void Configure(EntityTypeBuilder<SmsVerificationCode> builder)
{
builder.ConfigureEntity("sms_verification_codes");
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
builder.Property(entity => entity.Phone).HasMaxLength(32);
builder.Property(entity => entity.Purpose).HasSnakeCaseEnum();
builder.Property(entity => entity.CodeHash).HasMaxLength(256);
@@ -96,7 +95,7 @@ internal sealed class AuthLoginEventConfiguration : IEntityTypeConfiguration<Aut
builder.HasOne<Tenant>().WithMany()
.HasForeignKey(entity => entity.TenantId)
.OnDelete(DeleteBehavior.Cascade);
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.SetNull);
@@ -107,18 +106,30 @@ internal sealed class AuthSessionConfiguration : IEntityTypeConfiguration<AuthSe
{
public void Configure(EntityTypeBuilder<AuthSession> builder)
{
builder.ConfigureTenantEntity("auth_sessions");
builder.ConfigureEntity("auth_sessions");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Realm).HasSnakeCaseEnum();
builder.Property(entity => entity.TokenHash).HasMaxLength(256);
builder.Property(entity => entity.SecurityStamp).HasMaxLength(128);
builder.Property(entity => entity.Provider).HasMaxLength(50);
builder.Property(entity => entity.RevokedReason).HasMaxLength(100);
builder.Property(entity => entity.IpAddress).HasMaxLength(64);
builder.Property(entity => entity.UserAgent).HasMaxLength(1000);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => entity.TokenHash)
.IsUnique()
.HasAnnotation("Tiku:GlobalUnique", true);
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.ExpiresAt })
builder.HasIndex(entity => new { entity.Realm, entity.TenantId, entity.UserId, entity.ExpiresAt })
.HasFilter("revoked_at is null");
builder.HasIndex(entity => new { entity.TokenFamilyId, entity.RevokedAt });
builder.ToTable(table => table.HasCheckConstraint(
"ck_auth_sessions_realm_tenant",
"(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)"));
builder.HasOne<Tenant>().WithMany()
.HasForeignKey(entity => entity.TenantId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
@@ -126,6 +137,29 @@ internal sealed class AuthSessionConfiguration : IEntityTypeConfiguration<AuthSe
}
}
internal sealed class AuthChallengeConfiguration : IEntityTypeConfiguration<AuthChallenge>
{
public void Configure(EntityTypeBuilder<AuthChallenge> builder)
{
builder.ConfigureEntity("auth_challenges");
builder.Property(entity => entity.Realm).HasSnakeCaseEnum();
builder.Property(entity => entity.Purpose).HasSnakeCaseEnum();
builder.Property(entity => entity.TokenHash).HasMaxLength(64);
builder.Property(entity => entity.SecurityStamp).HasMaxLength(128);
builder.Property(entity => entity.Provider).HasMaxLength(50);
builder.Property(entity => entity.IpAddress).HasMaxLength(100);
builder.Property(entity => entity.UserAgent).HasMaxLength(1024);
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => entity.TokenHash).IsUnique();
builder.HasIndex(entity => new { entity.UserId, entity.Purpose, entity.ExpiresAt });
builder.ToTable(table => table.HasCheckConstraint(
"ck_auth_challenges_realm_tenant",
"(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)"));
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.UserId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Tenant>().WithMany().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class SmsSendRateLimitConfiguration : IEntityTypeConfiguration<SmsSendRateLimit>
{
public void Configure(EntityTypeBuilder<SmsSendRateLimit> builder)
@@ -154,32 +188,6 @@ internal sealed class SmsSendRateLimitConfiguration : IEntityTypeConfiguration<S
}
}
internal sealed class TenantRoleTemplateConfiguration : IEntityTypeConfiguration<TenantRoleTemplate>
{
public void Configure(EntityTypeBuilder<TenantRoleTemplate> builder)
{
builder.ConfigureTenantEntity("tenant_role_templates");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Code).HasMaxLength(100);
builder.Property(entity => entity.Name).HasMaxLength(200);
builder.Property(entity => entity.BaseRole).HasSnakeCaseEnum();
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.Permissions).IsJson("{}");
builder.Property(entity => entity.MenuPermissions).IsJson("{}");
builder.Property(entity => entity.ModulePermissions).IsJson("{}");
builder.Property(entity => entity.FieldPermissions).IsJson("{}");
builder.Property(entity => entity.DataScope).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.Code }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.SortOrder });
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.CreatedBy)
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UpdatedBy)
.OnDelete(DeleteBehavior.SetNull);
}
}
internal sealed class TenantClassConfiguration : IEntityTypeConfiguration<TenantClass>
{
public void Configure(EntityTypeBuilder<TenantClass> builder)

View File

@@ -13,7 +13,7 @@ using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(TikuDbContext))]
[Migration("20260728014412_InitialSchema")]
[Migration("20260728031410_InitialSchema")]
partial class InitialSchema
{
/// <inheritdoc />
@@ -28,6 +28,110 @@ namespace Tiku.Infrastructure.Persistence.Migrations
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("FriendlyName")
.HasColumnType("text")
.HasColumnName("friendly_name");
b.Property<string>("Xml")
.HasColumnType("text")
.HasColumnName("xml");
b.HasKey("Id")
.HasName("pk_data_protection_keys");
b.ToTable("data_protection_keys", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text")
.HasColumnName("claim_type");
b.Property<string>("ClaimValue")
.HasColumnType("text")
.HasColumnName("claim_value");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_user_claims");
b.HasIndex("UserId")
.HasDatabaseName("ix_user_claims_user_id");
b.ToTable("user_claims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text")
.HasColumnName("login_provider");
b.Property<string>("ProviderKey")
.HasColumnType("text")
.HasColumnName("provider_key");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text")
.HasColumnName("provider_display_name");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("LoginProvider", "ProviderKey")
.HasName("pk_user_logins");
b.HasIndex("UserId")
.HasDatabaseName("ix_user_logins_user_id");
b.ToTable("user_logins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.Property<string>("LoginProvider")
.HasColumnType("text")
.HasColumnName("login_provider");
b.Property<string>("Name")
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Value")
.HasColumnType("text")
.HasColumnName("value");
b.HasKey("UserId", "LoginProvider", "Name")
.HasName("pk_user_tokens");
b.ToTable("user_tokens", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Catalog.Category", b =>
{
b.Property<Guid>("Id")
@@ -7918,11 +8022,21 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer")
.HasColumnName("access_failed_count");
b.Property<string>("AvatarUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)")
.HasColumnName("avatar_url");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("concurrency_stamp");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
@@ -7934,6 +8048,14 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnType("citext")
.HasColumnName("email");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean")
.HasColumnName("email_confirmed");
b.Property<bool>("ForcePasswordChange")
.HasColumnType("boolean")
.HasColumnName("force_password_change");
b.Property<DateTimeOffset?>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
@@ -7943,25 +8065,48 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(64)")
.HasColumnName("legacy_id");
b.Property<string>("LegacyPasswordHash")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("legacy_password_hash");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean")
.HasColumnName("lockout_enabled");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone")
.HasColumnName("lockout_end");
b.Property<string>("Name")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("name");
b.Property<bool>("PasswordMigrationRequired")
.HasColumnType("boolean")
.HasColumnName("password_migration_required");
b.Property<string>("NormalizedEmail")
.HasMaxLength(320)
.HasColumnType("character varying(320)")
.HasColumnName("normalized_email");
b.Property<string>("NormalizedUserName")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("normalized_user_name");
b.Property<string>("PasswordHash")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("password_hash");
b.Property<string>("Phone")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("phone");
b.Property<string>("PhoneNumber")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("phone_number");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean")
.HasColumnName("phone_number_confirmed");
b.Property<string>("PrimaryRole")
.IsRequired()
.HasMaxLength(50)
@@ -7978,36 +8123,50 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnType("integer")
.HasColumnName("score");
b.Property<string>("SecurityStamp")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("security_stamp");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean")
.HasColumnName("two_factor_enabled");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.Property<string>("Username")
b.Property<string>("UserName")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("username");
.HasColumnName("user_name");
b.HasKey("Id")
.HasName("pk_users");
b.HasIndex("Email")
.IsUnique()
.HasDatabaseName("ix_users_email");
b.HasIndex("LegacyId")
.IsUnique()
.HasDatabaseName("ix_users_legacy_id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("email_index");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("user_name_index");
b.HasIndex("Phone")
.IsUnique()
.HasDatabaseName("ix_users_phone");
b.HasIndex("Username")
.IsUnique()
.HasDatabaseName("ix_users_username");
b.ToTable("users", (string)null);
});
@@ -8052,12 +8211,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(255)")
.HasColumnName("provider_subject");
b.Property<JsonElement>("SecretPayload")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("secret_payload")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("UnionId")
.HasMaxLength(255)
.HasColumnType("character varying(255)")
@@ -12651,6 +12804,95 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("question_versions", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Tenancy.AuthChallenge", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("ConsumedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("consumed_at");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<string>("IpAddress")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("ip_address");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("provider");
b.Property<string>("Purpose")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("purpose");
b.Property<string>("Realm")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("realm");
b.Property<string>("SecurityStamp")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("security_stamp");
b.Property<Guid?>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("token_hash");
b.Property<string>("UserAgent")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("user_agent");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_auth_challenges");
b.HasIndex("TenantId")
.HasDatabaseName("ix_auth_challenges_tenant_id");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("ix_auth_challenges_token_hash");
b.HasIndex("UserId", "Purpose", "ExpiresAt")
.HasDatabaseName("ix_auth_challenges_user_id_purpose_expires_at");
b.ToTable("auth_challenges", null, t =>
{
t.HasCheckConstraint("ck_auth_challenges_realm_tenant", "(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)");
});
});
modelBuilder.Entity("Tiku.Domain.Tenancy.AuthLoginEvent", b =>
{
b.Property<Guid>("Id")
@@ -12755,20 +12997,53 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<bool>("MfaSatisfied")
.HasColumnType("boolean")
.HasColumnName("mfa_satisfied");
b.Property<Guid?>("ParentSessionId")
.HasColumnType("uuid")
.HasColumnName("parent_session_id");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("provider");
b.Property<string>("Realm")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("realm");
b.Property<Guid?>("ReplacedBySessionId")
.HasColumnType("uuid")
.HasColumnName("replaced_by_session_id");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("revoked_at");
b.Property<Guid>("TenantId")
b.Property<string>("RevokedReason")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("revoked_reason");
b.Property<string>("SecurityStamp")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("security_stamp");
b.Property<Guid?>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<Guid>("TokenFamilyId")
.HasColumnType("uuid")
.HasColumnName("token_family_id");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(256)
@@ -12793,8 +13068,8 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.HasKey("Id")
.HasName("pk_auth_sessions");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_auth_sessions_tenant_id_id");
b.HasIndex("TenantId")
.HasDatabaseName("ix_auth_sessions_tenant_id");
b.HasIndex("TokenHash")
.IsUnique()
@@ -12804,11 +13079,17 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.HasIndex("UserId")
.HasDatabaseName("ix_auth_sessions_user_id");
b.HasIndex("TenantId", "UserId", "ExpiresAt")
.HasDatabaseName("ix_auth_sessions_tenant_id_user_id_expires_at")
b.HasIndex("TokenFamilyId", "RevokedAt")
.HasDatabaseName("ix_auth_sessions_token_family_id_revoked_at");
b.HasIndex("Realm", "TenantId", "UserId", "ExpiresAt")
.HasDatabaseName("ix_auth_sessions_realm_tenant_id_user_id_expires_at")
.HasFilter("revoked_at is null");
b.ToTable("auth_sessions", (string)null);
b.ToTable("auth_sessions", null, t =>
{
t.HasCheckConstraint("ck_auth_sessions_realm_tenant", "(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)");
});
});
modelBuilder.Entity("Tiku.Domain.Tenancy.SmsSendRateLimit", b =>
@@ -12934,9 +13215,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.HasKey("Id")
.HasName("pk_sms_verification_codes");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_sms_verification_codes_tenant_id_id");
b.HasIndex("TenantId", "Phone", "Purpose")
.IsUnique()
.HasDatabaseName("ix_sms_verification_codes_tenant_id_phone_purpose")
@@ -13626,22 +13904,12 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(50)")
.HasColumnName("legacy_role");
b.Property<JsonElement>("Permissions")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("permissions")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("role");
b.Property<Guid?>("RoleTemplateId")
.HasColumnType("uuid")
.HasColumnName("role_template_id");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
@@ -13671,9 +13939,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.HasIndex("UserId")
.HasDatabaseName("ix_tenant_memberships_user_id");
b.HasIndex("TenantId", "RoleTemplateId")
.HasDatabaseName("ix_tenant_memberships_tenant_id_role_template_id");
b.HasIndex("TenantId", "UserId", "Role")
.IsUnique()
.HasDatabaseName("ix_tenant_memberships_tenant_id_user_id_role");
@@ -13681,126 +13946,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("tenant_memberships", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantRoleTemplate", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("BaseRole")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("base_role");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("code");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uuid")
.HasColumnName("created_by");
b.Property<JsonElement>("DataScope")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("data_scope")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<JsonElement>("FieldPermissions")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("field_permissions")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<bool>("IsSystem")
.HasColumnType("boolean")
.HasColumnName("is_system");
b.Property<JsonElement>("MenuPermissions")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("menu_permissions")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<JsonElement>("ModulePermissions")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("module_permissions")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("name");
b.Property<JsonElement>("Permissions")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("permissions")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<int>("SortOrder")
.HasColumnType("integer")
.HasColumnName("sort_order");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uuid")
.HasColumnName("updated_by");
b.HasKey("Id")
.HasName("pk_tenant_role_templates");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_tenant_role_templates_tenant_id_id");
b.HasIndex("CreatedBy")
.HasDatabaseName("ix_tenant_role_templates_created_by");
b.HasIndex("UpdatedBy")
.HasDatabaseName("ix_tenant_role_templates_updated_by");
b.HasIndex("TenantId", "Code")
.IsUnique()
.HasDatabaseName("ix_tenant_role_templates_tenant_id_code");
b.HasIndex("TenantId", "Status", "SortOrder")
.HasDatabaseName("ix_tenant_role_templates_tenant_id_status_sort_order");
b.ToTable("tenant_role_templates", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantSecret", b =>
{
b.Property<Guid>("Id")
@@ -14156,6 +14301,36 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("tenant_student_notes", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_user_claims_users_user_id");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_user_logins_users_user_id");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_user_tokens_users_user_id");
});
modelBuilder.Entity("Tiku.Domain.Catalog.Category", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
@@ -17207,12 +17382,28 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_question_versions_questions_tenant_id_question_id");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.AuthLoginEvent", b =>
modelBuilder.Entity("Tiku.Domain.Tenancy.AuthChallenge", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("fk_auth_challenges_tenants_tenant_id");
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_auth_challenges_users_user_id");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.AuthLoginEvent", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.SetNull)
.IsRequired()
.HasConstraintName("fk_auth_login_events_tenants_tenant_id");
@@ -17229,7 +17420,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_auth_sessions_tenants_tenant_id");
b.HasOne("Tiku.Domain.Identity.User", null)
@@ -17378,35 +17568,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tenant_memberships_users_user_id");
b.HasOne("Tiku.Domain.Tenancy.TenantRoleTemplate", null)
.WithMany()
.HasForeignKey("TenantId", "RoleTemplateId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_tenant_memberships_tenant_role_templates_tenant_id_role_tem~");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantRoleTemplate", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_tenant_role_templates_users_created_by");
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tenant_role_templates_tenants_tenant_id");
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_tenant_role_templates_users_updated_by");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantSecret", b =>

View File

@@ -1,7 +1,7 @@
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Migrations;
using Tiku.Infrastructure.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
@@ -38,6 +38,20 @@ namespace Tiku.Infrastructure.Persistence.Migrations
table.UniqueConstraint("ak_backend_permissions_code", x => x.code);
});
migrationBuilder.CreateTable(
name: "data_protection_keys",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
friendly_name = table.Column<string>(type: "text", nullable: true),
xml = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_data_protection_keys", x => x.id);
});
migrationBuilder.CreateTable(
name: "platform_backend_roles",
columns: table => new
@@ -137,19 +151,31 @@ namespace Tiku.Infrastructure.Persistence.Migrations
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
legacy_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
username = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
email = table.Column<string>(type: "citext", maxLength: 320, nullable: true),
phone = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
avatar_url = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
primary_role = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
score = table.Column<int>(type: "integer", nullable: false),
last_seen_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
legacy_password_hash = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
password_migration_required = table.Column<bool>(type: "boolean", nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
force_password_change = table.Column<bool>(type: "boolean", nullable: false),
raw_profile = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
user_name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
normalized_user_name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
email = table.Column<string>(type: "citext", maxLength: 320, nullable: true),
normalized_email = table.Column<string>(type: "character varying(320)", maxLength: 320, nullable: true),
email_confirmed = table.Column<bool>(type: "boolean", nullable: false),
password_hash = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
security_stamp = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
concurrency_stamp = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
phone_number = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
phone_number_confirmed = table.Column<bool>(type: "boolean", nullable: false),
two_factor_enabled = table.Column<bool>(type: "boolean", nullable: false),
lockout_end = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
lockout_enabled = table.Column<bool>(type: "boolean", nullable: false),
access_failed_count = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
@@ -265,6 +291,27 @@ namespace Tiku.Infrastructure.Persistence.Migrations
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "user_claims",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
user_id = table.Column<Guid>(type: "uuid", nullable: false),
claim_type = table.Column<string>(type: "text", nullable: true),
claim_value = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_user_claims", x => x.id);
table.ForeignKey(
name: "fk_user_claims_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "user_identities",
columns: table => new
@@ -277,7 +324,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
open_id = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
phone = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
email = table.Column<string>(type: "citext", maxLength: 320, nullable: true),
secret_payload = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
@@ -292,6 +338,46 @@ namespace Tiku.Infrastructure.Persistence.Migrations
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "user_logins",
columns: table => new
{
login_provider = table.Column<string>(type: "text", nullable: false),
provider_key = table.Column<string>(type: "text", nullable: false),
provider_display_name = table.Column<string>(type: "text", nullable: true),
user_id = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_user_logins", x => new { x.login_provider, x.provider_key });
table.ForeignKey(
name: "fk_user_logins_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "user_tokens",
columns: table => new
{
user_id = table.Column<Guid>(type: "uuid", nullable: false),
login_provider = table.Column<string>(type: "text", nullable: false),
name = table.Column<string>(type: "text", nullable: false),
value = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_user_tokens", x => new { x.user_id, x.login_provider, x.name });
table.ForeignKey(
name: "fk_user_tokens_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "platform_backend_role_menus",
columns: table => new
@@ -403,6 +489,42 @@ namespace Tiku.Infrastructure.Persistence.Migrations
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "auth_challenges",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
user_id = table.Column<Guid>(type: "uuid", nullable: false),
realm = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: true),
purpose = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
token_hash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
security_stamp = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
provider = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
expires_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
consumed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
ip_address = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
user_agent = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_auth_challenges", x => x.id);
table.CheckConstraint("ck_auth_challenges_realm_tenant", "(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)");
table.ForeignKey(
name: "fk_auth_challenges_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_auth_challenges_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "auth_login_events",
columns: table => new
@@ -428,7 +550,7 @@ namespace Tiku.Infrastructure.Persistence.Migrations
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_auth_login_events_users_user_id",
column: x => x.user_id,
@@ -442,22 +564,29 @@ namespace Tiku.Infrastructure.Persistence.Migrations
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
realm = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: true),
user_id = table.Column<Guid>(type: "uuid", nullable: false),
token_family_id = table.Column<Guid>(type: "uuid", nullable: false),
parent_session_id = table.Column<Guid>(type: "uuid", nullable: true),
replaced_by_session_id = table.Column<Guid>(type: "uuid", nullable: true),
token_hash = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
security_stamp = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
mfa_satisfied = table.Column<bool>(type: "boolean", nullable: false),
provider = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
expires_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
revoked_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
revoked_reason = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
ip_address = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
user_agent = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_auth_sessions", x => x.id);
table.UniqueConstraint("ak_auth_sessions_tenant_id_id", x => new { x.tenant_id, x.id });
table.CheckConstraint("ck_auth_sessions_realm_tenant", "(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)");
table.ForeignKey(
name: "fk_auth_sessions_tenants_tenant_id",
column: x => x.tenant_id,
@@ -1186,7 +1315,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
constraints: table =>
{
table.PrimaryKey("pk_sms_verification_codes", x => x.id);
table.UniqueConstraint("ak_sms_verification_codes_tenant_id_id", x => new { x.tenant_id, x.id });
table.CheckConstraint("ck_sms_verification_codes_attempts", "attempts >= 0");
table.ForeignKey(
name: "fk_sms_verification_codes_tenants_tenant_id",
@@ -1498,50 +1626,34 @@ namespace Tiku.Infrastructure.Persistence.Migrations
});
migrationBuilder.CreateTable(
name: "tenant_role_templates",
name: "tenant_memberships",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
description = table.Column<string>(type: "text", nullable: true),
base_role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
user_id = table.Column<Guid>(type: "uuid", nullable: false),
role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
permissions = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
menu_permissions = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
module_permissions = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
field_permissions = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
data_scope = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
is_system = table.Column<bool>(type: "boolean", nullable: false),
sort_order = table.Column<int>(type: "integer", nullable: false),
created_by = table.Column<Guid>(type: "uuid", nullable: true),
updated_by = table.Column<Guid>(type: "uuid", nullable: true),
legacy_role = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_tenant_role_templates", x => x.id);
table.UniqueConstraint("ak_tenant_role_templates_tenant_id_id", x => new { x.tenant_id, x.id });
table.PrimaryKey("pk_tenant_memberships", x => x.id);
table.UniqueConstraint("ak_tenant_memberships_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_tenant_role_templates_tenants_tenant_id",
name: "fk_tenant_memberships_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_tenant_role_templates_users_created_by",
column: x => x.created_by,
name: "fk_tenant_memberships_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_tenant_role_templates_users_updated_by",
column: x => x.updated_by,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
@@ -2885,45 +2997,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "tenant_memberships",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
user_id = table.Column<Guid>(type: "uuid", nullable: false),
role_template_id = table.Column<Guid>(type: "uuid", nullable: true),
role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
permissions = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
legacy_role = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_tenant_memberships", x => x.id);
table.UniqueConstraint("ak_tenant_memberships_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_tenant_memberships_tenant_role_templates_tenant_id_role_tem~",
columns: x => new { x.tenant_id, x.role_template_id },
principalTable: "tenant_role_templates",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_tenant_memberships_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_tenant_memberships_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "content_nodes",
columns: table => new
@@ -6392,6 +6465,22 @@ namespace Tiku.Infrastructure.Persistence.Migrations
table: "audit_logs",
columns: new[] { "tenant_id", "target_type", "target_id", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_auth_challenges_tenant_id",
table: "auth_challenges",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "ix_auth_challenges_token_hash",
table: "auth_challenges",
column: "token_hash",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_auth_challenges_user_id_purpose_expires_at",
table: "auth_challenges",
columns: new[] { "user_id", "purpose", "expires_at" });
migrationBuilder.CreateIndex(
name: "ix_auth_login_events_tenant_id_user_id_created_at",
table: "auth_login_events",
@@ -6403,11 +6492,21 @@ namespace Tiku.Infrastructure.Persistence.Migrations
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_auth_sessions_tenant_id_user_id_expires_at",
name: "ix_auth_sessions_realm_tenant_id_user_id_expires_at",
table: "auth_sessions",
columns: new[] { "tenant_id", "user_id", "expires_at" },
columns: new[] { "realm", "tenant_id", "user_id", "expires_at" },
filter: "revoked_at is null");
migrationBuilder.CreateIndex(
name: "ix_auth_sessions_tenant_id",
table: "auth_sessions",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "ix_auth_sessions_token_family_id_revoked_at",
table: "auth_sessions",
columns: new[] { "token_family_id", "revoked_at" });
migrationBuilder.CreateIndex(
name: "ix_auth_sessions_token_hash",
table: "auth_sessions",
@@ -8609,11 +8708,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
table: "tenant_invoices",
columns: new[] { "tenant_id", "status", "due_date" });
migrationBuilder.CreateIndex(
name: "ix_tenant_memberships_tenant_id_role_template_id",
table: "tenant_memberships",
columns: new[] { "tenant_id", "role_template_id" });
migrationBuilder.CreateIndex(
name: "ix_tenant_memberships_tenant_id_user_id_role",
table: "tenant_memberships",
@@ -8656,27 +8750,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
table: "tenant_question_references",
columns: new[] { "question_owner_tenant_id", "question_id" });
migrationBuilder.CreateIndex(
name: "ix_tenant_role_templates_created_by",
table: "tenant_role_templates",
column: "created_by");
migrationBuilder.CreateIndex(
name: "ix_tenant_role_templates_tenant_id_code",
table: "tenant_role_templates",
columns: new[] { "tenant_id", "code" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_tenant_role_templates_tenant_id_status_sort_order",
table: "tenant_role_templates",
columns: new[] { "tenant_id", "status", "sort_order" });
migrationBuilder.CreateIndex(
name: "ix_tenant_role_templates_updated_by",
table: "tenant_role_templates",
column: "updated_by");
migrationBuilder.CreateIndex(
name: "ix_tenant_secrets_tenant_id_purpose_provider_secret_key",
table: "tenant_secrets",
@@ -8850,6 +8923,11 @@ namespace Tiku.Infrastructure.Persistence.Migrations
table: "user_badges",
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_user_claims_user_id",
table: "user_claims",
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_user_identities_provider_provider_subject",
table: "user_identities",
@@ -8861,6 +8939,11 @@ namespace Tiku.Infrastructure.Persistence.Migrations
table: "user_identities",
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_user_logins_user_id",
table: "user_logins",
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_user_notifications_created_by",
table: "user_notifications",
@@ -8942,10 +9025,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_users_email",
name: "email_index",
table: "users",
column: "email",
unique: true);
column: "normalized_email");
migrationBuilder.CreateIndex(
name: "ix_users_legacy_id",
@@ -8960,9 +9042,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations
unique: true);
migrationBuilder.CreateIndex(
name: "ix_users_username",
name: "user_name_index",
table: "users",
column: "username",
column: "normalized_user_name",
unique: true);
migrationBuilder.CreateIndex(
@@ -9347,6 +9429,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations
migrationBuilder.DropTable(
name: "app_assets");
migrationBuilder.DropTable(
name: "auth_challenges");
migrationBuilder.DropTable(
name: "auth_login_events");
@@ -9395,6 +9480,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations
migrationBuilder.DropTable(
name: "dashboard_daily_stats");
migrationBuilder.DropTable(
name: "data_protection_keys");
migrationBuilder.DropTable(
name: "entitlements");
@@ -9578,15 +9666,24 @@ namespace Tiku.Infrastructure.Persistence.Migrations
migrationBuilder.DropTable(
name: "user_badges");
migrationBuilder.DropTable(
name: "user_claims");
migrationBuilder.DropTable(
name: "user_identities");
migrationBuilder.DropTable(
name: "user_logins");
migrationBuilder.DropTable(
name: "user_notifications");
migrationBuilder.DropTable(
name: "user_score_events");
migrationBuilder.DropTable(
name: "user_tokens");
migrationBuilder.DropTable(
name: "user_word_favorites");
@@ -9662,9 +9759,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
migrationBuilder.DropTable(
name: "tenant_backend_roles");
migrationBuilder.DropTable(
name: "tenant_role_templates");
migrationBuilder.DropTable(
name: "tenant_classes");

View File

@@ -25,6 +25,110 @@ namespace Tiku.Infrastructure.Persistence.Migrations
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("FriendlyName")
.HasColumnType("text")
.HasColumnName("friendly_name");
b.Property<string>("Xml")
.HasColumnType("text")
.HasColumnName("xml");
b.HasKey("Id")
.HasName("pk_data_protection_keys");
b.ToTable("data_protection_keys", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text")
.HasColumnName("claim_type");
b.Property<string>("ClaimValue")
.HasColumnType("text")
.HasColumnName("claim_value");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_user_claims");
b.HasIndex("UserId")
.HasDatabaseName("ix_user_claims_user_id");
b.ToTable("user_claims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text")
.HasColumnName("login_provider");
b.Property<string>("ProviderKey")
.HasColumnType("text")
.HasColumnName("provider_key");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text")
.HasColumnName("provider_display_name");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("LoginProvider", "ProviderKey")
.HasName("pk_user_logins");
b.HasIndex("UserId")
.HasDatabaseName("ix_user_logins_user_id");
b.ToTable("user_logins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.Property<string>("LoginProvider")
.HasColumnType("text")
.HasColumnName("login_provider");
b.Property<string>("Name")
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Value")
.HasColumnType("text")
.HasColumnName("value");
b.HasKey("UserId", "LoginProvider", "Name")
.HasName("pk_user_tokens");
b.ToTable("user_tokens", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Catalog.Category", b =>
{
b.Property<Guid>("Id")
@@ -7915,11 +8019,21 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer")
.HasColumnName("access_failed_count");
b.Property<string>("AvatarUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)")
.HasColumnName("avatar_url");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("concurrency_stamp");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
@@ -7931,6 +8045,14 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnType("citext")
.HasColumnName("email");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean")
.HasColumnName("email_confirmed");
b.Property<bool>("ForcePasswordChange")
.HasColumnType("boolean")
.HasColumnName("force_password_change");
b.Property<DateTimeOffset?>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
@@ -7940,25 +8062,48 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(64)")
.HasColumnName("legacy_id");
b.Property<string>("LegacyPasswordHash")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("legacy_password_hash");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean")
.HasColumnName("lockout_enabled");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone")
.HasColumnName("lockout_end");
b.Property<string>("Name")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("name");
b.Property<bool>("PasswordMigrationRequired")
.HasColumnType("boolean")
.HasColumnName("password_migration_required");
b.Property<string>("NormalizedEmail")
.HasMaxLength(320)
.HasColumnType("character varying(320)")
.HasColumnName("normalized_email");
b.Property<string>("NormalizedUserName")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("normalized_user_name");
b.Property<string>("PasswordHash")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("password_hash");
b.Property<string>("Phone")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("phone");
b.Property<string>("PhoneNumber")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("phone_number");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean")
.HasColumnName("phone_number_confirmed");
b.Property<string>("PrimaryRole")
.IsRequired()
.HasMaxLength(50)
@@ -7975,36 +8120,50 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnType("integer")
.HasColumnName("score");
b.Property<string>("SecurityStamp")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("security_stamp");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean")
.HasColumnName("two_factor_enabled");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.Property<string>("Username")
b.Property<string>("UserName")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("username");
.HasColumnName("user_name");
b.HasKey("Id")
.HasName("pk_users");
b.HasIndex("Email")
.IsUnique()
.HasDatabaseName("ix_users_email");
b.HasIndex("LegacyId")
.IsUnique()
.HasDatabaseName("ix_users_legacy_id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("email_index");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("user_name_index");
b.HasIndex("Phone")
.IsUnique()
.HasDatabaseName("ix_users_phone");
b.HasIndex("Username")
.IsUnique()
.HasDatabaseName("ix_users_username");
b.ToTable("users", (string)null);
});
@@ -8049,12 +8208,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(255)")
.HasColumnName("provider_subject");
b.Property<JsonElement>("SecretPayload")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("secret_payload")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("UnionId")
.HasMaxLength(255)
.HasColumnType("character varying(255)")
@@ -12648,6 +12801,95 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("question_versions", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Tenancy.AuthChallenge", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("ConsumedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("consumed_at");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<string>("IpAddress")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("ip_address");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("provider");
b.Property<string>("Purpose")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("purpose");
b.Property<string>("Realm")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("realm");
b.Property<string>("SecurityStamp")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("security_stamp");
b.Property<Guid?>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("token_hash");
b.Property<string>("UserAgent")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("user_agent");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_auth_challenges");
b.HasIndex("TenantId")
.HasDatabaseName("ix_auth_challenges_tenant_id");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("ix_auth_challenges_token_hash");
b.HasIndex("UserId", "Purpose", "ExpiresAt")
.HasDatabaseName("ix_auth_challenges_user_id_purpose_expires_at");
b.ToTable("auth_challenges", null, t =>
{
t.HasCheckConstraint("ck_auth_challenges_realm_tenant", "(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)");
});
});
modelBuilder.Entity("Tiku.Domain.Tenancy.AuthLoginEvent", b =>
{
b.Property<Guid>("Id")
@@ -12752,20 +12994,53 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<bool>("MfaSatisfied")
.HasColumnType("boolean")
.HasColumnName("mfa_satisfied");
b.Property<Guid?>("ParentSessionId")
.HasColumnType("uuid")
.HasColumnName("parent_session_id");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("provider");
b.Property<string>("Realm")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("realm");
b.Property<Guid?>("ReplacedBySessionId")
.HasColumnType("uuid")
.HasColumnName("replaced_by_session_id");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("revoked_at");
b.Property<Guid>("TenantId")
b.Property<string>("RevokedReason")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("revoked_reason");
b.Property<string>("SecurityStamp")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("security_stamp");
b.Property<Guid?>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<Guid>("TokenFamilyId")
.HasColumnType("uuid")
.HasColumnName("token_family_id");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(256)
@@ -12790,8 +13065,8 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.HasKey("Id")
.HasName("pk_auth_sessions");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_auth_sessions_tenant_id_id");
b.HasIndex("TenantId")
.HasDatabaseName("ix_auth_sessions_tenant_id");
b.HasIndex("TokenHash")
.IsUnique()
@@ -12801,11 +13076,17 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.HasIndex("UserId")
.HasDatabaseName("ix_auth_sessions_user_id");
b.HasIndex("TenantId", "UserId", "ExpiresAt")
.HasDatabaseName("ix_auth_sessions_tenant_id_user_id_expires_at")
b.HasIndex("TokenFamilyId", "RevokedAt")
.HasDatabaseName("ix_auth_sessions_token_family_id_revoked_at");
b.HasIndex("Realm", "TenantId", "UserId", "ExpiresAt")
.HasDatabaseName("ix_auth_sessions_realm_tenant_id_user_id_expires_at")
.HasFilter("revoked_at is null");
b.ToTable("auth_sessions", (string)null);
b.ToTable("auth_sessions", null, t =>
{
t.HasCheckConstraint("ck_auth_sessions_realm_tenant", "(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)");
});
});
modelBuilder.Entity("Tiku.Domain.Tenancy.SmsSendRateLimit", b =>
@@ -12931,9 +13212,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.HasKey("Id")
.HasName("pk_sms_verification_codes");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_sms_verification_codes_tenant_id_id");
b.HasIndex("TenantId", "Phone", "Purpose")
.IsUnique()
.HasDatabaseName("ix_sms_verification_codes_tenant_id_phone_purpose")
@@ -13623,22 +13901,12 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(50)")
.HasColumnName("legacy_role");
b.Property<JsonElement>("Permissions")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("permissions")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("role");
b.Property<Guid?>("RoleTemplateId")
.HasColumnType("uuid")
.HasColumnName("role_template_id");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
@@ -13668,9 +13936,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.HasIndex("UserId")
.HasDatabaseName("ix_tenant_memberships_user_id");
b.HasIndex("TenantId", "RoleTemplateId")
.HasDatabaseName("ix_tenant_memberships_tenant_id_role_template_id");
b.HasIndex("TenantId", "UserId", "Role")
.IsUnique()
.HasDatabaseName("ix_tenant_memberships_tenant_id_user_id_role");
@@ -13678,126 +13943,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("tenant_memberships", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantRoleTemplate", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("BaseRole")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("base_role");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("code");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uuid")
.HasColumnName("created_by");
b.Property<JsonElement>("DataScope")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("data_scope")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<JsonElement>("FieldPermissions")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("field_permissions")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<bool>("IsSystem")
.HasColumnType("boolean")
.HasColumnName("is_system");
b.Property<JsonElement>("MenuPermissions")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("menu_permissions")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<JsonElement>("ModulePermissions")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("module_permissions")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("name");
b.Property<JsonElement>("Permissions")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("permissions")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<int>("SortOrder")
.HasColumnType("integer")
.HasColumnName("sort_order");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uuid")
.HasColumnName("updated_by");
b.HasKey("Id")
.HasName("pk_tenant_role_templates");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_tenant_role_templates_tenant_id_id");
b.HasIndex("CreatedBy")
.HasDatabaseName("ix_tenant_role_templates_created_by");
b.HasIndex("UpdatedBy")
.HasDatabaseName("ix_tenant_role_templates_updated_by");
b.HasIndex("TenantId", "Code")
.IsUnique()
.HasDatabaseName("ix_tenant_role_templates_tenant_id_code");
b.HasIndex("TenantId", "Status", "SortOrder")
.HasDatabaseName("ix_tenant_role_templates_tenant_id_status_sort_order");
b.ToTable("tenant_role_templates", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantSecret", b =>
{
b.Property<Guid>("Id")
@@ -14153,6 +14298,36 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("tenant_student_notes", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_user_claims_users_user_id");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_user_logins_users_user_id");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_user_tokens_users_user_id");
});
modelBuilder.Entity("Tiku.Domain.Catalog.Category", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
@@ -17204,12 +17379,28 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_question_versions_questions_tenant_id_question_id");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.AuthLoginEvent", b =>
modelBuilder.Entity("Tiku.Domain.Tenancy.AuthChallenge", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("fk_auth_challenges_tenants_tenant_id");
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_auth_challenges_users_user_id");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.AuthLoginEvent", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.SetNull)
.IsRequired()
.HasConstraintName("fk_auth_login_events_tenants_tenant_id");
@@ -17226,7 +17417,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_auth_sessions_tenants_tenant_id");
b.HasOne("Tiku.Domain.Identity.User", null)
@@ -17375,35 +17565,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tenant_memberships_users_user_id");
b.HasOne("Tiku.Domain.Tenancy.TenantRoleTemplate", null)
.WithMany()
.HasForeignKey("TenantId", "RoleTemplateId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_tenant_memberships_tenant_role_templates_tenant_id_role_tem~");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantRoleTemplate", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_tenant_role_templates_users_created_by");
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tenant_role_templates_tenants_tenant_id");
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_tenant_role_templates_users_updated_by");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantSecret", b =>

View File

@@ -1,4 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
using System.Reflection;
using Tiku.Application.Security;
using Tiku.Domain.Catalog;
@@ -18,7 +21,7 @@ namespace Tiku.Infrastructure.Persistence;
public sealed class TikuDbContext(
DbContextOptions<TikuDbContext> options,
ITenantContext tenantContext) : DbContext(options)
ITenantContext tenantContext) : IdentityUserContext<User, Guid>(options), IDataProtectionKeyContext
{
public TikuDbContext(DbContextOptions<TikuDbContext> options)
: this(options, CreateToolingTenantContext())
@@ -37,7 +40,8 @@ public sealed class TikuDbContext(
return context;
}
public DbSet<Tenant> Tenants => Set<Tenant>();
public DbSet<User> Users => Set<User>();
public new DbSet<User> Users => Set<User>();
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
public DbSet<UserIdentity> UserIdentities => Set<UserIdentity>();
public DbSet<TenantMembership> TenantMemberships => Set<TenantMembership>();
public DbSet<TenantDomain> TenantDomains => Set<TenantDomain>();
@@ -49,8 +53,8 @@ public sealed class TikuDbContext(
public DbSet<SmsVerificationCode> SmsVerificationCodes => Set<SmsVerificationCode>();
public DbSet<AuthLoginEvent> AuthLoginEvents => Set<AuthLoginEvent>();
public DbSet<AuthSession> AuthSessions => Set<AuthSession>();
public DbSet<AuthChallenge> AuthChallenges => Set<AuthChallenge>();
public DbSet<SmsSendRateLimit> SmsSendRateLimits => Set<SmsSendRateLimit>();
public DbSet<TenantRoleTemplate> TenantRoleTemplates => Set<TenantRoleTemplate>();
public DbSet<TenantClass> TenantClasses => Set<TenantClass>();
public DbSet<TenantClassMember> TenantClassMembers => Set<TenantClassMember>();
public DbSet<TenantStudentNote> TenantStudentNotes => Set<TenantStudentNote>();
@@ -186,9 +190,14 @@ public sealed class TikuDbContext(
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<IdentityUserClaim<Guid>>().ToTable("user_claims");
modelBuilder.Entity<IdentityUserLogin<Guid>>().ToTable("user_logins");
modelBuilder.Entity<IdentityUserToken<Guid>>().ToTable("user_tokens");
modelBuilder.HasPostgresExtension("citext");
modelBuilder.HasPostgresExtension("ltree");
modelBuilder.ApplyConfigurationsFromAssembly(typeof(TikuDbContext).Assembly);
modelBuilder.Entity<DataProtectionKey>().ToTable("data_protection_keys");
ApplyTenantQueryFilters(modelBuilder);
ValidateTenantModel(modelBuilder);
modelBuilder.UseSnakeCaseIdentifiers();
@@ -298,6 +307,15 @@ public sealed class TikuDbContext(
{
var now = DateTimeOffset.UtcNow;
foreach (var entry in ChangeTracker.Entries<User>().Where(entry => entry.State == EntityState.Modified))
{
if (entry.Property(user => user.Status).IsModified ||
entry.Property(user => user.PasswordHash).IsModified)
{
entry.Entity.SecurityStamp = Guid.NewGuid().ToString("N");
}
}
foreach (var entry in ChangeTracker.Entries<IHasTimestamps>())
{
if (entry.State == EntityState.Added)

View File

@@ -392,7 +392,7 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService
return new StudentProfileItem(
profile.Id,
user.Id,
user.Username,
user.UserName,
user.Phone,
user.Email,
user.Name,

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="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Options" />

View File

@@ -1,8 +1,10 @@
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Npgsql;
using System.Text.Json;
using Tiku.Application.Commerce;
using Tiku.Application.Auth;
using Tiku.Application.Growth;
@@ -11,6 +13,7 @@ using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Api;
using Tiku.Domain.Identity;
using Tiku.Domain.Operations;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Content;
using Tiku.Domain.Tenancy;
@@ -25,12 +28,32 @@ public sealed class ApiTestFactory(
IReferralQrcodeGenerator? referralQrcodeGenerator = null,
IPaymentProviderGateway? paymentProviderGateway = null,
IDomainOwnershipVerifier? domainOwnershipVerifier = null,
IDomainGatewayProvisioner? domainGatewayProvisioner = null) : WebApplicationFactory<ApiProgramMarker>
IDomainGatewayProvisioner? domainGatewayProvisioner = null,
ISmsProvider? smsProvider = null,
IReadOnlyDictionary<string, string?>? configurationOverrides = null) : WebApplicationFactory<ApiProgramMarker>
{
private readonly PostgresTestDatabase database = PostgresTestDatabase.Create();
protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((_, configuration) =>
{
var values = new Dictionary<string, string?>
{
["Security:Jwt:KeyId"] = TestJwtKeys.KeyId,
["Security:Jwt:PrivateKeyPem"] = TestJwtKeys.PrivateKeyPem,
["Tenancy:Resolution:TenantCodePathPrefixes:0"] = "/api"
};
if (configurationOverrides is not null)
{
foreach (var pair in configurationOverrides)
{
values[pair.Key] = pair.Value;
}
}
configuration.AddInMemoryCollection(values);
});
builder.ConfigureServices(services =>
{
foreach (var descriptor in services
@@ -53,6 +76,8 @@ public sealed class ApiTestFactory(
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName));
options.AddInterceptors(serviceProvider.GetRequiredService<TenantIsolationSaveChangesInterceptor>());
});
services.RemoveAll<IJwtKeyRing>();
services.AddSingleton<IJwtKeyRing, TestJwtKeyRing>();
if (wechatOAuthClient is not null)
{
@@ -85,6 +110,12 @@ public sealed class ApiTestFactory(
services.RemoveAll<IDomainGatewayProvisioner>();
services.AddSingleton(domainGatewayProvisioner);
}
if (smsProvider is not null)
{
services.RemoveAll<ISmsProvider>();
services.AddSingleton(smsProvider);
}
});
}
@@ -96,6 +127,91 @@ public sealed class ApiTestFactory(
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
dbContext.AddRange(entities);
await dbContext.SaveChangesAsync();
var backendMembers = entities
.OfType<TenantMembership>()
.Where(membership =>
membership.Status == MembershipStatus.Active &&
membership.Role is TenantRole.TenantOwner or TenantRole.TenantAdmin)
.Select(membership => (membership.TenantId, membership.UserId))
.Distinct()
.ToArray();
if (backendMembers.Length > 0)
{
await EnsureTenantBackendAccessAsync(dbContext, backendMembers);
}
}
private static async Task EnsureTenantBackendAccessAsync(
TikuDbContext dbContext,
IEnumerable<(Guid TenantId, Guid UserId)> members)
{
var permissionCodes = BackendPermissions.Tenant.Order(StringComparer.Ordinal).ToArray();
var existingPermissionCodes = await dbContext.BackendPermissions
.Where(permission => permissionCodes.Contains(permission.Code))
.Select(permission => permission.Code)
.ToListAsync();
foreach (var permissionCode in permissionCodes.Except(existingPermissionCodes, StringComparer.Ordinal))
{
dbContext.BackendPermissions.Add(new BackendPermission
{
Code = permissionCode,
Name = permissionCode,
Area = BackendPermissionArea.Tenant,
Module = permissionCode.Split(':')[1],
IsSystem = true
});
}
foreach (var tenantGroup in members.GroupBy(member => member.TenantId))
{
var tenantId = tenantGroup.Key;
var role = await dbContext.TenantBackendRoles.SingleOrDefaultAsync(entity =>
entity.TenantId == tenantId && entity.Code == "integration_test_admin");
if (role is null)
{
role = new TenantBackendRole
{
TenantId = tenantId,
Code = "integration_test_admin",
Name = "Integration Test Administrator",
Status = BackendRoleStatus.Active,
IsSystem = true,
DataScope = JsonSerializer.SerializeToElement(new { mode = "all" })
};
dbContext.TenantBackendRoles.Add(role);
}
var assignedPermissionCodes = await dbContext.TenantBackendRolePermissions
.Where(entity => entity.TenantId == tenantId && entity.RoleId == role.Id)
.Select(entity => entity.PermissionCode)
.ToListAsync();
foreach (var permissionCode in permissionCodes.Except(assignedPermissionCodes, StringComparer.Ordinal))
{
dbContext.TenantBackendRolePermissions.Add(new TenantBackendRolePermission
{
TenantId = tenantId,
RoleId = role.Id,
PermissionCode = permissionCode
});
}
var assignedUserIds = await dbContext.TenantBackendUserRoles
.Where(entity => entity.TenantId == tenantId && entity.RoleId == role.Id)
.Select(entity => entity.UserId)
.ToListAsync();
foreach (var member in tenantGroup.Where(member => !assignedUserIds.Contains(member.UserId)))
{
dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole
{
TenantId = tenantId,
UserId = member.UserId,
RoleId = role.Id
});
}
}
await dbContext.SaveChangesAsync();
}
public IServiceScope CreateSystemScope(string reason = "Integration test verification")
@@ -151,39 +267,51 @@ public sealed class ApiTestFactory(
public async Task<Guid> SeedActiveSessionAsync(
Guid userId,
Guid? tenantId = null,
string tokenHash = "integration-test-token-hash")
string tokenHash = "integration-test-token-hash",
bool includeMembership = false)
{
var resolvedTenantId = tenantId ?? Guid.NewGuid();
await SeedAsync(
var user = new User
{
Id = userId,
Phone = "13800000000"
};
var session = new AuthSession
{
Id = Guid.NewGuid(),
Realm = AuthRealm.Tenant,
TenantId = resolvedTenantId,
UserId = userId,
TokenFamilyId = Guid.NewGuid(),
TokenHash = tokenHash,
SecurityStamp = user.SecurityStamp ?? string.Empty,
Provider = "test",
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
};
var entities = new List<object>
{
new Tenant
{
Id = resolvedTenantId,
Slug = resolvedTenantId.ToString("N"),
Name = "Test Tenant"
},
new User
user,
session
};
if (includeMembership)
{
entities.Add(new TenantMembership
{
Id = userId,
Phone = "13800000000"
},
new AuthSession
{
Id = Guid.NewGuid(),
TenantId = resolvedTenantId,
UserId = userId,
TokenHash = tokenHash,
Provider = "test",
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
Role = TenantRole.Student,
Status = MembershipStatus.Active
});
}
using var scope = Services.CreateScope();
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
.InitializeSystem(resolvedTenantId, "Integration test session lookup");
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
return await dbContext.AuthSessions
.Where(session => session.UserId == userId)
.Select(session => session.Id)
.SingleAsync();
await SeedAsync([.. entities]);
return session.Id;
}
protected override void Dispose(bool disposing)

View File

@@ -214,8 +214,6 @@ public sealed class AssetAccessEndpointTests
{
var userId = Guid.NewGuid();
var phone = "13800000000";
var passwordHash = new PasswordHasher().Hash("passw0rd!");
await factory.SeedAsync(
Tenant(tenantId, tenantId.ToString("N")),
new User
@@ -223,21 +221,13 @@ public sealed class AssetAccessEndpointTests
Id = userId,
Phone = phone,
Name = "Test User"
},
}.WithTestPassword(),
new TenantMembership
{
TenantId = tenantId,
UserId = userId,
Role = TenantRole.Student,
Status = MembershipStatus.Active
},
new UserIdentity
{
UserId = userId,
Provider = "password",
ProviderSubject = phone,
Phone = phone,
SecretPayload = CreateSecretPayload(passwordHash)
});
return (tenantId, userId, phone);
@@ -247,20 +237,7 @@ public sealed class AssetAccessEndpointTests
HttpClient client,
(Guid TenantId, Guid UserId, string Phone) seed)
{
var loginResponse = await client.PostAsJsonAsync(
"/api/auth/login/password",
new PasswordLoginDto
{
TenantCode = seed.TenantId.ToString("N"),
Phone = seed.Phone,
Password = "passw0rd!"
});
var loginJson = await ReadJsonAsync(loginResponse);
var accessToken = loginJson.RootElement
.GetProperty("tokens")
.GetProperty("accessToken")
.GetString();
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
}
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
@@ -269,13 +246,6 @@ public sealed class AssetAccessEndpointTests
return await JsonDocument.ParseAsync(stream);
}
private static JsonElement CreateSecretPayload(string passwordHash)
{
using var document = JsonDocument.Parse(
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
return document.RootElement.Clone();
}
private sealed class FakeObjectStorageService : IObjectStorageService
{
public string ConfiguredDefaultProvider() => ObjectStorageProviders.LocalDev;

View File

@@ -255,8 +255,6 @@ public sealed class AssetManagementEndpointTests
var tenantId = Guid.NewGuid();
var userId = Guid.NewGuid();
var phone = "13900000000";
var passwordHash = new PasswordHasher().Hash("passw0rd!");
await factory.SeedAsync(
new Tenant
{
@@ -271,21 +269,13 @@ public sealed class AssetManagementEndpointTests
Id = userId,
Phone = phone,
Name = "Tenant Admin"
},
}.WithTestPassword(),
new TenantMembership
{
TenantId = tenantId,
UserId = userId,
Role = TenantRole.TenantAdmin,
Status = MembershipStatus.Active
},
new UserIdentity
{
UserId = userId,
Provider = "password",
ProviderSubject = phone,
Phone = phone,
SecretPayload = CreateSecretPayload(passwordHash)
});
return (tenantId, userId, phone);
@@ -295,20 +285,7 @@ public sealed class AssetManagementEndpointTests
HttpClient client,
(Guid TenantId, Guid UserId, string Phone) seed)
{
var loginResponse = await client.PostAsJsonAsync(
"/api/auth/login/password",
new PasswordLoginDto
{
TenantCode = seed.TenantId.ToString("N"),
Phone = seed.Phone,
Password = "passw0rd!"
});
var loginJson = await ReadJsonAsync(loginResponse);
var accessToken = loginJson.RootElement
.GetProperty("tokens")
.GetProperty("accessToken")
.GetString();
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
}
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
@@ -317,13 +294,6 @@ public sealed class AssetManagementEndpointTests
return await JsonDocument.ParseAsync(stream);
}
private static JsonElement CreateSecretPayload(string passwordHash)
{
using var document = JsonDocument.Parse(
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
return document.RootElement.Clone();
}
private sealed class FakeObjectStorageService : IObjectStorageService
{
public long? MetadataSizeBytes { get; init; }

View File

@@ -1,6 +1,7 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Tiku.Application.Auth;
@@ -15,6 +16,59 @@ namespace Tiku.IntegrationTests.Api;
public sealed class AuthEndpointTests
{
[Fact]
public async Task Sms_send_creates_login_code_without_exposing_it_and_rejects_platform_realm()
{
var provider = new CapturingSmsProvider();
await using var factory = new ApiTestFactory(smsProvider: provider);
var tenantId = Guid.NewGuid();
await factory.SeedAsync(new Tenant
{
Id = tenantId,
Slug = tenantId.ToString("N"),
Name = "SMS Tenant"
});
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
var response = await client.PostAsJsonAsync(
"/api/auth/sms/send",
new SendSmsCodeDto
{
Realm = AuthRealm.Tenant,
TenantCode = tenantId.ToString("N"),
Phone = "13800000000",
DeviceId = "sms-endpoint-device"
});
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
Assert.DoesNotContain(provider.Code!, body, StringComparison.Ordinal);
Assert.Matches("^[0-9]{6}$", provider.Code!);
using (var scope = factory.CreateSystemScope("Verify SMS send endpoint"))
{
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var dimensions = await dbContext.SmsSendRateLimits
.Select(item => item.Dimension)
.ToArrayAsync();
Assert.Contains(SmsRateLimitDimension.Tenant, dimensions);
Assert.Contains(SmsRateLimitDimension.Phone, dimensions);
Assert.Contains(SmsRateLimitDimension.Device, dimensions);
}
client.DefaultRequestHeaders.Remove("x-tenant-code");
var platformResponse = await client.PostAsJsonAsync(
"/api/auth/sms/send",
new SendSmsCodeDto
{
Realm = AuthRealm.Platform,
Phone = "13800000000",
DeviceId = "sms-endpoint-device"
});
Assert.Equal(HttpStatusCode.BadRequest, platformResponse.StatusCode);
Assert.Equal(1, provider.SendCount);
}
[Fact]
public async Task Custom_host_rejects_jwt_from_another_tenant_and_ignores_spoofed_tenant_header()
{
@@ -39,20 +93,11 @@ public sealed class AuthEndpointTests
IsPrimary = true
});
using var client = factory.CreateClient();
var loginResponse = await client.PostAsJsonAsync(
"/api/auth/login/password",
new PasswordLoginDto
{
TenantCode = tenantB.TenantId.ToString("N"),
Phone = tenantB.Phone,
Password = "passw0rd!"
});
var loginJson = await ReadJsonAsync(loginResponse);
var accessToken = loginJson.RootElement.GetProperty("tokens").GetProperty("accessToken").GetString();
var tokens = await client.LoginAsTenantAsync(tenantB.TenantId, tenantB.Phone);
using var jwtRequest = new HttpRequestMessage(HttpMethod.Get, "/api/me");
jwtRequest.Headers.Host = "a.example.test";
jwtRequest.Headers.Authorization = new("Bearer", accessToken);
jwtRequest.Headers.Authorization = new("Bearer", tokens.AccessToken);
var jwtResponse = await client.SendAsync(jwtRequest);
using var spoofRequest = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login/password");
@@ -60,8 +105,9 @@ public sealed class AuthEndpointTests
spoofRequest.Headers.Add("x-tenant-code", tenantB.TenantId.ToString("N"));
spoofRequest.Content = JsonContent.Create(new PasswordLoginDto
{
Realm = AuthRealm.Tenant,
Phone = tenantB.Phone,
Password = "passw0rd!"
Password = PasswordTestUserExtensions.TestPassword
});
var spoofResponse = await client.SendAsync(spoofRequest);
@@ -69,6 +115,54 @@ public sealed class AuthEndpointTests
Assert.NotEqual(HttpStatusCode.OK, spoofResponse.StatusCode);
}
[Fact]
public async Task Platform_authentication_artifacts_are_rejected_on_an_unconfigured_host()
{
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
{
["Tenancy:Resolution:ExemptPathPrefixes:3"] = "/api/auth"
});
using var client = factory.CreateClient();
var refreshToken = $"v2.p.-.{Guid.NewGuid():N}.{new string('a', 86)}";
var requests = new[]
{
new HttpRequestMessage(HttpMethod.Post, "/api/auth/login/password")
{
Content = JsonContent.Create(new PasswordLoginDto
{
Realm = AuthRealm.Platform,
Phone = "admin@example.com",
Password = PasswordTestUserExtensions.TestPassword
})
},
new HttpRequestMessage(HttpMethod.Post, "/api/auth/refresh")
{
Content = JsonContent.Create(new RefreshSessionDto { RefreshToken = refreshToken })
},
new HttpRequestMessage(HttpMethod.Post, "/api/auth/logout")
{
Content = JsonContent.Create(new RefreshSessionDto { RefreshToken = refreshToken })
},
new HttpRequestMessage(HttpMethod.Post, "/api/auth/mfa/totp/setup")
{
Content = JsonContent.Create(new MfaChallengeDto
{
ChallengeToken = $"c1.p.-.{new string('b', 86)}"
})
}
};
foreach (var request in requests)
{
using (request)
{
request.Headers.Host = "unconfigured.example.test";
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}
}
[Fact]
public async Task Password_login_can_access_current_user_and_tenant()
{
@@ -76,25 +170,11 @@ public sealed class AuthEndpointTests
var seed = await SeedLoginUserAsync(factory);
using var client = factory.CreateClient();
var loginResponse = await client.PostAsJsonAsync(
"/api/auth/login/password",
new PasswordLoginDto
{
TenantCode = seed.TenantId.ToString("N"),
Phone = seed.Phone,
Password = "passw0rd!"
});
var loginJson = await ReadJsonAsync(loginResponse);
var accessToken = loginJson.RootElement
.GetProperty("tokens")
.GetProperty("accessToken")
.GetString();
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
var tokens = await client.LoginAsTenantAsync(seed.TenantId, seed.Phone);
client.UseAccessToken(tokens);
var meResponse = await client.GetAsync("/api/me");
var tenantResponse = await client.GetAsync("/api/tenants/current");
Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, tenantResponse.StatusCode);
Assert.Contains(seed.UserId.ToString(), await meResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
@@ -108,22 +188,22 @@ public sealed class AuthEndpointTests
var seed = await SeedLoginUserAsync(factory);
await SeedSmsCodeAsync(factory, seed.TenantId, seed.Phone, "123456");
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N"));
var loginResponse = await client.PostAsJsonAsync(
"/api/auth/login/sms",
new SmsLoginDto
{
Realm = AuthRealm.Tenant,
TenantCode = seed.TenantId.ToString("N"),
Phone = seed.Phone,
Code = "123456"
});
var loginJson = await ReadJsonAsync(loginResponse);
var accessToken = loginJson.RootElement
.GetProperty("tokens")
.GetProperty("accessToken")
.GetString();
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
var tokens = await client.CompleteTenantAuthenticationAsync(
loginResponse,
seed.TenantId,
seed.Phone);
client.UseAccessToken(tokens);
var meResponse = await client.GetAsync("/api/me");
Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode);
@@ -136,27 +216,16 @@ public sealed class AuthEndpointTests
await using var factory = new ApiTestFactory();
var seed = await SeedLoginUserAsync(factory);
using var client = factory.CreateClient();
var loginResponse = await client.PostAsJsonAsync(
"/api/auth/login/password",
new PasswordLoginDto
{
TenantCode = seed.TenantId.ToString("N"),
Phone = seed.Phone,
Password = "passw0rd!"
});
var loginJson = await ReadJsonAsync(loginResponse);
var tokens = loginJson.RootElement.GetProperty("tokens");
var accessToken = tokens.GetProperty("accessToken").GetString();
var refreshToken = tokens.GetProperty("refreshToken").GetString();
var tokens = await client.LoginAsTenantAsync(seed.TenantId, seed.Phone);
var logoutResponse = await client.PostAsJsonAsync(
"/api/auth/logout",
new RefreshSessionDto { RefreshToken = refreshToken! });
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
new RefreshSessionDto { RefreshToken = tokens.RefreshToken });
client.UseAccessToken(tokens);
var meResponse = await client.GetAsync("/api/me");
var refreshResponse = await client.PostAsJsonAsync(
"/api/auth/refresh",
new RefreshSessionDto { RefreshToken = refreshToken! });
new RefreshSessionDto { RefreshToken = tokens.RefreshToken });
Assert.Equal(HttpStatusCode.NoContent, logoutResponse.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, meResponse.StatusCode);
@@ -206,16 +275,19 @@ public sealed class AuthEndpointTests
EncryptionTag = protectedSecret.Tag
});
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
var loginResponse = await client.PostAsJsonAsync(
"/api/auth/oauth/wechat-miniapp",
new OAuthCodeDto
{
Realm = AuthRealm.Tenant,
TenantCode = tenantId.ToString("N"),
Code = "wx-code"
});
var loginJson = await ReadJsonAsync(loginResponse);
var accessToken = loginJson.RootElement
.GetProperty("user")
.GetProperty("tokens")
.GetProperty("accessToken")
.GetString();
@@ -231,6 +303,13 @@ public sealed class AuthEndpointTests
identity.Provider == "wechat_miniapp" &&
identity.OpenId == "mini-open-id" &&
identity.UnionId == "union-id");
var persistedUser = dbContext.Users.Single(user =>
dbContext.UserIdentities.Any(identity =>
identity.UserId == user.Id && identity.Provider == "wechat_miniapp"));
Assert.DoesNotContain(
"session_key",
persistedUser.RawProfile.GetRawText(),
StringComparison.OrdinalIgnoreCase);
}
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLoginUserAsync(
@@ -239,8 +318,6 @@ public sealed class AuthEndpointTests
var tenantId = Guid.NewGuid();
var userId = Guid.NewGuid();
var phone = "13800000000";
var passwordHash = new PasswordHasher().Hash("passw0rd!");
await factory.SeedAsync(
new Tenant
{
@@ -253,21 +330,13 @@ public sealed class AuthEndpointTests
Id = userId,
Phone = phone,
Name = "Test User"
},
}.WithTestPassword(),
new TenantMembership
{
TenantId = tenantId,
UserId = userId,
Role = TenantRole.TenantAdmin,
Status = MembershipStatus.Active
},
new UserIdentity
{
UserId = userId,
Provider = "password",
ProviderSubject = phone,
Phone = phone,
SecretPayload = CreateSecretPayload(passwordHash)
});
return (tenantId, userId, phone);
@@ -281,12 +350,18 @@ public sealed class AuthEndpointTests
{
using var scope = factory.CreateSystemScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var smsOptions = scope.ServiceProvider.GetRequiredService<IOptions<SmsSecurityOptions>>().Value;
dbContext.SmsVerificationCodes.Add(new SmsVerificationCode
{
TenantId = tenantId,
Phone = phone,
Purpose = SmsPurpose.Login,
CodeHash = SmsCodeHashing.Hash(tenantId, phone, SmsPurpose.Login, code),
CodeHash = SmsCodeHashing.Hash(
tenantId,
phone,
SmsPurpose.Login,
code,
smsOptions.CodePepper),
Status = SmsVerificationStatus.Sent,
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5)
});
@@ -299,13 +374,6 @@ public sealed class AuthEndpointTests
return await JsonDocument.ParseAsync(stream);
}
private static JsonElement CreateSecretPayload(string passwordHash)
{
using var document = JsonDocument.Parse(
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
return document.RootElement.Clone();
}
private static ProtectedTenantSecret ProtectTenantSecret(Guid tenantId, string secretRef, JsonElement payload)
{
var protector = new TenantSecretProtector(Options.Create(new TenantSecretEncryptionOptions
@@ -346,4 +414,19 @@ public sealed class AuthEndpointTests
"""{"openid":"mini-open-id","unionid":"union-id","session_key":"session-key"}"""));
}
}
private sealed class CapturingSmsProvider : ISmsProvider
{
public int SendCount { get; private set; }
public string? Code { get; private set; }
public Task<SmsProviderSendResult> SendAsync(
SmsProviderSendRequest request,
CancellationToken cancellationToken = default)
{
SendCount++;
Code = request.Code;
return Task.FromResult(new SmsProviderSendResult("test", "sent", "sms-message-id"));
}
}
}

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.Http.Json;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using Tiku.Api.Contracts;
using Tiku.Api.Options;
using Tiku.Application.Auth;
@@ -21,13 +18,6 @@ namespace Tiku.IntegrationTests.Api;
public sealed class CommerceEndpointTests
{
private static readonly JwtOptions JwtOptions = new()
{
Issuer = "tiku-backend",
Audience = "tiku-api",
SigningKey = "development-only-tiku-signing-key-change-before-production"
};
[Fact]
public async Task Anonymous_commerce_request_returns_401()
{
@@ -60,11 +50,10 @@ public sealed class CommerceEndpointTests
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new(
"Bearer",
CreateToken([
TestJwtKeys.CreateToken([
new Claim(TikuClaimTypes.UserId, userId.ToString()),
new Claim(TikuClaimTypes.SessionId, sessionId.ToString()),
new Claim(TikuClaimTypes.TenantId, tenantId.ToString()),
new Claim(TikuClaimTypes.TenantRole, TenantRole.Student.ToString())
new Claim(TikuClaimTypes.TenantId, tenantId.ToString())
]));
var response = await client.PostAsJsonAsync(
@@ -343,7 +332,6 @@ public sealed class CommerceEndpointTests
var userId = Guid.NewGuid();
var planId = Guid.NewGuid();
var phone = "13800000000";
var passwordHash = new PasswordHasher().Hash("passw0rd!");
var entities = new List<object>
{
new Tenant
@@ -357,15 +345,7 @@ public sealed class CommerceEndpointTests
Id = userId,
Phone = phone,
Name = "Commerce User"
},
new UserIdentity
{
UserId = userId,
Provider = "password",
ProviderSubject = phone,
Phone = phone,
SecretPayload = CreateSecretPayload(passwordHash)
},
}.WithTestPassword(),
new SvipPlan
{
Id = planId,
@@ -401,44 +381,7 @@ public sealed class CommerceEndpointTests
private static async Task LoginAsync(HttpClient client, LoginSeed seed)
{
var loginResponse = await client.PostAsJsonAsync(
"/api/auth/login/password",
new PasswordLoginDto
{
TenantCode = seed.TenantId.ToString("N"),
Phone = seed.Phone,
Password = "passw0rd!"
});
loginResponse.EnsureSuccessStatusCode();
using var loginJson = await JsonDocument.ParseAsync(await loginResponse.Content.ReadAsStreamAsync());
var accessToken = loginJson.RootElement
.GetProperty("tokens")
.GetProperty("accessToken")
.GetString();
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
}
private static JsonElement CreateSecretPayload(string passwordHash)
{
using var document = JsonDocument.Parse(
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
return document.RootElement.Clone();
}
private static string CreateToken(IEnumerable<Claim> claims)
{
var credentials = new SigningCredentials(
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtOptions.SigningKey)),
SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
JwtOptions.Issuer,
JwtOptions.Audience,
claims,
expires: DateTime.UtcNow.AddMinutes(5),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
}
private sealed record LoginSeed(Guid TenantId, Guid UserId, Guid PlanId, string Phone);

View File

@@ -108,9 +108,6 @@ public sealed class CommissionEndpointTests
User(admin, "tenant_admin"),
User(referrer, "sales"),
User(student, "student"),
Identity(admin),
Identity(referrer),
Identity(student),
Membership(admin, TenantRole.TenantAdmin),
Membership(referrer, TenantRole.Sales),
Membership(student, TenantRole.Student),
@@ -156,22 +153,13 @@ public sealed class CommissionEndpointTests
return new CommissionSeed(tenantId, admin, referrer, student);
}
private static User User(LoginSeed seed, string role) => new() { Id = seed.UserId, Phone = seed.Phone, Name = role, PrimaryRole = role };
private static User User(LoginSeed seed, string role) =>
new User { Id = seed.UserId, Phone = seed.Phone, Name = role, PrimaryRole = role }.WithTestPassword();
private static TenantMembership Membership(LoginSeed seed, TenantRole role) => new() { TenantId = seed.TenantId, UserId = seed.UserId, Role = role, Status = MembershipStatus.Active };
private static UserIdentity Identity(LoginSeed seed) => new() { UserId = seed.UserId, Provider = "password", ProviderSubject = seed.Phone, Phone = seed.Phone, SecretPayload = CreateSecretPayload(new PasswordHasher().Hash("passw0rd!")) };
private static async Task LoginAsync(HttpClient client, LoginSeed seed)
{
var loginResponse = await client.PostAsJsonAsync("/api/auth/login/password", new PasswordLoginDto { TenantCode = seed.TenantId.ToString("N"), Phone = seed.Phone, Password = "passw0rd!" });
loginResponse.EnsureSuccessStatusCode();
using var loginJson = await JsonDocument.ParseAsync(await loginResponse.Content.ReadAsStreamAsync());
client.DefaultRequestHeaders.Authorization = new("Bearer", loginJson.RootElement.GetProperty("tokens").GetProperty("accessToken").GetString());
}
private static JsonElement CreateSecretPayload(string passwordHash)
{
using var document = JsonDocument.Parse($$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
return document.RootElement.Clone();
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
}
private sealed record LoginSeed(Guid TenantId, Guid UserId, string Phone);

View File

@@ -168,6 +168,79 @@ public sealed class ContentManagementEndpointTests
Assert.NotEmpty(template.RootElement.GetProperty("contentBase64").GetString() ?? string.Empty);
}
[Fact]
public async Task ContentEntries_ApplySelfAndRestrictedScopesAndHideUnauthorizedUpdates()
{
await using var factory = new ApiTestFactory();
var seed = await SeedAdminAsync(factory);
var allowedRegionId = Guid.NewGuid();
var outsideRegionId = Guid.NewGuid();
var regionalCreatorId = Guid.NewGuid();
var outsideCreatorId = Guid.NewGuid();
var ownEntry = new ContentEntry
{
TenantId = seed.TenantId,
RegionId = outsideRegionId,
EntryKey = "own-entry",
Name = "Own Entry",
CreatedBy = seed.UserId
};
var regionalEntry = new ContentEntry
{
TenantId = seed.TenantId,
RegionId = allowedRegionId,
EntryKey = "regional-entry",
Name = "Regional Entry",
CreatedBy = regionalCreatorId
};
var outsideEntry = new ContentEntry
{
TenantId = seed.TenantId,
RegionId = outsideRegionId,
EntryKey = "outside-entry",
Name = "Outside Entry",
CreatedBy = outsideCreatorId
};
await factory.SeedAsync(
new Tiku.Domain.Catalog.Region { Id = allowedRegionId, TenantId = seed.TenantId, Name = "Allowed Region" },
new Tiku.Domain.Catalog.Region { Id = outsideRegionId, TenantId = seed.TenantId, Name = "Outside Region" },
new User { Id = regionalCreatorId, Name = "Regional Creator" },
new User { Id = outsideCreatorId, Name = "Outside Creator" },
ownEntry,
regionalEntry,
outsideEntry);
await SetDataScopeAsync(factory, seed.TenantId, new { mode = "self" });
using var client = factory.CreateClient();
await LoginAsync(client, seed);
using var selfResponse = await client.GetAsync("/api/tenant-content/entries?includeInactive=true");
using var selfJson = await ReadJsonAsync(selfResponse);
await SetDataScopeAsync(factory, seed.TenantId, new
{
mode = "restricted",
regionIds = new[] { allowedRegionId },
includesSelf = false
});
using var restrictedResponse = await client.GetAsync("/api/tenant-content/entries?includeInactive=true");
using var restrictedJson = await ReadJsonAsync(restrictedResponse);
using var deniedUpdate = await client.PostAsJsonAsync(
"/api/tenant-content/entries",
new UpsertContentEntryDto
{
Id = outsideEntry.Id,
RegionId = outsideRegionId,
EntryKey = outsideEntry.EntryKey,
Name = "Must stay hidden"
});
Assert.Equal([ownEntry.Id], selfJson.RootElement.GetProperty("items").EnumerateArray()
.Select(item => item.GetProperty("id").GetGuid()));
Assert.Equal([regionalEntry.Id], restrictedJson.RootElement.GetProperty("items").EnumerateArray()
.Select(item => item.GetProperty("id").GetGuid()));
Assert.Equal(HttpStatusCode.NotFound, deniedUpdate.StatusCode);
}
private static async Task<Guid> CreateEntryAsync(HttpClient client)
{
using var response = await client.PostAsJsonAsync(
@@ -187,8 +260,6 @@ public sealed class ContentManagementEndpointTests
var tenantId = Guid.NewGuid();
var userId = Guid.NewGuid();
var phone = "13700000000";
var passwordHash = new PasswordHasher().Hash("passw0rd!");
await factory.SeedAsync(
new Tenant
{
@@ -203,21 +274,13 @@ public sealed class ContentManagementEndpointTests
Id = userId,
Phone = phone,
Name = "Tenant Admin"
},
}.WithTestPassword(),
new TenantMembership
{
TenantId = tenantId,
UserId = userId,
Role = TenantRole.TenantAdmin,
Status = MembershipStatus.Active
},
new UserIdentity
{
UserId = userId,
Provider = "password",
ProviderSubject = phone,
Phone = phone,
SecretPayload = CreateSecretPayload(passwordHash)
});
return (tenantId, userId, phone);
@@ -227,20 +290,17 @@ public sealed class ContentManagementEndpointTests
HttpClient client,
(Guid TenantId, Guid UserId, string Phone) seed)
{
var loginResponse = await client.PostAsJsonAsync(
"/api/auth/login/password",
new PasswordLoginDto
{
TenantCode = seed.TenantId.ToString("N"),
Phone = seed.Phone,
Password = "passw0rd!"
});
var loginJson = await ReadJsonAsync(loginResponse);
var accessToken = loginJson.RootElement
.GetProperty("tokens")
.GetProperty("accessToken")
.GetString();
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
}
private static async Task SetDataScopeAsync(ApiTestFactory factory, Guid tenantId, object value)
{
using var scope = factory.CreateSystemScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var role = dbContext.TenantBackendRoles.Single(item =>
item.TenantId == tenantId && item.Code == "integration_test_admin");
role.DataScope = JsonSerializer.SerializeToElement(value);
await dbContext.SaveChangesAsync();
}
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
@@ -249,10 +309,4 @@ public sealed class ContentManagementEndpointTests
return await JsonDocument.ParseAsync(stream);
}
private static JsonElement CreateSecretPayload(string passwordHash)
{
using var document = JsonDocument.Parse(
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
return document.RootElement.Clone();
}
}

View File

@@ -121,49 +121,20 @@ public sealed class CrmEndpointTests
var phone = "13800002001";
await factory.SeedAsync(
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "CRM Tenant" },
new User { Id = userId, Phone = phone, Name = "CRM Admin" },
new User { Id = userId, Phone = phone, Name = "CRM Admin" }.WithTestPassword(),
new TenantMembership
{
TenantId = tenantId,
UserId = userId,
Role = TenantRole.TenantAdmin,
Status = MembershipStatus.Active
},
new UserIdentity
{
UserId = userId,
Provider = "password",
ProviderSubject = phone,
Phone = phone,
SecretPayload = CreateSecretPayload(new PasswordHasher().Hash("passw0rd!"))
});
return new LoginSeed(tenantId, userId, phone);
}
private static async Task LoginAsync(HttpClient client, LoginSeed seed)
{
var loginResponse = await client.PostAsJsonAsync(
"/api/auth/login/password",
new PasswordLoginDto
{
TenantCode = seed.TenantId.ToString("N"),
Phone = seed.Phone,
Password = "passw0rd!"
});
loginResponse.EnsureSuccessStatusCode();
using var loginJson = await JsonDocument.ParseAsync(await loginResponse.Content.ReadAsStreamAsync());
var accessToken = loginJson.RootElement
.GetProperty("tokens")
.GetProperty("accessToken")
.GetString();
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
}
private static JsonElement CreateSecretPayload(string passwordHash)
{
using var document = JsonDocument.Parse(
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
return document.RootElement.Clone();
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
}
private sealed record LoginSeed(Guid TenantId, Guid UserId, string Phone);

Some files were not shown because too many files have changed in this diff Show More