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