using System.Security.Cryptography; using System.Text; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using Microsoft.IdentityModel.Tokens; using Microsoft.EntityFrameworkCore; using RR3CommunityServer.Data; using RR3CommunityServer.Models; namespace RR3CommunityServer.Services; public interface IAuthService { Task<(bool Success, string? Token, string? Error)> RegisterAsync(RegisterRequest request); Task<(bool Success, LoginResponse? Response, string? Error)> LoginAsync(LoginRequest request); Task<(bool Success, string? Error)> ChangePasswordAsync(int accountId, ChangePasswordRequest request); Task<(bool Success, string? Error)> ForgotPasswordAsync(ForgotPasswordRequest request); Task<(bool Success, string? Error)> ResetPasswordAsync(ResetPasswordRequest request); Task<(bool Success, string? Error)> LinkDeviceAsync(int accountId, LinkDeviceRequest request); Task<(bool Success, string? Error)> UnlinkDeviceAsync(int accountId, string deviceId); Task GetAccountSettingsAsync(int accountId); Task ValidateTokenAsync(string token); } public class AuthService : IAuthService { private readonly RR3DbContext _context; private readonly IConfiguration _configuration; private readonly ILogger _logger; public AuthService(RR3DbContext context, IConfiguration configuration, ILogger logger) { _context = context; _configuration = configuration; _logger = logger; } public async Task<(bool Success, string? Token, string? Error)> RegisterAsync(RegisterRequest request) { // Validate input if (string.IsNullOrWhiteSpace(request.Username) || request.Username.Length < 3) return (false, null, "Username must be at least 3 characters"); if (string.IsNullOrWhiteSpace(request.Email) || !request.Email.Contains('@')) return (false, null, "Invalid email address"); if (string.IsNullOrWhiteSpace(request.Password) || request.Password.Length < 6) return (false, null, "Password must be at least 6 characters"); if (request.Password != request.ConfirmPassword) return (false, null, "Passwords do not match"); // Check if username or email already exists var existingUsername = await _context.Set() .AnyAsync(a => a.Username.ToLower() == request.Username.ToLower()); if (existingUsername) return (false, null, "Username already taken"); var existingEmail = await _context.Set() .AnyAsync(a => a.Email.ToLower() == request.Email.ToLower()); if (existingEmail) return (false, null, "Email already registered"); // Create account var account = new Account { Username = request.Username, Email = request.Email, PasswordHash = HashPassword(request.Password), CreatedAt = DateTime.UtcNow, IsActive = true, EmailVerified = false, EmailVerificationToken = GenerateToken() }; // Create associated game user var user = new User { SynergyId = Guid.NewGuid().ToString(), Nickname = request.Username, Gold = 100000, // Starting gold for community server Cash = 500000, // Starting cash Level = 1, Experience = 0, Reputation = 0, CreatedAt = DateTime.UtcNow }; _context.Users.Add(user); await _context.SaveChangesAsync(); account.UserId = user.Id; _context.Set().Add(account); await _context.SaveChangesAsync(); _logger.LogInformation("New account registered: {Username} ({Email})", account.Username, account.Email); // Generate JWT token var token = GenerateJwtToken(account); return (true, token, null); } public async Task<(bool Success, LoginResponse? Response, string? Error)> LoginAsync(LoginRequest request) { // Find account by username or email var account = await _context.Set() .Include(a => a.User) .FirstOrDefaultAsync(a => a.Username.ToLower() == request.UsernameOrEmail.ToLower() || a.Email.ToLower() == request.UsernameOrEmail.ToLower()); if (account == null) return (false, null, "Invalid username/email or password"); if (!account.IsActive) return (false, null, "Account is disabled"); // Verify password if (!VerifyPassword(request.Password, account.PasswordHash)) return (false, null, "Invalid username/email or password"); // Update last login account.LastLoginAt = DateTime.UtcNow; // Link device if provided if (!string.IsNullOrEmpty(request.DeviceId)) { var deviceLink = await _context.Set() .FirstOrDefaultAsync(da => da.AccountId == account.Id && da.DeviceId == request.DeviceId); if (deviceLink == null) { deviceLink = new DeviceAccount { AccountId = account.Id, DeviceId = request.DeviceId, LinkedAt = DateTime.UtcNow, LastUsedAt = DateTime.UtcNow }; _context.Set().Add(deviceLink); } else { deviceLink.LastUsedAt = DateTime.UtcNow; } } await _context.SaveChangesAsync(); _logger.LogInformation("User logged in: {Username}", account.Username); // Generate JWT token var token = GenerateJwtToken(account); var expiresAt = DateTime.UtcNow.AddDays(30); var response = new LoginResponse { Token = token, AccountId = account.Id, Username = account.Username, Email = account.Email, ExpiresAt = expiresAt }; return (true, response, null); } public async Task<(bool Success, string? Error)> ChangePasswordAsync(int accountId, ChangePasswordRequest request) { var account = await _context.Set().FindAsync(accountId); if (account == null) return (false, "Account not found"); if (!VerifyPassword(request.CurrentPassword, account.PasswordHash)) return (false, "Current password is incorrect"); if (request.NewPassword.Length < 6) return (false, "New password must be at least 6 characters"); if (request.NewPassword != request.ConfirmPassword) return (false, "Passwords do not match"); account.PasswordHash = HashPassword(request.NewPassword); await _context.SaveChangesAsync(); _logger.LogInformation("Password changed for account: {AccountId}", accountId); return (true, null); } public async Task<(bool Success, string? Error)> ForgotPasswordAsync(ForgotPasswordRequest request) { var account = await _context.Set() .FirstOrDefaultAsync(a => a.Email.ToLower() == request.Email.ToLower()); if (account == null) { // Don't reveal if email exists _logger.LogWarning("Password reset requested for non-existent email: {Email}", request.Email); return (true, null); } account.PasswordResetToken = GenerateToken(); account.PasswordResetExpiry = DateTime.UtcNow.AddHours(24); await _context.SaveChangesAsync(); _logger.LogInformation("Password reset token generated for: {Email}", request.Email); // TODO: Send email with reset link // For now, just log the token _logger.LogWarning("Password reset token: {Token} (implement email service)", account.PasswordResetToken); return (true, null); } public async Task<(bool Success, string? Error)> ResetPasswordAsync(ResetPasswordRequest request) { var account = await _context.Set() .FirstOrDefaultAsync(a => a.PasswordResetToken == request.Token); if (account == null || account.PasswordResetExpiry == null || account.PasswordResetExpiry < DateTime.UtcNow) return (false, "Invalid or expired reset token"); if (request.NewPassword.Length < 6) return (false, "Password must be at least 6 characters"); if (request.NewPassword != request.ConfirmPassword) return (false, "Passwords do not match"); account.PasswordHash = HashPassword(request.NewPassword); account.PasswordResetToken = null; account.PasswordResetExpiry = null; await _context.SaveChangesAsync(); _logger.LogInformation("Password reset completed for account: {AccountId}", account.Id); return (true, null); } public async Task<(bool Success, string? Error)> LinkDeviceAsync(int accountId, LinkDeviceRequest request) { var account = await _context.Set().FindAsync(accountId); if (account == null) return (false, "Account not found"); var existingLink = await _context.Set() .FirstOrDefaultAsync(da => da.AccountId == accountId && da.DeviceId == request.DeviceId); if (existingLink != null) return (false, "Device already linked"); var deviceLink = new DeviceAccount { AccountId = accountId, DeviceId = request.DeviceId, DeviceName = request.DeviceName, LinkedAt = DateTime.UtcNow, LastUsedAt = DateTime.UtcNow }; _context.Set().Add(deviceLink); await _context.SaveChangesAsync(); _logger.LogInformation("Device {DeviceId} linked to account {AccountId}", request.DeviceId, accountId); return (true, null); } public async Task<(bool Success, string? Error)> UnlinkDeviceAsync(int accountId, string deviceId) { var deviceLink = await _context.Set() .FirstOrDefaultAsync(da => da.AccountId == accountId && da.DeviceId == deviceId); if (deviceLink == null) return (false, "Device not linked to this account"); _context.Set().Remove(deviceLink); await _context.SaveChangesAsync(); _logger.LogInformation("Device {DeviceId} unlinked from account {AccountId}", deviceId, accountId); return (true, null); } public async Task GetAccountSettingsAsync(int accountId) { var account = await _context.Set() .Include(a => a.User) .Include(a => a.LinkedDevices) .FirstOrDefaultAsync(a => a.Id == accountId); if (account == null) return null; var carsOwned = account.UserId.HasValue ? await _context.OwnedCars.CountAsync(c => c.UserId == account.UserId.Value) : 0; return new AccountSettingsResponse { AccountId = account.Id, Username = account.Username, Email = account.Email, EmailVerified = account.EmailVerified, CreatedAt = account.CreatedAt, LastLoginAt = account.LastLoginAt, LinkedDevices = account.LinkedDevices.Select(d => new LinkedDeviceInfo { DeviceId = d.DeviceId, DeviceName = d.DeviceName, LinkedAt = d.LinkedAt, LastUsedAt = d.LastUsedAt }).ToList(), Gold = account.User?.Gold ?? 0, Cash = account.User?.Cash ?? 0, Level = account.User?.Level ?? 1, CarsOwned = carsOwned }; } public async Task ValidateTokenAsync(string token) { try { var tokenHandler = new JwtSecurityTokenHandler(); var key = Encoding.ASCII.GetBytes(_configuration["Jwt:Secret"] ?? "RR3CommunityServer_DefaultSecret_ChangeThis"); tokenHandler.ValidateToken(token, new TokenValidationParameters { ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(key), ValidateIssuer = false, ValidateAudience = false, ClockSkew = TimeSpan.Zero }, out SecurityToken validatedToken); var jwtToken = (JwtSecurityToken)validatedToken; var accountId = int.Parse(jwtToken.Claims.First(x => x.Type == "id").Value); return await _context.Set().FindAsync(accountId); } catch { return null; } } private string HashPassword(string password) { // Use BCrypt for password hashing return BCrypt.Net.BCrypt.HashPassword(password); } private bool VerifyPassword(string password, string hash) { return BCrypt.Net.BCrypt.Verify(password, hash); } private string GenerateJwtToken(Account account) { var tokenHandler = new JwtSecurityTokenHandler(); var key = Encoding.ASCII.GetBytes(_configuration["Jwt:Secret"] ?? "RR3CommunityServer_DefaultSecret_ChangeThis"); var tokenDescriptor = new SecurityTokenDescriptor { Subject = new ClaimsIdentity(new[] { new Claim("id", account.Id.ToString()), new Claim("username", account.Username), new Claim("email", account.Email) }), Expires = DateTime.UtcNow.AddDays(30), SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature) }; var token = tokenHandler.CreateToken(tokenDescriptor); return tokenHandler.WriteToken(token); } private string GenerateToken() { return Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); } }