forked from gongxuegit/tiku-backend.net
feat: harden SaaS authentication and authorization
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
353
Tiku.Infrastructure/Auth/AuthSessionStore.cs
Normal file
353
Tiku.Infrastructure/Auth/AuthSessionStore.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
59
Tiku.Infrastructure/Auth/JwtKeyRing.cs
Normal file
59
Tiku.Infrastructure/Auth/JwtKeyRing.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user