using System.Security.Cryptography; using System.Text; using Media.JoshHeaps.Net; using Media.JoshHeaps.Net.Models; namespace Media.JoshHeaps.Net.Services; public class AuthService(DbExecutor db) { // Hash a password using BCrypt public string HashPassword(string password) { return BCrypt.Net.BCrypt.HashPassword(password); } // Verify a password against a hash public bool VerifyPassword(string password, string hash) { try { return BCrypt.Net.BCrypt.Verify(password, hash); } catch { return false; } } // Register a new user public async Task<(bool Success, string? Error, string? VerificationToken)> RegisterUserAsync(string email, string username, string password) { try { // Check if email already exists var emailExists = await db.ExecuteAsync( "SELECT COUNT(*) FROM app.users WHERE email = @email", new { email } ); if (emailExists > 0) { return (false, "Email is already registered", null); } // Check if username already exists var usernameExists = await db.ExecuteAsync( "SELECT COUNT(*) FROM app.users WHERE username = @username", new { username } ); if (usernameExists > 0) { return (false, "Username is already taken", null); } // Hash the password var passwordHash = HashPassword(password); // Insert new user and get the user ID var userId = await db.ExecuteAsync( @"INSERT INTO app.users (email, username, password_hash, is_active, email_verified, failed_login_attempts) VALUES (@email, @username, @passwordHash, true, false, 0) RETURNING id", new { email, username, passwordHash } ); // Generate verification token var verificationToken = GenerateVerificationToken(); var expiresAt = DateTime.UtcNow.AddHours(24); // Store verification token await db.ExecuteAsync( @"INSERT INTO app.email_verification_tokens (user_id, token, expires_at) VALUES (@userId, @verificationToken, @expiresAt)", new { userId, verificationToken, expiresAt } ); return (true, null, verificationToken); } catch (Exception ex) { return (false, $"Registration failed: {ex.Message}", null); } } // Generate verification token private string GenerateVerificationToken() { return Guid.NewGuid().ToString("N"); } // Verify email with token public async Task<(bool Success, string? Error)> VerifyEmailAsync(string token) { try { // Get token info var tokenRow = await db.ExecuteReaderAsync( @"SELECT user_id, expires_at, verified_at FROM app.email_verification_tokens WHERE token = @token", reader => { return new { UserId = reader.GetInt64(0), ExpiresAt = reader.GetDateTime(1), VerifiedAt = reader.IsDBNull(2) ? (DateTime?)null : reader.GetDateTime(2) }; }, new { token } ); if (tokenRow == null) { return (false, "Invalid verification token"); } if (tokenRow.VerifiedAt.HasValue) { return (false, "Email has already been verified"); } if (tokenRow.ExpiresAt < DateTime.UtcNow) { return (false, "Verification token has expired"); } // Mark token as verified await db.ExecuteAsync( "UPDATE app.email_verification_tokens SET verified_at = @verifiedAt WHERE token = @token", new { verifiedAt = DateTime.UtcNow, token } ); // Mark user email as verified await db.ExecuteAsync( "UPDATE app.users SET email_verified = true WHERE id = @userId", new { userId = tokenRow.UserId } ); return (true, null); } catch (Exception ex) { return (false, $"Verification failed: {ex.Message}"); } } // Resend verification email public async Task<(bool Success, string? Error, string? VerificationToken)> ResendVerificationTokenAsync(string email) { try { // Get user by email var userRow = await db.ExecuteReaderAsync( "SELECT id, email_verified FROM app.users WHERE email = @email", reader => { return new { UserId = reader.GetInt64(0), EmailVerified = reader.GetBoolean(1) }; }, new { email } ); if (userRow == null) { return (false, "Email not found", null); } if (userRow.EmailVerified) { return (false, "Email is already verified", null); } // Invalidate old tokens (set verified_at to prevent reuse) await db.ExecuteAsync( "UPDATE app.email_verification_tokens SET verified_at = @verifiedAt WHERE user_id = @userId AND verified_at IS NULL", new { verifiedAt = DateTime.UtcNow, userId = userRow.UserId } ); // Generate new verification token var verificationToken = GenerateVerificationToken(); var expiresAt = DateTime.UtcNow.AddHours(24); // Store new verification token await db.ExecuteAsync( @"INSERT INTO app.email_verification_tokens (user_id, token, expires_at) VALUES (@userId, @verificationToken, @expiresAt)", new { userId = userRow.UserId, verificationToken, expiresAt } ); return (true, null, verificationToken); } catch (Exception ex) { return (false, $"Failed to resend verification: {ex.Message}", null); } } // Login user public async Task<(bool Success, string? Error, UserLoginInfo? User)> LoginAsync(string emailOrUsername, string password) { try { // Get user by email or username var query = @" SELECT id, email, username, password_hash, is_active, email_verified, failed_login_attempts, locked_until, last_login FROM app.users WHERE email = @emailOrUsername OR username = @emailOrUsername"; var userRow = await db.ExecuteReaderAsync(query, reader => { return new UserRow { Id = reader.GetInt64(0), Email = reader.GetString(1), Username = reader.GetString(2), PasswordHash = reader.GetString(3), IsActive = reader.GetBoolean(4), EmailVerified = reader.GetBoolean(5), FailedAttempts = reader.GetInt32(6), LockedUntil = reader.IsDBNull(7) ? (DateTime?)null : reader.GetDateTime(7) }; }, new { emailOrUsername }); if (userRow == null) { return (false, "Invalid email/username or password", null); } // Check if account is locked if (userRow.LockedUntil.HasValue && userRow.LockedUntil.Value > DateTime.UtcNow) { var remainingMinutes = (int)(userRow.LockedUntil.Value - DateTime.UtcNow).TotalMinutes; return (false, $"Account is locked. Try again in {remainingMinutes} minute(s)", null); } // Check if account is active if (!userRow.IsActive) { return (false, "Account is deactivated", null); } // Verify password if (!VerifyPassword(password, userRow.PasswordHash)) { // Increment failed attempts await IncrementFailedLoginAttemptsAsync(userRow.Id, userRow.FailedAttempts); return (false, "Invalid email/username or password", null); } // Successful login - reset failed attempts and update last login await ResetFailedLoginAttemptsAsync(userRow.Id); var userInfo = new UserLoginInfo { Id = userRow.Id, Email = userRow.Email, Username = userRow.Username, IsActive = userRow.IsActive, EmailVerified = userRow.EmailVerified }; return (true, null, userInfo); } catch (Exception ex) { return (false, $"Login failed: {ex.Message}", null); } } // Increment failed login attempts private async Task IncrementFailedLoginAttemptsAsync(long userId, int currentAttempts) { var newAttempts = currentAttempts + 1; // Lock account for 15 minutes after 5 failed attempts if (newAttempts >= 5) { var lockUntil = DateTime.UtcNow.AddMinutes(15); await db.ExecuteAsync( "UPDATE app.users SET failed_login_attempts = @newAttempts, locked_until = @lockUntil WHERE id = @userId", new { userId, newAttempts, lockUntil } ); } else { await db.ExecuteAsync( "UPDATE app.users SET failed_login_attempts = @newAttempts WHERE id = @userId", new { userId, newAttempts } ); } } // Reset failed login attempts private async Task ResetFailedLoginAttemptsAsync(long userId) { await db.ExecuteAsync( "UPDATE app.users SET failed_login_attempts = 0, locked_until = NULL, last_login = @lastLogin WHERE id = @userId", new { userId, lastLogin = DateTime.UtcNow } ); } public static string GenerateSecureToken() { var bytes = RandomNumberGenerator.GetBytes(32); return Convert.ToBase64String(bytes) .Replace("+", "-") .Replace("/", "_") .TrimEnd('='); } public static string HashToken(string token) { var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token)); return Convert.ToHexString(bytes).ToLowerInvariant(); } public async Task<(bool Success, string? Error, string? Token, string? Username)> RequestPasswordResetAsync(string email) { try { var userRow = await db.ExecuteReaderAsync( "SELECT id, username, is_active, locked_until FROM app.users WHERE email = @email", reader => new { UserId = reader.GetInt64(0), Username = reader.GetString(1), IsActive = reader.GetBoolean(2), LockedUntil = reader.IsDBNull(3) ? (DateTime?)null : reader.GetDateTime(3) }, new { email } ); if (userRow == null) { // Artificial delay to prevent timing-based email enumeration await Task.Delay(Random.Shared.Next(100, 300)); return (true, null, null, null); } // Silently succeed for inactive/locked accounts (don't reveal state) if (!userRow.IsActive || (userRow.LockedUntil.HasValue && userRow.LockedUntil.Value > DateTime.UtcNow)) { return (true, null, null, null); } // Rate limit: max 3 requests per hour var recentCount = await db.ExecuteAsync( @"SELECT COUNT(*) FROM app.password_reset_tokens WHERE user_id = @userId AND created_at > @cutoff", new { userId = userRow.UserId, cutoff = DateTimeOffset.UtcNow.AddHours(-1) } ); if (recentCount >= 3) { return (true, null, null, null); } // Invalidate all existing unused tokens for this user await db.ExecuteNonQueryAsync( @"UPDATE app.password_reset_tokens SET used_at = @now WHERE user_id = @userId AND used_at IS NULL", new { userId = userRow.UserId, now = DateTimeOffset.UtcNow } ); // Generate and store new token var token = GenerateSecureToken(); var tokenHash = HashToken(token); var expiresAt = DateTimeOffset.UtcNow.AddHours(1); await db.ExecuteNonQueryAsync( @"INSERT INTO app.password_reset_tokens (user_id, token_hash, expires_at) VALUES (@userId, @tokenHash, @expiresAt)", new { userId = userRow.UserId, tokenHash, expiresAt } ); return (true, null, token, userRow.Username); } catch (Exception ex) { return (false, $"Password reset request failed: {ex.Message}", null, null); } } public async Task<(bool Valid, string? Error)> ValidatePasswordResetTokenAsync(string token) { try { var tokenHash = HashToken(token); var tokenRow = await db.ExecuteReaderAsync( @"SELECT expires_at, used_at FROM app.password_reset_tokens WHERE token_hash = @tokenHash", reader => new { ExpiresAt = reader.GetFieldValue(0), UsedAt = reader.IsDBNull(1) ? (DateTimeOffset?)null : reader.GetFieldValue(1) }, new { tokenHash } ); if (tokenRow == null) return (false, "Invalid or expired reset link. Please request a new one."); if (tokenRow.UsedAt.HasValue) return (false, "This reset link has already been used. Please request a new one."); if (tokenRow.ExpiresAt < DateTimeOffset.UtcNow) return (false, "This reset link has expired. Please request a new one."); return (true, null); } catch (Exception ex) { return (false, $"Token validation failed: {ex.Message}"); } } public async Task<(bool Success, string? Error)> ResetPasswordAsync(string token, string newPassword) { try { var tokenHash = HashToken(token); var tokenRow = await db.ExecuteReaderAsync( @"SELECT id, user_id, expires_at, used_at FROM app.password_reset_tokens WHERE token_hash = @tokenHash", reader => new { Id = reader.GetInt64(0), UserId = reader.GetInt64(1), ExpiresAt = reader.GetFieldValue(2), UsedAt = reader.IsDBNull(3) ? (DateTimeOffset?)null : reader.GetFieldValue(3) }, new { tokenHash } ); if (tokenRow == null) return (false, "Invalid or expired reset link. Please request a new one."); if (tokenRow.UsedAt.HasValue) return (false, "This reset link has already been used. Please request a new one."); if (tokenRow.ExpiresAt < DateTimeOffset.UtcNow) return (false, "This reset link has expired. Please request a new one."); // Hash new password and update user var passwordHash = HashPassword(newPassword); await db.ExecuteNonQueryAsync( @"UPDATE app.users SET password_hash = @passwordHash, failed_login_attempts = 0, locked_until = NULL WHERE id = @userId", new { userId = tokenRow.UserId, passwordHash } ); // Mark token as used await db.ExecuteNonQueryAsync( "UPDATE app.password_reset_tokens SET used_at = @now WHERE id = @tokenId", new { tokenId = tokenRow.Id, now = DateTimeOffset.UtcNow } ); return (true, null); } catch (Exception ex) { return (false, $"Password reset failed: {ex.Message}"); } } }