forked from gongxuegit/tiku-backend.net
feat: harden SaaS authentication and authorization
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
49
Tiku.Application/Auth/IAuthSessionStore.cs
Normal file
49
Tiku.Application/Auth/IAuthSessionStore.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Application.Auth;
|
||||
|
||||
public interface IAuthSessionStore
|
||||
{
|
||||
string GenerateRefreshToken(AuthRealm realm, Guid? tenantId, Guid sessionId);
|
||||
bool TryParseRefreshToken(string refreshToken, out RefreshTokenLocator locator);
|
||||
string HashRefreshToken(string refreshToken);
|
||||
|
||||
Task<AuthTokenPair> IssueAsync(
|
||||
AuthSessionIssueRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AuthTokenPair> RotateAsync(
|
||||
string refreshToken,
|
||||
string? ipAddress,
|
||||
string? userAgent,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AuthSessionValidationResult?> ValidateAccessSessionAsync(
|
||||
Guid sessionId,
|
||||
Guid userId,
|
||||
AuthRealm realm,
|
||||
Guid? tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task RevokeFamilyAsync(string refreshToken, string reason, CancellationToken cancellationToken = default);
|
||||
Task RevokeRealmAsync(Guid userId, AuthRealm realm, Guid? tenantId, string reason, CancellationToken cancellationToken = default);
|
||||
Task RevokeAllAsync(Guid userId, string reason, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record AuthSessionIssueRequest(
|
||||
Guid UserId,
|
||||
string? Phone,
|
||||
string? Email,
|
||||
string SecurityStamp,
|
||||
AuthRealm Realm,
|
||||
Guid? TenantId,
|
||||
string Provider,
|
||||
bool MfaSatisfied,
|
||||
string? IpAddress,
|
||||
string? UserAgent,
|
||||
Guid? TokenFamilyId = null,
|
||||
Guid? ParentSessionId = null);
|
||||
|
||||
public sealed record AuthSessionValidationResult(Guid UserId, AuthRealm Realm, Guid? TenantId, bool MfaSatisfied);
|
||||
|
||||
public readonly record struct RefreshTokenLocator(AuthRealm Realm, Guid? TenantId, Guid SessionId);
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace Tiku.Application.Auth;
|
||||
|
||||
public interface IPasswordHasher
|
||||
{
|
||||
string Hash(string password);
|
||||
bool Verify(string password, string passwordHash);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Application.Auth;
|
||||
|
||||
public interface ISessionService
|
||||
{
|
||||
string GenerateRefreshToken(Guid tenantId, Guid sessionId);
|
||||
bool TryParseRefreshToken(string refreshToken, out RefreshTokenLocator locator);
|
||||
string HashRefreshToken(string refreshToken);
|
||||
|
||||
Task<AuthTokenPair> IssueAsync(
|
||||
Guid userId,
|
||||
string? phone,
|
||||
string? email,
|
||||
TenantMembership membership,
|
||||
string provider,
|
||||
string? ipAddress,
|
||||
string? userAgent,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public readonly record struct RefreshTokenLocator(Guid TenantId, Guid SessionId);
|
||||
@@ -9,5 +9,7 @@ public interface ITokenService
|
||||
Guid sessionId,
|
||||
string? phone,
|
||||
string? email,
|
||||
TenantMembership membership);
|
||||
AuthRealm realm,
|
||||
Guid? tenantId,
|
||||
bool mfaSatisfied);
|
||||
}
|
||||
|
||||
23
Tiku.Application/Auth/SmsSecurityOptions.cs
Normal file
23
Tiku.Application/Auth/SmsSecurityOptions.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace Tiku.Application.Auth;
|
||||
|
||||
public sealed class SmsSecurityOptions
|
||||
{
|
||||
public const string SectionName = "Authentication:Sms";
|
||||
|
||||
public string CodePepper { get; set; } = string.Empty;
|
||||
public int MaxVerificationAttempts { get; set; } = 5;
|
||||
public int TenantRequestsPerHour { get; set; } = 100;
|
||||
public int PhoneRequestsPerHour { get; set; } = 5;
|
||||
public int IpRequestsPerHour { get; set; } = 20;
|
||||
public int DeviceRequestsPerHour { get; set; } = 10;
|
||||
|
||||
public static bool BeValid(SmsSecurityOptions options)
|
||||
{
|
||||
return options.CodePepper.Length >= 32 &&
|
||||
options.MaxVerificationAttempts == 5 &&
|
||||
options.TenantRequestsPerHour > 0 &&
|
||||
options.PhoneRequestsPerHour > 0 &&
|
||||
options.IpRequestsPerHour > 0 &&
|
||||
options.DeviceRequestsPerHour > 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user