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.
381 lines
14 KiB
C#
381 lines
14 KiB
C#
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));
|
|
}
|
|
}
|