diff --git a/RR3CommunityServer/Controllers/AuthController.cs b/RR3CommunityServer/Controllers/AuthController.cs new file mode 100644 index 0000000..bde7450 --- /dev/null +++ b/RR3CommunityServer/Controllers/AuthController.cs @@ -0,0 +1,128 @@ +using Microsoft.AspNetCore.Mvc; +using RR3CommunityServer.Models; +using RR3CommunityServer.Services; + +namespace RR3CommunityServer.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AuthController : ControllerBase +{ + private readonly IAuthService _authService; + private readonly ILogger _logger; + + public AuthController(IAuthService authService, ILogger logger) + { + _authService = authService; + _logger = logger; + } + + [HttpPost("register")] + public async Task Register([FromBody] RegisterRequest request) + { + var (success, token, error) = await _authService.RegisterAsync(request); + + if (!success) + return BadRequest(new { message = error }); + + return Ok(new { message = "Account created successfully", token }); + } + + [HttpPost("login")] + public async Task Login([FromBody] LoginRequest request) + { + var (success, response, error) = await _authService.LoginAsync(request); + + if (!success) + return Unauthorized(new { message = error }); + + return Ok(response); + } + + [HttpPost("change-password")] + public async Task ChangePassword([FromBody] ChangePasswordRequest request) + { + var token = Request.Headers["Authorization"].ToString().Replace("Bearer ", ""); + var account = await _authService.ValidateTokenAsync(token); + + if (account == null) + return Unauthorized(new { message = "Invalid or expired token" }); + + var (success, error) = await _authService.ChangePasswordAsync(account.Id, request); + + if (!success) + return BadRequest(new { message = error }); + + return Ok(new { message = "Password changed successfully" }); + } + + [HttpPost("forgot-password")] + public async Task ForgotPassword([FromBody] ForgotPasswordRequest request) + { + var (success, error) = await _authService.ForgotPasswordAsync(request); + + if (!success) + return BadRequest(new { message = error }); + + return Ok(new { message = "Password reset instructions sent to your email" }); + } + + [HttpPost("reset-password")] + public async Task ResetPassword([FromBody] ResetPasswordRequest request) + { + var (success, error) = await _authService.ResetPasswordAsync(request); + + if (!success) + return BadRequest(new { message = error }); + + return Ok(new { message = "Password reset successfully" }); + } + + [HttpGet("me")] + public async Task GetCurrentUser() + { + var token = Request.Headers["Authorization"].ToString().Replace("Bearer ", ""); + var account = await _authService.ValidateTokenAsync(token); + + if (account == null) + return Unauthorized(new { message = "Invalid or expired token" }); + + var settings = await _authService.GetAccountSettingsAsync(account.Id); + + return Ok(settings); + } + + [HttpPost("link-device")] + public async Task LinkDevice([FromBody] LinkDeviceRequest request) + { + var token = Request.Headers["Authorization"].ToString().Replace("Bearer ", ""); + var account = await _authService.ValidateTokenAsync(token); + + if (account == null) + return Unauthorized(new { message = "Invalid or expired token" }); + + var (success, error) = await _authService.LinkDeviceAsync(account.Id, request); + + if (!success) + return BadRequest(new { message = error }); + + return Ok(new { message = "Device linked successfully" }); + } + + [HttpDelete("unlink-device/{deviceId}")] + public async Task UnlinkDevice(string deviceId) + { + var token = Request.Headers["Authorization"].ToString().Replace("Bearer ", ""); + var account = await _authService.ValidateTokenAsync(token); + + if (account == null) + return Unauthorized(new { message = "Invalid or expired token" }); + + var (success, error) = await _authService.UnlinkDeviceAsync(account.Id, deviceId); + + if (!success) + return BadRequest(new { message = error }); + + return Ok(new { message = "Device unlinked successfully" }); + } +} diff --git a/RR3CommunityServer/Data/RR3DbContext.cs b/RR3CommunityServer/Data/RR3DbContext.cs index 9f7fb17..8d6ac48 100644 --- a/RR3CommunityServer/Data/RR3DbContext.cs +++ b/RR3CommunityServer/Data/RR3DbContext.cs @@ -10,6 +10,8 @@ public class RR3DbContext : DbContext public DbSet Devices { get; set; } public DbSet Users { get; set; } public DbSet Sessions { get; set; } + public DbSet Accounts { get; set; } + public DbSet DeviceAccounts { get; set; } public DbSet Purchases { get; set; } public DbSet CatalogItems { get; set; } public DbSet DailyRewards { get; set; } diff --git a/RR3CommunityServer/Models/AccountModels.cs b/RR3CommunityServer/Models/AccountModels.cs new file mode 100644 index 0000000..ac44a81 --- /dev/null +++ b/RR3CommunityServer/Models/AccountModels.cs @@ -0,0 +1,113 @@ +using RR3CommunityServer.Data; + +namespace RR3CommunityServer.Models; + +// Account entity with authentication +public class Account +{ + public int Id { get; set; } + public string Username { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string PasswordHash { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? LastLoginAt { get; set; } + public bool IsActive { get; set; } = true; + public bool EmailVerified { get; set; } = false; + public string? EmailVerificationToken { get; set; } + public string? PasswordResetToken { get; set; } + public DateTime? PasswordResetExpiry { get; set; } + + // Link to game user data + public int? UserId { get; set; } + public User? User { get; set; } + + // Multiple devices can be linked to one account + public List LinkedDevices { get; set; } = new(); +} + +// Join table for account-device relationship +public class DeviceAccount +{ + public int Id { get; set; } + public int AccountId { get; set; } + public Account Account { get; set; } = null!; + public string DeviceId { get; set; } = string.Empty; + public string? DeviceName { get; set; } + public DateTime LinkedAt { get; set; } = DateTime.UtcNow; + public DateTime LastUsedAt { get; set; } = DateTime.UtcNow; +} + +// Request/Response DTOs for authentication +public class RegisterRequest +{ + public string Username { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string ConfirmPassword { get; set; } = string.Empty; +} + +public class LoginRequest +{ + public string UsernameOrEmail { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string? DeviceId { get; set; } +} + +public class LoginResponse +{ + public string Token { get; set; } = string.Empty; + public int AccountId { get; set; } + public string Username { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public DateTime ExpiresAt { get; set; } +} + +public class ChangePasswordRequest +{ + public string CurrentPassword { get; set; } = string.Empty; + public string NewPassword { get; set; } = string.Empty; + public string ConfirmPassword { get; set; } = string.Empty; +} + +public class ForgotPasswordRequest +{ + public string Email { get; set; } = string.Empty; +} + +public class ResetPasswordRequest +{ + public string Token { get; set; } = string.Empty; + public string NewPassword { get; set; } = string.Empty; + public string ConfirmPassword { get; set; } = string.Empty; +} + +public class LinkDeviceRequest +{ + public string DeviceId { get; set; } = string.Empty; + public string? DeviceName { get; set; } +} + +public class AccountSettingsResponse +{ + public int AccountId { get; set; } + public string Username { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public bool EmailVerified { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? LastLoginAt { get; set; } + public List LinkedDevices { get; set; } = new(); + + // Game progress + public int? Gold { get; set; } + public int? Cash { get; set; } + public int? Level { get; set; } + public int? CarsOwned { get; set; } +} + +public class LinkedDeviceInfo +{ + public string DeviceId { get; set; } = string.Empty; + public string? DeviceName { get; set; } + public DateTime LinkedAt { get; set; } + public DateTime LastUsedAt { get; set; } +} diff --git a/RR3CommunityServer/Program.cs b/RR3CommunityServer/Program.cs index c220c50..e72d0ac 100644 --- a/RR3CommunityServer/Program.cs +++ b/RR3CommunityServer/Program.cs @@ -20,6 +20,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); // CORS for cross-origin requests diff --git a/RR3CommunityServer/RR3CommunityServer.csproj b/RR3CommunityServer/RR3CommunityServer.csproj index 6aca281..9378d7a 100644 --- a/RR3CommunityServer/RR3CommunityServer.csproj +++ b/RR3CommunityServer/RR3CommunityServer.csproj @@ -16,6 +16,9 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + + + diff --git a/RR3CommunityServer/Services/AuthService.cs b/RR3CommunityServer/Services/AuthService.cs new file mode 100644 index 0000000..28d8173 --- /dev/null +++ b/RR3CommunityServer/Services/AuthService.cs @@ -0,0 +1,380 @@ +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)); + } +} diff --git a/RR3CommunityServer/appsettings.json b/RR3CommunityServer/appsettings.json index d77198a..5ff21f8 100644 --- a/RR3CommunityServer/appsettings.json +++ b/RR3CommunityServer/appsettings.json @@ -6,6 +6,12 @@ } }, "AllowedHosts": "*", + "Jwt": { + "Secret": "RR3CommunityServer_SecureJwtSecret_ChangeThisInProduction_MinimumLength32Characters", + "Issuer": "RR3CommunityServer", + "Audience": "RR3Community", + "ExpiryDays": 30 + }, "AssetsBasePath": "Assets/downloaded", "CustomAssetsPath": "Assets/custom", "ModsPath": "Assets/mods",