using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Nuuru.Server.Data; using Nuuru.Server.Models; namespace Nuuru.Server.Services { public interface IAuthService { Task RegisterAsync(string userName, string password, string? signupIpAddress); Task LoginAsync(string userName, string password, string? loginIpAddress); } public class AuthResult { public bool Success { get; set; } public Guid? UserId { get; set; } public string? RefreshToken { get; set; } public bool RequiresVerification { get; set; } public bool CanRetryIpVerification { get; set; } public DateTime? VerificationAvailableAt { get; set; } public string? Message { get; set; } public string[]? Errors { get; set; } public static AuthResult Succeeded(Guid userId, string refreshToken) => new() { Success = true, UserId = userId, RefreshToken = refreshToken }; public static AuthResult VerificationPending(Guid userId, DateTime verificationAvailableAt, string message) => new() { Success = true, UserId = userId, RequiresVerification = true, VerificationAvailableAt = verificationAvailableAt, Message = message }; public static AuthResult Failed(params string[] errors) => new() { Success = false, Errors = errors }; public static AuthResult FailedWithRetry(params string[] errors) => new() { Success = false, Errors = errors, CanRetryIpVerification = true }; } public class AuthService : IAuthService { private const string DefaultRoleName = "User"; private readonly UserManager _userManager; private readonly SignInManager _signInManager; private readonly ITokenService _tokenService; private readonly ISiteSettingsService _siteSettingsService; private readonly IIpIntelligenceService _ipIntelligenceService; private readonly ApplicationDbContext _dbContext; private readonly ILogger _logger; public AuthService( UserManager userManager, SignInManager signInManager, ITokenService tokenService, ISiteSettingsService siteSettingsService, IIpIntelligenceService ipIntelligenceService, ApplicationDbContext dbContext, ILogger logger) { _userManager = userManager; _signInManager = signInManager; _tokenService = tokenService; _siteSettingsService = siteSettingsService; _ipIntelligenceService = ipIntelligenceService; _dbContext = dbContext; _logger = logger; } public async Task RegisterAsync(string userName, string password, string? signupIpAddress) { if (!string.IsNullOrWhiteSpace(signupIpAddress)) { var ipAlreadyUsed = await _dbContext.AuditLogs .AnyAsync(a => a.IpAddress == signupIpAddress && a.UserId != null); if (ipAlreadyUsed) { return AuthResult.Failed("Registration is not available from this network."); } } var verificationEnabled = await _siteSettingsService.IsSignupGeoVerificationEnabledAsync(); IpIntelligenceResult? signupLookup = null; DateTime? verificationAvailableAt = null; if (verificationEnabled) { var lookupUrl = await _siteSettingsService.GetSignupGeoVerificationLookupUrlAsync(); if (string.IsNullOrWhiteSpace(signupIpAddress)) { return AuthResult.Failed("Could not determine your IP address for account verification."); } signupLookup = await _ipIntelligenceService.LookupAsync(signupIpAddress, lookupUrl); if (signupLookup == null) { return AuthResult.Failed("Could not validate your IP address. Please try again later."); } var rejectFlaggedVpnSignups = await _siteSettingsService.ShouldRejectFlaggedVpnSignupsAsync(); if (rejectFlaggedVpnSignups && signupLookup.IsFlagged) { return AuthResult.Failed("Sign ups from VPN or proxy connections are not allowed."); } var holdHours = await _siteSettingsService.GetSignupGeoVerificationHoldHoursAsync(); verificationAvailableAt = DateTime.UtcNow.AddHours(holdHours); } var user = new ApplicationUser { UserName = userName, Id = Guid.NewGuid(), IsGeoVerificationPending = verificationEnabled, Status = "", Biography = string.Empty }; if (verificationEnabled && signupLookup != null && verificationAvailableAt.HasValue) { user.SignupVerificationIpAddress = signupLookup.IpAddress ?? signupIpAddress; user.SignupVerificationCountryCode = Normalize(signupLookup.CountryCode); user.SignupVerificationRegionCode = Normalize(signupLookup.RegionCode ?? signupLookup.Region); user.SignupVerificationCity = Normalize(signupLookup.City); user.SignupVerificationIspAsn = Normalize(signupLookup.IspAsn); user.SignupVerificationIspName = Normalize(signupLookup.IspName); user.SignupVerificationAvailableAt = verificationAvailableAt.Value; } var result = await _userManager.CreateAsync(user, password); if (!result.Succeeded) { var errors = result.Errors.Select(e => e.Description).ToArray(); return AuthResult.Failed(errors); } await _userManager.AddToRoleAsync(user, "User"); _logger.LogInformation("User {UserName} registered successfully", userName); if (verificationEnabled && verificationAvailableAt.HasValue) { return AuthResult.VerificationPending( user.Id, verificationAvailableAt.Value, $"Account created. Login is available after verification hold ends at {verificationAvailableAt.Value:O} UTC."); } var refreshToken = await _tokenService.GenerateRefreshTokenAsync(user); return AuthResult.Succeeded(user.Id, refreshToken); } public async Task LoginAsync(string userName, string password, string? loginIpAddress) { var verificationEnabled = await _siteSettingsService.IsSignupGeoVerificationEnabledAsync(); var user = await _userManager.FindByNameAsync(userName); if (user == null) { return AuthResult.Failed("Invalid credentials"); } if (user.IsSystemAccount) { return AuthResult.Failed("Invalid credentials"); } var result = await _signInManager.PasswordSignInAsync( userName, password, isPersistent: false, lockoutOnFailure: true); if (!result.Succeeded) { if (result.IsLockedOut) { _logger.LogWarning("User {UserName} is locked out", userName); return AuthResult.Failed("Account is locked"); } return AuthResult.Failed("Invalid credentials"); } // Banned users are allowed to log in so they can view ban details and submit appeals. // The BanCheckMiddleware enforces access restrictions on all non-appeal endpoints. if (verificationEnabled && user.IsGeoVerificationPending) { var roles = await _userManager.GetRolesAsync(user); if (HasNonDefaultRole(roles)) { user.IsGeoVerificationPending = false; user.SignupVerificationCompletedAt = DateTime.UtcNow; var bypassUpdateResult = await _userManager.UpdateAsync(user); if (!bypassUpdateResult.Succeeded) { var errors = bypassUpdateResult.Errors.Select(e => e.Description).ToArray(); return AuthResult.Failed(errors); } var bypassRefreshToken = await _tokenService.GenerateRefreshTokenAsync(user); return AuthResult.Succeeded(user.Id, bypassRefreshToken); } var holdUntil = user.SignupVerificationAvailableAt; if (holdUntil.HasValue && DateTime.UtcNow < holdUntil.Value) { return AuthResult.Failed($"Account verification hold is active until {holdUntil.Value:O} UTC."); } if (string.IsNullOrWhiteSpace(loginIpAddress)) { return AuthResult.FailedWithRetry("Could not determine your IP address for account verification."); } var ipAlreadyUsed = await _dbContext.AuditLogs .AnyAsync(a => a.IpAddress == loginIpAddress && a.UserId != null && a.UserId != user.Id); if (ipAlreadyUsed) { return AuthResult.Failed("Verification is not available from this network."); } var lookupUrl = await _siteSettingsService.GetSignupGeoVerificationLookupUrlAsync(); var loginLookup = await _ipIntelligenceService.LookupAsync(loginIpAddress, lookupUrl); if (loginLookup == null) { return AuthResult.FailedWithRetry("Could not validate your IP address. Please try again later."); } if (!IsRegionMatch(user, loginLookup) || !IsIspMatch(user, loginLookup)) { return AuthResult.FailedWithRetry("Login IP did not match your signup region and ISP."); } user.IsGeoVerificationPending = false; user.SignupVerificationCompletedAt = DateTime.UtcNow; var updateResult = await _userManager.UpdateAsync(user); if (!updateResult.Succeeded) { var errors = updateResult.Errors.Select(e => e.Description).ToArray(); return AuthResult.Failed(errors); } } var refreshToken = await _tokenService.GenerateRefreshTokenAsync(user); return AuthResult.Succeeded(user.Id, refreshToken); } private static bool HasNonDefaultRole(IEnumerable roles) { return roles.Any(role => !role.Equals(DefaultRoleName, StringComparison.OrdinalIgnoreCase)); } private static bool IsRegionMatch(ApplicationUser user, IpIntelligenceResult loginLookup) { if (!IsSame(user.SignupVerificationCountryCode, loginLookup.CountryCode)) { return false; } var signupRegion = Normalize(user.SignupVerificationRegionCode); if (signupRegion != null && !IsSame(signupRegion, Normalize(loginLookup.RegionCode ?? loginLookup.Region))) { return false; } var signupCity = Normalize(user.SignupVerificationCity); if (signupCity != null && !IsSame(signupCity, Normalize(loginLookup.City))) { return false; } return true; } private static bool IsIspMatch(ApplicationUser user, IpIntelligenceResult loginLookup) { if (!string.IsNullOrWhiteSpace(user.SignupVerificationIspAsn) && !string.IsNullOrWhiteSpace(loginLookup.IspAsn)) { return IsSame(user.SignupVerificationIspAsn, loginLookup.IspAsn); } return IsSame(user.SignupVerificationIspName, loginLookup.IspName); } private static bool IsSame(string? left, string? right) { return string.Equals(Normalize(left), Normalize(right), StringComparison.OrdinalIgnoreCase); } private static string? Normalize(string? value) { if (string.IsNullOrWhiteSpace(value)) { return null; } return value.Trim(); } } }