Files
tiku-backend.net/Tiku.Infrastructure/Auth/AuthService.cs
2026-07-26 12:51:56 +08:00

299 lines
9.3 KiB
C#

using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Auth;
using Tiku.Domain.Identity;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Auth;
public sealed class AuthService(
TikuDbContext dbContext,
IPasswordHasher passwordHasher,
ISmsVerificationService smsVerificationService,
ISessionService sessionService) : IAuthService
{
private const string PasswordProvider = "password";
private const string SmsProvider = "sms";
public async Task<AuthenticatedUser> LoginWithPasswordAsync(
PasswordLoginRequest request,
CancellationToken cancellationToken = default)
{
var phone = SmsCodeHashing.NormalizePhone(request.Phone);
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);
if (user is null ||
identity is null ||
!TryGetPasswordHash(identity.SecretPayload, out var passwordHash) ||
!passwordHasher.Verify(request.Password, passwordHash))
{
await AddLoginEventAsync(
request.TenantId,
user?.Id,
PasswordProvider,
phone,
AuthLoginResult.Failed,
"invalid_credentials",
request.IpAddress,
request.UserAgent,
cancellationToken);
throw new InvalidCredentialsException();
}
return await CompleteSuccessfulLoginAsync(
request.TenantId,
user,
PasswordProvider,
phone,
request.IpAddress,
request.UserAgent,
cancellationToken);
}
public async Task<AuthenticatedUser> LoginWithSmsAsync(
SmsLoginRequest request,
CancellationToken cancellationToken = default)
{
var phone = SmsCodeHashing.NormalizePhone(request.Phone);
var user = await dbContext.Users
.SingleOrDefaultAsync(entity => entity.Phone == phone, cancellationToken);
try
{
await smsVerificationService.VerifyCodeAsync(
request.TenantId,
phone,
SmsPurpose.Login,
request.Code,
cancellationToken);
}
catch (InvalidCredentialsException exception)
{
await AddLoginEventAsync(
request.TenantId,
user?.Id,
SmsProvider,
phone,
AuthLoginResult.Failed,
exception.Code,
request.IpAddress,
request.UserAgent,
cancellationToken);
throw;
}
if (user is null)
{
await AddLoginEventAsync(
request.TenantId,
null,
SmsProvider,
phone,
AuthLoginResult.Failed,
"user_not_found",
request.IpAddress,
request.UserAgent,
cancellationToken);
throw new InvalidCredentialsException();
}
return await CompleteSuccessfulLoginAsync(
request.TenantId,
user,
SmsProvider,
phone,
request.IpAddress,
request.UserAgent,
cancellationToken);
}
public async Task<AuthTokenPair> RefreshAsync(
RefreshSessionRequest request,
CancellationToken cancellationToken = default)
{
var tokenHash = sessionService.HashRefreshToken(request.RefreshToken);
var now = DateTimeOffset.UtcNow;
var session = await dbContext.AuthSessions
.SingleOrDefaultAsync(entity => 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);
}
public async Task LogoutAsync(
LogoutSessionRequest request,
CancellationToken cancellationToken = default)
{
var tokenHash = sessionService.HashRefreshToken(request.RefreshToken);
var session = await dbContext.AuthSessions
.SingleOrDefaultAsync(entity => 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);
}
private async Task<AuthenticatedUser> CompleteSuccessfulLoginAsync(
Guid tenantId,
User user,
string provider,
string identifier,
string? ipAddress,
string? userAgent,
CancellationToken cancellationToken)
{
var membership = await FindActiveMembershipAsync(tenantId, user.Id, cancellationToken);
if (membership is null)
{
await AddLoginEventAsync(
tenantId,
user.Id,
provider,
identifier,
AuthLoginResult.Failed,
"tenant_access_denied",
ipAddress,
userAgent,
cancellationToken);
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,
cancellationToken);
await AddLoginEventAsync(
tenantId,
user.Id,
provider,
identifier,
AuthLoginResult.Success,
null,
ipAddress,
userAgent,
cancellationToken);
return new AuthenticatedUser(
user.Id,
user.Phone,
user.Email,
user.Name,
new TenantMembershipSummary(
tenant.Id,
tenant.Name,
membership.Role,
membership.Status),
tokens);
}
private async Task<TenantMembership?> FindActiveMembershipAsync(
Guid tenantId,
Guid userId,
CancellationToken cancellationToken)
{
return await dbContext.TenantMemberships
.Where(entity =>
entity.TenantId == tenantId &&
entity.UserId == userId &&
entity.Status == MembershipStatus.Active)
.OrderBy(entity => entity.Role)
.FirstOrDefaultAsync(cancellationToken);
}
private async Task AddLoginEventAsync(
Guid tenantId,
Guid? userId,
string provider,
string? identifier,
AuthLoginResult result,
string? failureCode,
string? ipAddress,
string? userAgent,
CancellationToken cancellationToken)
{
dbContext.AuthLoginEvents.Add(new AuthLoginEvent
{
TenantId = tenantId,
UserId = userId,
Provider = provider,
Identifier = identifier,
Result = result,
FailureCode = failureCode,
IpAddress = ipAddress,
UserAgent = userAgent
});
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);
}
}