feat: strengthen P0 security and operations

This commit is contained in:
2026-08-01 11:20:02 +08:00
parent f776056834
commit 84c2b0b21d
77 changed files with 24185 additions and 355 deletions

View File

@@ -230,6 +230,78 @@ public sealed class AuthController(
return Ok(AuthenticationResultDto.FromApplication(result));
}
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
[HttpPost("password/reset/sms/send")]
[EndpointSummary("发送密码重置短信验证码")]
[EndpointDescription("仅适用于租户授权域;无论手机号是否存在均返回相同接受响应。")]
[ProducesResponseType<SmsSendResult>(StatusCodes.Status202Accepted)]
public async Task<ActionResult<SmsSendResult>> SendPasswordResetCode(
PasswordResetSmsSendDto request,
CancellationToken cancellationToken)
{
var tenantId = await ResolveRealmTenantIdAsync(AuthRealm.Tenant, request.TenantCode, cancellationToken)
?? throw new RequiredFieldException("tenantCode is required for password reset.");
var result = await authService.RequestPasswordResetAsync(
new PasswordResetCodeRequest(
tenantId,
request.Phone,
GetIpAddress(),
Request.Headers.UserAgent.ToString(),
request.DeviceId),
cancellationToken);
return Accepted(result);
}
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
[HttpPost("password/reset")]
[EndpointSummary("使用短信验证码重置密码")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> ResetPassword(
PasswordResetDto request,
CancellationToken cancellationToken)
{
var tenantId = await ResolveRealmTenantIdAsync(AuthRealm.Tenant, request.TenantCode, cancellationToken)
?? throw new RequiredFieldException("tenantCode is required for password reset.");
await authService.ResetPasswordAsync(
new PasswordResetRequest(
tenantId,
request.Phone,
request.Code,
request.NewPassword,
GetIpAddress(),
Request.Headers.UserAgent.ToString()),
cancellationToken);
return NoContent();
}
[Authorize]
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
[HttpPost("password/change")]
[EndpointSummary("已登录用户修改密码")]
[EndpointDescription("修改成功后撤销旧会话并返回新的令牌对。")]
public async Task<ActionResult<AuthenticationResultDto>> ChangePassword(
AuthenticatedPasswordChangeDto request,
CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId || currentUser.SessionId is not { } sessionId)
{
return Unauthorized();
}
var result = await authService.ChangePasswordAsync(
new AuthenticatedPasswordChangeRequest(
userId,
sessionId,
request.CurrentPassword,
request.NewPassword,
GetIpAddress(),
Request.Headers.UserAgent.ToString()),
cancellationToken);
return Ok(AuthenticationResultDto.FromApplication(result));
}
private void ResolveRefreshTokenTenant(string refreshToken)
{
if (!sessionStore.TryParseRefreshToken(refreshToken, out var locator))

View File

@@ -12,7 +12,8 @@ namespace Tiku.Api.Controllers;
[Authorize(Policy = BackendPermissions.TenantJobManage)]
public sealed class BackgroundJobsController(
IBackgroundJobService backgroundJobService,
ITenantContext tenantContext) : ControllerBase
ITenantContext tenantContext,
ICurrentUser currentUser) : ControllerBase
{
[HttpGet]
[EndpointSummary("查询租户后台任务")]
@@ -37,8 +38,38 @@ public sealed class BackgroundJobsController(
return Ok(await backgroundJobService.EnqueueAsync(request.ToCommand(tenantId), cancellationToken));
}
[HttpGet("{jobId:guid}")]
[EndpointSummary("查询租户后台任务详情")]
public async Task<ActionResult<BackgroundJobItem>> Detail(Guid jobId, CancellationToken cancellationToken)
{
var item = await backgroundJobService.GetAsync(jobId, ResolveTenantId(), cancellationToken);
return item is null ? NotFound() : Ok(item);
}
[HttpPost("{jobId:guid}/cancel")]
[EndpointSummary("取消租户后台任务")]
public async Task<ActionResult<BackgroundJobItem>> Cancel(
Guid jobId,
CancelBackgroundJobDto request,
CancellationToken cancellationToken)
{
return Ok(await backgroundJobService.RequestCancellationAsync(
jobId, ResolveTenantId(), ResolveUserId(), request.Reason, cancellationToken));
}
[HttpPost("{jobId:guid}/retry")]
[EndpointSummary("重试失败或已取消的租户后台任务")]
public async Task<ActionResult<BackgroundJobItem>> Retry(Guid jobId, CancellationToken cancellationToken)
{
return Ok(await backgroundJobService.RetryAsync(
jobId, ResolveTenantId(), ResolveUserId(), cancellationToken));
}
private Guid ResolveTenantId()
{
return tenantContext.TenantId ?? throw new InvalidOperationException("Tenant context was not resolved.");
}
private Guid ResolveUserId() =>
currentUser.UserId ?? throw new InvalidOperationException("Current user was not resolved.");
}

View File

@@ -171,6 +171,79 @@ public sealed class BrowserAuthController(
return NoContent();
}
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
[HttpPost("password/reset/sms/send")]
[EndpointSummary("发送浏览器密码重置短信验证码")]
public async Task<ActionResult<SmsSendResult>> SendPasswordResetCode(
PasswordResetSmsSendDto request,
CancellationToken cancellationToken)
{
EnsureTrustedOrigin();
var tenantId = await ResolveTenantIdAsync(AuthRealm.Tenant, request.TenantCode, cancellationToken)
?? throw new RequiredFieldException("tenantCode is required for password reset.");
var result = await authService.RequestPasswordResetAsync(
new PasswordResetCodeRequest(
tenantId,
request.Phone,
HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
request.DeviceId),
cancellationToken);
return Accepted(result);
}
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
[HttpPost("password/reset")]
[EndpointSummary("使用短信验证码重置浏览器账号密码")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> ResetPassword(
PasswordResetDto request,
CancellationToken cancellationToken)
{
EnsureTrustedOrigin();
var tenantId = await ResolveTenantIdAsync(AuthRealm.Tenant, request.TenantCode, cancellationToken)
?? throw new RequiredFieldException("tenantCode is required for password reset.");
await authService.ResetPasswordAsync(
new PasswordResetRequest(
tenantId,
request.Phone,
request.Code,
request.NewPassword,
HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString()),
cancellationToken);
ClearCookies();
return NoContent();
}
[Authorize]
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
[HttpPost("password/change")]
[EndpointSummary("浏览器已登录用户修改密码")]
public async Task<ActionResult<object>> ChangePassword(
AuthenticatedPasswordChangeDto request,
CancellationToken cancellationToken)
{
EnsureTrustedOrigin();
if (currentUser.UserId is not { } userId || currentUser.SessionId is not { } sessionId)
{
return Unauthorized();
}
var result = await authService.ChangePasswordAsync(
new AuthenticatedPasswordChangeRequest(
userId,
sessionId,
request.CurrentPassword,
request.NewPassword,
HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString()),
cancellationToken);
return Ok(WriteResult(result));
}
private object WriteResult(AuthenticationResult result)
{
if (result.User?.Tokens is { } tokens)

View File

@@ -35,13 +35,7 @@ public sealed class HealthController(
var database = await dbContext.Database.CanConnectAsync(cancellationToken);
var redis = !redisSecurityStore.IsConfigured || await redisSecurityStore.PingAsync(cancellationToken);
var ready = database && redis;
var response = new
{
status = ready ? "ready" : "not_ready",
database,
redis = new { configured = redisSecurityStore.IsConfigured, ready = redis },
checkedAt = DateTimeOffset.UtcNow
};
var response = new { status = ready ? "ready" : "not_ready", checkedAt = DateTimeOffset.UtcNow };
return ready ? Ok(response) : StatusCode(StatusCodes.Status503ServiceUnavailable, response);
}
}

View File

@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Security;
using Tiku.Application.Auth;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
@@ -13,6 +14,7 @@ namespace Tiku.Api.Controllers;
[Route("api/me")]
public sealed class MeController(
ICurrentUser currentUser,
IAuthSessionStore sessionStore,
TikuDbContext dbContext) : ControllerBase
{
[HttpGet]
@@ -54,6 +56,36 @@ public sealed class MeController(
user.Name,
memberships));
}
[HttpGet("sessions")]
[EndpointSummary("查询当前授权域的登录设备")]
[ProducesResponseType<IReadOnlyCollection<AuthSessionSummary>>(StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyCollection<AuthSessionSummary>>> Sessions(
CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId || currentUser.SessionId is not { } sessionId)
{
return Unauthorized();
}
return Ok(await sessionStore.ListActiveAsync(userId, sessionId, cancellationToken));
}
[HttpDelete("sessions/{sessionFamilyId:guid}")]
[EndpointSummary("撤销其他设备的登录会话")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> RevokeSession(
Guid sessionFamilyId,
CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId || currentUser.SessionId is not { } sessionId)
{
return Unauthorized();
}
await sessionStore.RevokeOwnedFamilyAsync(userId, sessionId, sessionFamilyId, cancellationToken);
return NoContent();
}
}
/// <summary>

View File

@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Contracts;
using Tiku.Application.PlatformAdmin;
using Tiku.Application.Auth;
using Tiku.Application.Security;
using Tiku.Domain.Platform;
@@ -14,6 +15,7 @@ namespace Tiku.Api.Controllers;
[Route("api/platform-admin")]
public sealed class PlatformAdminController(
IPlatformAdminService platformAdminService,
IAuthAdministrationService authAdministrationService,
ICurrentUser currentUser) : ControllerBase
{
[HttpGet("overview")]
@@ -134,6 +136,27 @@ public sealed class PlatformAdminController(
return Ok(await platformAdminService.UpdateStaffStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
[HttpPost("staff/{userId:guid}/password-reset")]
[Authorize(Policy = BackendPermissions.PlatformStaffManage)]
[EndpointSummary("为平台员工设置一次性临时密码")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> ResetStaffPassword(
Guid userId,
AdministrativePasswordResetDto request,
CancellationToken cancellationToken)
{
var actor = ResolveActor();
await authAdministrationService.ResetPasswordAsync(
new AdministrativePasswordResetRequest(
actor.UserId,
userId,
null,
request.TemporaryPassword,
request.Reason),
cancellationToken);
return NoContent();
}
[HttpGet("audit-logs")]
[Authorize(Policy = BackendPermissions.PlatformAuditView)]
[EndpointSummary("查询平台审计日志")]

View File

@@ -0,0 +1,159 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Contracts;
using Tiku.Application.Jobs;
using Tiku.Application.Security;
using Tiku.Domain.Operations;
using Tiku.Application.Assets;
using Tiku.Application.Storage;
using Tiku.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Tiku.Infrastructure.Storage;
namespace Tiku.Api.Controllers;
[ApiController]
[Tags("平台端-运维")]
[Route("api/platform-admin/operations")]
[Authorize(Policy = BackendPermissions.PlatformOperationsView)]
public sealed class PlatformOperationsController(
IBackgroundJobService backgroundJobService,
ICurrentUser currentUser,
TikuDbContext dbContext,
IRedisSecurityStore redisSecurityStore,
IAssetSecurityScanner assetSecurityScanner,
IObjectStorageService objectStorageService,
IOptions<AliyunOssOptions> aliyunOssOptions) : ControllerBase
{
[HttpGet("health")]
[EndpointSummary("查询受保护的依赖深度健康状态")]
public async Task<ActionResult<object>> Health(CancellationToken cancellationToken)
{
var database = await dbContext.Database.CanConnectAsync(cancellationToken);
var redis = !redisSecurityStore.IsConfigured || await redisSecurityStore.PingAsync(cancellationToken);
var clamAv = await assetSecurityScanner.CheckHealthAsync(cancellationToken);
var storageProvider = objectStorageService.ConfiguredDefaultProvider();
var storageConfigured = storageProvider switch
{
ObjectStorageProviders.AliyunOss => aliyunOssOptions.Value.IsConfigured,
ObjectStorageProviders.LocalDev => true,
_ => false
};
var newestHeartbeat = await dbContext.WorkerHeartbeats.AsNoTracking()
.MaxAsync(item => (DateTimeOffset?)item.LastHeartbeatAt, cancellationToken);
var workerReady = newestHeartbeat >= DateTimeOffset.UtcNow.AddMinutes(-2);
return Ok(new
{
status = database && redis && clamAv && workerReady && storageConfigured ? "healthy" : "degraded",
database,
redis = new { configured = redisSecurityStore.IsConfigured, ready = redis },
worker = new { ready = workerReady, lastHeartbeatAt = newestHeartbeat },
clamAv,
storage = new { provider = storageProvider, configured = storageConfigured },
checkedAt = DateTimeOffset.UtcNow
});
}
[HttpGet("workers")]
[EndpointSummary("查询 Worker 与周期循环状态")]
public async Task<ActionResult<object>> Workers(CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
var items = await dbContext.WorkerHeartbeats.AsNoTracking()
.OrderBy(item => item.WorkerId)
.ThenBy(item => item.Processor)
.Select(item => new
{
item.WorkerId,
item.Processor,
item.StartedAt,
item.LastHeartbeatAt,
item.LastIterationStartedAt,
item.LastIterationCompletedAt,
item.LastSucceededAt,
item.LastError,
item.IsRunning
})
.ToArrayAsync(cancellationToken);
return Ok(new
{
staleAfterSeconds = 120,
items = items.Select(item => new
{
item.WorkerId,
item.Processor,
item.StartedAt,
item.LastHeartbeatAt,
item.LastIterationStartedAt,
item.LastIterationCompletedAt,
item.LastSucceededAt,
item.LastError,
item.IsRunning,
stale = item.LastHeartbeatAt < now.AddMinutes(-2)
})
});
}
[HttpGet("job-metrics")]
[EndpointSummary("查询后台任务队列指标")]
public async Task<ActionResult<object>> JobMetrics(CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
var counts = await dbContext.BackgroundJobs.AsNoTracking()
.GroupBy(item => item.Status)
.Select(group => new { status = group.Key, count = group.Count() })
.ToArrayAsync(cancellationToken);
var oldestPending = await dbContext.BackgroundJobs.AsNoTracking()
.Where(item => item.Status == BackgroundJobStatus.Pending)
.MinAsync(item => (DateTimeOffset?)item.CreatedAt, cancellationToken);
var expiredLeases = await dbContext.BackgroundJobs.AsNoTracking()
.CountAsync(item => item.Status == BackgroundJobStatus.Processing && item.LockExpiresAt < now, cancellationToken);
return Ok(new
{
counts,
oldestPendingAt = oldestPending,
queueAgeSeconds = oldestPending.HasValue ? Math.Max(0, (now - oldestPending.Value).TotalSeconds) : 0,
expiredLeases,
checkedAt = now
});
}
[HttpGet("jobs")]
[EndpointSummary("查询全平台后台任务")]
public async Task<ActionResult<IReadOnlyCollection<BackgroundJobItem>>> Jobs(
[FromQuery] Guid? tenantId,
[FromQuery] string? jobType,
[FromQuery] BackgroundJobStatus? status,
[FromQuery] int? limit,
CancellationToken cancellationToken) =>
Ok(await backgroundJobService.ListPlatformAsync(
tenantId, jobType, status, limit ?? 100, cancellationToken));
[HttpGet("jobs/{jobId:guid}")]
[EndpointSummary("查询平台后台任务详情")]
public async Task<ActionResult<BackgroundJobItem>> Job(Guid jobId, CancellationToken cancellationToken)
{
var item = await backgroundJobService.GetAsync(jobId, null, cancellationToken);
return item is null ? NotFound() : Ok(item);
}
[HttpPost("jobs/{jobId:guid}/cancel")]
[Authorize(Policy = BackendPermissions.PlatformOperationsManage)]
[EndpointSummary("取消平台后台任务")]
public async Task<ActionResult<BackgroundJobItem>> CancelJob(
Guid jobId,
CancelBackgroundJobDto request,
CancellationToken cancellationToken) =>
Ok(await backgroundJobService.RequestCancellationAsync(
jobId, null, ResolveUserId(), request.Reason, cancellationToken));
[HttpPost("jobs/{jobId:guid}/retry")]
[Authorize(Policy = BackendPermissions.PlatformOperationsManage)]
[EndpointSummary("重试平台后台任务")]
public async Task<ActionResult<BackgroundJobItem>> RetryJob(Guid jobId, CancellationToken cancellationToken) =>
Ok(await backgroundJobService.RetryAsync(jobId, null, ResolveUserId(), cancellationToken));
private Guid ResolveUserId() =>
currentUser.UserId ?? throw new InvalidOperationException("Current platform user was not resolved.");
}

View File

@@ -0,0 +1,76 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Contracts;
using Tiku.Application.Security;
using Tiku.Application.Storage;
using Tiku.Application.Tenancy;
namespace Tiku.Api.Controllers;
[ApiController]
[Tags("平台端-租户生命周期")]
[Route("api/platform-admin/tenants/{tenantId:guid}")]
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
public sealed class PlatformTenantLifecycleController(
ITenantLifecycleService lifecycleService,
ICurrentUser currentUser) : ControllerBase
{
[HttpGet("archive-preview")]
[EndpointSummary("预检租户归档条件")]
public async Task<ActionResult<TenantArchivePreview>> Preview(Guid tenantId, CancellationToken cancellationToken) =>
Ok(await lifecycleService.PreviewArchiveAsync(tenantId, cancellationToken));
[HttpPost("exports")]
[EndpointSummary("创建租户数据导出")]
public async Task<ActionResult<TenantLifecycleOperationItem>> CreateExport(
Guid tenantId,
CancellationToken cancellationToken) =>
Accepted(await lifecycleService.CreateExportAsync(tenantId, ResolveUserId(), cancellationToken));
[HttpGet("exports/{operationId:guid}")]
[EndpointSummary("查询租户数据导出状态")]
public async Task<ActionResult<TenantLifecycleOperationItem>> ExportStatus(
Guid tenantId,
Guid operationId,
CancellationToken cancellationToken)
{
var operation = await lifecycleService.GetOperationAsync(tenantId, operationId, cancellationToken);
return operation is null ? NotFound() : Ok(operation);
}
[HttpGet("exports/{operationId:guid}/download")]
[EndpointSummary("获取租户导出下载地址")]
public async Task<ActionResult<ObjectStorageSignedUrl>> DownloadExport(
Guid tenantId,
Guid operationId,
CancellationToken cancellationToken) =>
Ok(await lifecycleService.SignExportDownloadAsync(tenantId, operationId, cancellationToken));
[HttpPost("archive")]
[EndpointSummary("逻辑归档租户")]
public async Task<ActionResult<TenantLifecycleOperationItem>> Archive(
Guid tenantId,
TenantLifecycleReasonDto request,
CancellationToken cancellationToken) =>
Ok(await lifecycleService.ArchiveAsync(tenantId, ResolveUserId(), request.Reason, cancellationToken));
[HttpPost("restore")]
[EndpointSummary("恢复租户到暂停状态")]
public async Task<ActionResult<TenantLifecycleOperationItem>> Restore(
Guid tenantId,
TenantLifecycleReasonDto request,
CancellationToken cancellationToken) =>
Ok(await lifecycleService.RestoreAsync(tenantId, ResolveUserId(), request.Reason, cancellationToken));
[HttpPost("owner-transfer")]
[EndpointSummary("转移租户所有者")]
public async Task<ActionResult<TenantLifecycleOperationItem>> TransferOwner(
Guid tenantId,
TenantOwnerTransferDto request,
CancellationToken cancellationToken) =>
Ok(await lifecycleService.TransferOwnerAsync(
tenantId, ResolveUserId(), request.TargetUserId, request.Reason, cancellationToken));
private Guid ResolveUserId() =>
currentUser.UserId ?? throw new InvalidOperationException("Current platform user was not resolved.");
}

View File

@@ -2,10 +2,12 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Contracts;
using Tiku.Application.Catalog;
using Tiku.Application.Auth;
using Tiku.Application.Content;
using Tiku.Application.Security;
using Tiku.Application.TenantAdmin;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Content;
namespace Tiku.Api.Controllers;
@@ -15,6 +17,7 @@ namespace Tiku.Api.Controllers;
[Route("api/tenant-admin")]
public sealed class TenantAdminDirectController(
ITenantAdminDirectService tenantAdminService,
IAuthAdministrationService authAdministrationService,
ICurrentUser currentUser,
ITenantContext currentTenant) : ControllerBase
{
@@ -314,6 +317,27 @@ public sealed class TenantAdminDirectController(
return Ok(await tenantAdminService.DisableMemberAsync(ResolveActor(), request.MembershipId, cancellationToken));
}
[HttpPost("members/{userId:guid}/password-reset")]
[Authorize(Policy = BackendPermissions.TenantStaffManage)]
[EndpointSummary("为租户成员设置一次性临时密码")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> ResetMemberPassword(
Guid userId,
AdministrativePasswordResetDto request,
CancellationToken cancellationToken)
{
var actor = ResolveActor();
await authAdministrationService.ResetPasswordAsync(
new AdministrativePasswordResetRequest(
actor.UserId,
userId,
actor.TenantId,
request.TemporaryPassword,
request.Reason),
cancellationToken);
return NoContent();
}
[HttpGet("audit-logs")]
[Authorize(Policy = BackendPermissions.TenantStaffManage)]
[EndpointSummary("查询租户审计日志")]