using FlatRender.IdentitySvc.Application.Services.Interfaces; using FlatRender.IdentitySvc.Domain.Entities; using FlatRender.IdentitySvc.Domain.Enums; using FlatRender.IdentitySvc.Infrastructure.Data; using FlatRender.IdentitySvc.Models.Requests; using FlatRender.IdentitySvc.Models.Responses; using Microsoft.EntityFrameworkCore; using OtpNet; // Otp.NET package namespace FlatRender.IdentitySvc.Application.Services; public class AuthService( IdentityDbContext db, ITokenService tokenService, IConfiguration config) : IAuthService { private readonly int _refreshTokenDays = int.Parse(config["Jwt:RefreshTokenDays"] ?? "30"); public async Task RegisterAsync(RegisterRequest request, string? ipAddress) { var tenant = await db.Tenants.FirstOrDefaultAsync(t => t.Slug == request.TenantSlug && t.DeletedAt == null) ?? throw new InvalidOperationException("Tenant not found"); if (string.IsNullOrEmpty(request.Email) && string.IsNullOrEmpty(request.PhoneNumber)) throw new ArgumentException("Email or phone number is required"); if (!string.IsNullOrEmpty(request.Email)) { var exists = await db.Users.AnyAsync(u => u.TenantId == tenant.Id && u.Email == request.Email && u.DeletedAt == null); if (exists) throw new InvalidOperationException("Email already registered"); } var user = new User { TenantId = tenant.Id, Email = request.Email?.ToLowerInvariant(), PhoneNumber = request.PhoneNumber, PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password), PasswordSetAt = DateTime.UtcNow, FullName = request.FullName, RegisterMode = string.IsNullOrEmpty(request.PhoneNumber) ? RegisterMode.Email : RegisterMode.Mobile, RegisterDate = DateTime.UtcNow, }; db.Users.Add(user); await db.SaveChangesAsync(); // Create email verification token bool verificationRequired = false; if (!string.IsNullOrEmpty(user.Email)) { verificationRequired = true; await CreateConfirmationTokenAsync(user.Id, tenant.Id, TokenPurpose.EmailVerification, user.Email, ipAddress); } return new RegisterResponse(user.Id, verificationRequired); } public async Task LoginAsync(LoginRequest request, string? ipAddress) { var tenant = await db.Tenants.FirstOrDefaultAsync(t => t.Slug == request.TenantSlug && t.DeletedAt == null) ?? throw new UnauthorizedAccessException("Tenant not found"); User? user = null; if (!string.IsNullOrEmpty(request.Email)) user = await db.Users.FirstOrDefaultAsync(u => u.TenantId == tenant.Id && u.Email == request.Email && u.DeletedAt == null); else if (!string.IsNullOrEmpty(request.PhoneNumber)) user = await db.Users.FirstOrDefaultAsync(u => u.TenantId == tenant.Id && u.PhoneNumber == request.PhoneNumber && u.DeletedAt == null); if (user == null || string.IsNullOrEmpty(user.PasswordHash)) throw new UnauthorizedAccessException("Invalid credentials"); if (!BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash)) throw new UnauthorizedAccessException("Invalid credentials"); if (user.BanAccount && (user.UnblockDate == null || user.UnblockDate > DateTime.UtcNow)) throw new UnauthorizedAccessException("Account is banned"); // Check MFA var mfaFactor = await db.MfaFactors.FirstOrDefaultAsync(m => m.UserId == user.Id && m.IsVerified && m.IsPrimary); if (mfaFactor != null) { // Return mfa_token — caller must complete challenge throw new MfaRequiredException(GenerateMfaToken(user.Id, tenant.Id), "MFA required"); } user.LastLoginAt = DateTime.UtcNow; user.LastLoginIp = ipAddress; user.LastActiveDate = DateTime.UtcNow; var tokens = await CreateSessionAsync(user, tenant, request.DeviceId, request.DeviceName, ipAddress); return tokens; } public async Task RefreshAsync(string refreshToken) { var tokenHash = tokenService.HashToken(refreshToken); var session = await db.UserSessions .Include(s => s.User) .FirstOrDefaultAsync(s => s.RefreshTokenHash == tokenHash && s.RevokedAt == null && s.ExpiresAt > DateTime.UtcNow) ?? throw new UnauthorizedAccessException("Invalid or expired refresh token"); var tenant = await db.Tenants.FindAsync(session.TenantId) ?? throw new UnauthorizedAccessException("Tenant not found"); // Rotate refresh token session.RevokedAt = DateTime.UtcNow; var tokens = await CreateSessionAsync(session.User, tenant, session.DeviceId, session.DeviceName, null, session.Id); await db.SaveChangesAsync(); return tokens; } public async Task LogoutAsync(Guid sessionId, Guid userId) { var session = await db.UserSessions.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId); if (session != null) { session.RevokedAt = DateTime.UtcNow; await db.SaveChangesAsync(); } } public async Task> GetSessionsAsync(Guid userId) { var sessions = await db.UserSessions .Where(s => s.UserId == userId && s.RevokedAt == null && s.ExpiresAt > DateTime.UtcNow) .OrderByDescending(s => s.LastUsedAt ?? s.IssuedAt) .ToListAsync(); return sessions.Select(s => new SessionResponse( s.Id, s.DeviceName, s.UserAgent, s.IpAddress, s.IssuedAt, s.LastUsedAt, false )).ToList(); } public async Task RevokeSessionAsync(Guid sessionId, Guid userId) { var session = await db.UserSessions.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId) ?? throw new KeyNotFoundException("Session not found"); session.RevokedAt = DateTime.UtcNow; await db.SaveChangesAsync(); } public async Task VerifyEmailAsync(string tokenHash, string code) { var confirmation = await db.ConfirmationTokens .FirstOrDefaultAsync(t => t.TokenHash == tokenHash && t.Purpose == TokenPurpose.EmailVerification && !t.IsConsumed && t.ExpiresAt > DateTime.UtcNow) ?? throw new InvalidOperationException("Invalid or expired token"); if (confirmation.TryCount >= confirmation.MaxTries) throw new InvalidOperationException("Too many attempts"); confirmation.TryCount++; if (confirmation.Code != code) { await db.SaveChangesAsync(); return false; } confirmation.IsConsumed = true; confirmation.ConsumedAt = DateTime.UtcNow; if (confirmation.UserId.HasValue) { var user = await db.Users.FindAsync(confirmation.UserId.Value); if (user != null) { user.EmailVerified = true; user.EmailVerifiedAt = DateTime.UtcNow; } } await db.SaveChangesAsync(); return true; } public async Task VerifyPhoneAsync(string tokenHash, string code) { var confirmation = await db.ConfirmationTokens .FirstOrDefaultAsync(t => t.TokenHash == tokenHash && t.Purpose == TokenPurpose.PhoneVerification && !t.IsConsumed && t.ExpiresAt > DateTime.UtcNow) ?? throw new InvalidOperationException("Invalid or expired token"); if (confirmation.TryCount >= confirmation.MaxTries) throw new InvalidOperationException("Too many attempts"); confirmation.TryCount++; if (confirmation.Code != code) { await db.SaveChangesAsync(); return false; } confirmation.IsConsumed = true; confirmation.ConsumedAt = DateTime.UtcNow; if (confirmation.UserId.HasValue) { var user = await db.Users.FindAsync(confirmation.UserId.Value); if (user != null) { user.PhoneVerified = true; user.PhoneVerifiedAt = DateTime.UtcNow; } } await db.SaveChangesAsync(); return true; } public async Task RequestPasswordResetAsync(string tenantSlug, string? email, string? phone) { var tenant = await db.Tenants.FirstOrDefaultAsync(t => t.Slug == tenantSlug && t.DeletedAt == null); if (tenant == null) return; User? user = null; if (!string.IsNullOrEmpty(email)) user = await db.Users.FirstOrDefaultAsync(u => u.TenantId == tenant.Id && u.Email == email && u.DeletedAt == null); else if (!string.IsNullOrEmpty(phone)) user = await db.Users.FirstOrDefaultAsync(u => u.TenantId == tenant.Id && u.PhoneNumber == phone && u.DeletedAt == null); if (user == null) return; await CreateConfirmationTokenAsync(user.Id, tenant.Id, TokenPurpose.PasswordReset, email ?? phone!, null); } public async Task ConfirmPasswordResetAsync(string token, string newPassword) { var tokenHash = tokenService.HashToken(token); var confirmation = await db.ConfirmationTokens .FirstOrDefaultAsync(t => t.TokenHash == tokenHash && t.Purpose == TokenPurpose.PasswordReset && !t.IsConsumed && t.ExpiresAt > DateTime.UtcNow) ?? throw new InvalidOperationException("Invalid or expired token"); confirmation.IsConsumed = true; confirmation.ConsumedAt = DateTime.UtcNow; if (confirmation.UserId.HasValue) { var user = await db.Users.FindAsync(confirmation.UserId.Value); if (user != null) { user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(newPassword); user.PasswordSetAt = DateTime.UtcNow; user.LastPasswordResetDate = DateTime.UtcNow; // Revoke all sessions await db.UserSessions .Where(s => s.UserId == user.Id && s.RevokedAt == null) .ExecuteUpdateAsync(s => s.SetProperty(x => x.RevokedAt, DateTime.UtcNow)); } } await db.SaveChangesAsync(); return true; } public async Task ChangePasswordAsync(Guid userId, string currentPassword, string newPassword) { var user = await db.Users.FindAsync(userId) ?? throw new KeyNotFoundException("User not found"); if (string.IsNullOrEmpty(user.PasswordHash) || !BCrypt.Net.BCrypt.Verify(currentPassword, user.PasswordHash)) throw new UnauthorizedAccessException("Current password is incorrect"); user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(newPassword); user.PasswordSetAt = DateTime.UtcNow; user.LastPasswordResetDate = DateTime.UtcNow; await db.SaveChangesAsync(); } public async Task SetupMfaAsync(Guid userId, string factorType, string? label) { if (!Enum.TryParse(factorType, true, out var type)) throw new ArgumentException("Invalid factor type"); if (type == Domain.Enums.MfaFactorType.TOTP) { var secret = KeyGeneration.GenerateRandomKey(20); var base32Secret = Base32Encoding.ToString(secret); var user = await db.Users.FindAsync(userId)!; var factor = new MfaFactor { UserId = userId, FactorType = type, SecretEncrypted = base32Secret, Label = label ?? user?.Email ?? "FlatRender", }; db.MfaFactors.Add(factor); await db.SaveChangesAsync(); var qrLabel = Uri.EscapeDataString($"FlatRender:{user?.Email ?? userId.ToString()}"); var qrSecret = Uri.EscapeDataString(base32Secret); var qrUrl = $"otpauth://totp/{qrLabel}?secret={qrSecret}&issuer=FlatRender"; return new MfaSetupResponse(factor.Id, base32Secret, qrUrl, null); } throw new NotImplementedException($"Factor type {factorType} not yet supported"); } public async Task VerifyMfaAsync(Guid userId, Guid factorId, string code) { var factor = await db.MfaFactors.FirstOrDefaultAsync(m => m.Id == factorId && m.UserId == userId) ?? throw new KeyNotFoundException("MFA factor not found"); if (!VerifyTotp(factor.SecretEncrypted!, code)) return false; factor.IsVerified = true; factor.IsPrimary = true; factor.LastUsedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return true; } public async Task ChallengeMfaAsync(string mfaToken, string code) { var (userId, tenantId) = ValidateMfaToken(mfaToken); var factor = await db.MfaFactors.FirstOrDefaultAsync(m => m.UserId == userId && m.IsVerified && m.IsPrimary) ?? throw new UnauthorizedAccessException("No verified MFA factor"); if (!VerifyTotp(factor.SecretEncrypted!, code)) throw new UnauthorizedAccessException("Invalid MFA code"); factor.LastUsedAt = DateTime.UtcNow; var user = await db.Users.FindAsync(userId)! ?? throw new UnauthorizedAccessException("User not found"); var tenant = await db.Tenants.FindAsync(tenantId)! ?? throw new UnauthorizedAccessException("Tenant not found"); return await CreateSessionAsync(user, tenant, null, null, null); } public async Task SubscribePushAsync(Guid userId, Guid tenantId, string endpoint, string p256dh, string auth, string? userAgent) { var existing = await db.PushSubscriptions.FirstOrDefaultAsync(p => p.UserId == userId && p.Endpoint == endpoint); if (existing != null) { existing.IsActive = true; existing.FailureCount = 0; } else { db.PushSubscriptions.Add(new PushSubscription { UserId = userId, TenantId = tenantId, Endpoint = endpoint, P256dhKey = p256dh, AuthKey = auth, UserAgent = userAgent, }); } await db.SaveChangesAsync(); } public async Task UnsubscribePushAsync(Guid userId, string? endpoint) { if (string.IsNullOrEmpty(endpoint)) { await db.PushSubscriptions .Where(p => p.UserId == userId) .ExecuteUpdateAsync(p => p.SetProperty(x => x.IsActive, false)); } else { await db.PushSubscriptions .Where(p => p.UserId == userId && p.Endpoint == endpoint) .ExecuteUpdateAsync(p => p.SetProperty(x => x.IsActive, false)); } } // ── Private helpers ────────────────────────────────────────────────── private async Task CreateSessionAsync( User user, Tenant tenant, string? deviceId, string? deviceName, string? ipAddress, Guid? replacingSessionId = null) { var accessToken = tokenService.GenerateAccessToken(user, tenant); var refreshToken = tokenService.GenerateRefreshToken(); var refreshHash = tokenService.HashToken(refreshToken); var session = new UserSession { UserId = user.Id, TenantId = tenant.Id, RefreshTokenHash = refreshHash, DeviceId = deviceId, DeviceName = deviceName, IpAddress = ipAddress, ExpiresAt = DateTime.UtcNow.AddDays(_refreshTokenDays), }; db.UserSessions.Add(session); await db.SaveChangesAsync(); var userResponse = MapUserResponse(user); var tenantResponse = MapTenantResponse(tenant); return new AuthTokensResponse(accessToken, refreshToken, "Bearer", 15 * 60, userResponse, tenantResponse); } /// Issue a session for an externally-authenticated (OAuth/social) user. public Task IssueOAuthSessionAsync(User user, Tenant tenant, string? deviceName, string? ipAddress) => CreateSessionAsync(user, tenant, null, deviceName, ipAddress); private async Task CreateConfirmationTokenAsync( Guid userId, Guid tenantId, TokenPurpose purpose, string identifier, string? ipAddress) { var rawToken = tokenService.GenerateRefreshToken(); var tokenHash = tokenService.HashToken(rawToken); var code = Random.Shared.Next(100000, 999999).ToString(); db.ConfirmationTokens.Add(new ConfirmationToken { UserId = userId, TenantId = tenantId, Purpose = purpose, Identifier = identifier, TokenHash = tokenHash, Code = code, RequestIp = ipAddress, ExpiresAt = DateTime.UtcNow.AddHours(24), }); await db.SaveChangesAsync(); // TODO: send code via email/SMS } private string GenerateMfaToken(Guid userId, Guid tenantId) { // Short-lived token encoding userId+tenantId for the MFA challenge flow var payload = $"{userId}:{tenantId}:{DateTime.UtcNow.AddMinutes(10).Ticks}"; var bytes = System.Text.Encoding.UTF8.GetBytes(payload); return Convert.ToBase64String(bytes); } private (Guid userId, Guid tenantId) ValidateMfaToken(string mfaToken) { try { var payload = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(mfaToken)); var parts = payload.Split(':'); if (parts.Length != 3) throw new UnauthorizedAccessException("Invalid MFA token"); var expiry = new DateTime(long.Parse(parts[2])); if (expiry < DateTime.UtcNow) throw new UnauthorizedAccessException("MFA token expired"); return (Guid.Parse(parts[0]), Guid.Parse(parts[1])); } catch (Exception ex) when (ex is not UnauthorizedAccessException) { throw new UnauthorizedAccessException("Invalid MFA token"); } } private static bool VerifyTotp(string base32Secret, string code) { try { var secretBytes = Base32Encoding.ToBytes(base32Secret); var totp = new Totp(secretBytes); return totp.VerifyTotp(code, out _, new VerificationWindow(1, 1)); } catch { return false; } } internal static UserResponse MapUserResponse(User u) => new( u.Id, u.TenantId, u.Email, u.EmailVerified, u.PhoneNumber, u.PhoneVerified, u.FullName, u.AvatarUrl, u.IsAdmin, u.IsTenantAdmin, u.RegisterMode.ToString(), u.LastActiveDate, u.BalanceMinor, u.AffiliateBalanceMinor, u.LoyaltyScore, u.DailyRemainRenderCount, u.MaxDailyRenderCount, u.ParallelRenderingCeiling, u.UsedStorageBytes, u.RegisterDate, u.Slogan, u.AboutMe, u.CompanyName, u.WebsiteName, u.BirthDate, u.Gender?.ToString(), u.NationalCode, u.CountryCode ); internal static TenantResponse MapTenantResponse(Tenant t) => new( t.Id, t.Slug, t.Name, t.Kind.ToString(), t.Status.ToString(), t.CustomDomain, t.DomainVerified, t.ContactEmail, t.MaxUsers, t.MaxStorageGb, t.MonthlyRenderQty, t.TrialEndsAt, t.CreatedAt ); } public class MfaRequiredException(string mfaToken, string message) : Exception(message) { public string MfaToken { get; } = mfaToken; }