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:
380
RR3CommunityServer/Services/AuthService.cs
Normal file
380
RR3CommunityServer/Services/AuthService.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user