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

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