Add user authentication and account management system

Features:
- User registration with username, email, password
- Login with JWT token authentication
- Password hashing with BCrypt
- Account settings & management
- Device linking to accounts
- Change password & password reset
- Account-User relationship (1-to-1 with game data)

Database entities:
- Account: User accounts with credentials
- DeviceAccount: Link devices to accounts (many-to-many)

API endpoints:
- POST /api/auth/register
- POST /api/auth/login
- POST /api/auth/change-password
- POST /api/auth/forgot-password
- POST /api/auth/reset-password
- GET /api/auth/me
- POST /api/auth/link-device
- DELETE /api/auth/unlink-device/{deviceId}

Starting resources for new accounts:
- 100,000 Gold
- 500,000 Cash
- Level 1

Ready for VPS deployment with HTTPS.
This commit is contained in:
2026-02-19 15:00:16 -08:00
parent 8ba7c605f1
commit a6bab92282
7 changed files with 633 additions and 0 deletions

View File

@@ -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<AccountSettingsResponse?> GetAccountSettingsAsync(int accountId);
Task<Account?> ValidateTokenAsync(string token);
}
public class AuthService : IAuthService
{
private readonly RR3DbContext _context;
private readonly IConfiguration _configuration;
private readonly ILogger<AuthService> _logger;
public AuthService(RR3DbContext context, IConfiguration configuration, ILogger<AuthService> 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<Account>()
.AnyAsync(a => a.Username.ToLower() == request.Username.ToLower());
if (existingUsername)
return (false, null, "Username already taken");
var existingEmail = await _context.Set<Account>()
.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<Account>().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<Account>()
.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<DeviceAccount>()
.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<DeviceAccount>().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<Account>().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<Account>()
.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<Account>()
.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<Account>().FindAsync(accountId);
if (account == null)
return (false, "Account not found");
var existingLink = await _context.Set<DeviceAccount>()
.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<DeviceAccount>().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<DeviceAccount>()
.FirstOrDefaultAsync(da => da.AccountId == accountId && da.DeviceId == deviceId);
if (deviceLink == null)
return (false, "Device not linked to this account");
_context.Set<DeviceAccount>().Remove(deviceLink);
await _context.SaveChangesAsync();
_logger.LogInformation("Device {DeviceId} unlinked from account {AccountId}", deviceId, accountId);
return (true, null);
}
public async Task<AccountSettingsResponse?> GetAccountSettingsAsync(int accountId)
{
var account = await _context.Set<Account>()
.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<Account?> 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<Account>().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));
}
}