Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions RustRconServerManager.Backend/Cli/ResetPasswordCli.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,18 @@ public static async Task<int> RunAsync(IServiceProvider services)
Console.WriteLine("RustRconServerManager - Password Reset");
Console.WriteLine();

Console.Write("Account email: ");
var email = Console.ReadLine()?.Trim();
if (string.IsNullOrWhiteSpace(email))
Console.Write("Account username: ");
var username = Console.ReadLine()?.Trim();
if (string.IsNullOrWhiteSpace(username))
{
Console.WriteLine("No email entered. Aborting.");
Console.WriteLine("No username entered. Aborting.");
return 1;
}

var user = await userManager.FindByEmailAsync(email);
var user = await userManager.FindByNameAsync(username);
if (user == null)
{
Console.WriteLine($"No account found for '{email}'.");
Console.WriteLine($"No account found for '{username}'.");
return 1;
}

Expand Down Expand Up @@ -99,7 +99,7 @@ public static async Task<int> RunAsync(IServiceProvider services)
}

Console.WriteLine();
Console.WriteLine($"Password for '{email}' has been reset. Any existing sessions have been signed out.");
Console.WriteLine($"Password for '{username}' has been reset. Any existing sessions have been signed out.");
return 0;
}

Expand Down
75 changes: 59 additions & 16 deletions RustRconServerManager.Backend/Controllers/AccountController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,22 @@ public AccountController(AppDbContext dbContext)
[HttpGet("BasicInfo")]
public IActionResult GetBasicInfo()
{
var userEmail = User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value;
var userId = User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value;

if (string.IsNullOrEmpty(userEmail))
return Unauthorized("No email claim found");
if (string.IsNullOrEmpty(userId))
return Unauthorized("No user claim found");

var user = _dbContext.Users.Where(x => x.Email == userEmail).FirstOrDefault();
var user = _dbContext.Users.Where(x => x.Id == userId).FirstOrDefault();

if (user == null)
return NotFound();

return Ok(new
{
Email = user.Email,
Username = user.UserName,
DisplayName = user.DisplayName,
HasChosenUsername = user.HasChosenUsername,
IsModerator = user.IsModerator,
isAdmin = user.isAdmin
});
Expand All @@ -54,12 +56,12 @@ public IActionResult GetBasicInfo()
[HttpGet("SelectedServerId")]
public IActionResult GetSelectedServerId()
{
var userEmail = User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value;
var userId = User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value;

if (string.IsNullOrEmpty(userEmail))
return Unauthorized("No email claim found");
if (string.IsNullOrEmpty(userId))
return Unauthorized("No user claim found");

var user = _dbContext.Users.Where(x => x.Email == userEmail).FirstOrDefault();
var user = _dbContext.Users.Where(x => x.Id == userId).FirstOrDefault();

if (user == null)
return NotFound();
Expand All @@ -77,12 +79,12 @@ public IActionResult GetSelectedServerId()
public IActionResult AccountInformation()
{

var userEmail = User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value;
var userId = User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value;

if (string.IsNullOrEmpty(userEmail))
return Unauthorized("No email claim found");
if (string.IsNullOrEmpty(userId))
return Unauthorized("No user claim found");

var accountInformation = _dbContext.Users.Where(x => x.Email == userEmail).FirstOrDefault();
var accountInformation = _dbContext.Users.Where(x => x.Id == userId).FirstOrDefault();

if (accountInformation == null)
return NotFound();
Expand All @@ -94,6 +96,7 @@ public IActionResult AccountInformation()
Account_AccountInformationDTO dto = new Account_AccountInformationDTO();

dto.Email = accountInformation.Email;
dto.Username = accountInformation.UserName;
dto.DisplayName = accountInformation.DisplayName;
dto.CreatedAt = accountInformation.CreatedAt;
dto.isAdmin = accountInformation.isAdmin;
Expand All @@ -119,12 +122,12 @@ public async Task<IActionResult> SetDisplayName([FromBody] Account_SetDisplayNam
if (trimmed.Length > 50)
return BadRequest("Display name must be 50 characters or fewer.");

var userEmail = User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value;
var userId = User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value;

if (string.IsNullOrEmpty(userEmail))
return Unauthorized("No email claim found");
if (string.IsNullOrEmpty(userId))
return Unauthorized("No user claim found");

var user = await _dbContext.Users.FirstOrDefaultAsync(x => x.Email == userEmail);
var user = await _dbContext.Users.FirstOrDefaultAsync(x => x.Id == userId);

if (user == null)
return NotFound();
Expand All @@ -134,5 +137,45 @@ public async Task<IActionResult> SetDisplayName([FromBody] Account_SetDisplayNam

return Ok(new { DisplayName = user.DisplayName });
}

/// <summary>
/// Sets the current user's username (used to log in instead of an email address).
/// Required for every account going forward - existing accounts created before
/// username-based login existed had it silently set equal to their email, and are
/// prompted once to pick a real one (see MainLayout's RequireUsernameGate).
/// </summary>
[HttpPut("Username")]
public async Task<IActionResult> SetUsername([FromBody] Account_SetUsernameDTO dto)
{
var trimmed = dto.Username?.Trim() ?? string.Empty;

if (string.IsNullOrEmpty(trimmed))
return BadRequest("Username is required.");

if (trimmed.Length > 50)
return BadRequest("Username must be 50 characters or fewer.");

var userId = User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value;

if (string.IsNullOrEmpty(userId))
return Unauthorized("No user claim found");

var user = await _dbContext.Users.FirstOrDefaultAsync(x => x.Id == userId);

if (user == null)
return NotFound();

var existing = await _dbContext.Users
.FirstOrDefaultAsync(x => x.NormalizedUserName == trimmed.ToUpperInvariant() && x.Id != userId);
if (existing != null)
return BadRequest("That username is already taken.");

user.UserName = trimmed;
user.NormalizedUserName = trimmed.ToUpperInvariant();
user.HasChosenUsername = true;
await _dbContext.SaveChangesAsync();

return Ok(new { Username = user.UserName });
}
}
}
37 changes: 25 additions & 12 deletions RustRconServerManager.Backend/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ public async Task<IActionResult> Setup(Authorization_SetupRequestDTO model)
return BadRequest(new { message = "Display name is required" });
}

if (string.IsNullOrWhiteSpace(model.Username))
{
return BadRequest(new { message = "Username is required" });
}

// Get or create default SystemProfile
var systemProfile = await _dbContext.SystemProfiles.FirstOrDefaultAsync();
if (systemProfile == null)
Expand Down Expand Up @@ -111,12 +116,13 @@ public async Task<IActionResult> Setup(Authorization_SetupRequestDTO model)
// Create the admin user
var user = new ApplicationUser
{
UserName = model.Email,
Email = model.Email,
UserName = model.Username.Trim(),
Email = string.IsNullOrWhiteSpace(model.Email) ? null : model.Email.Trim(),
DisplayName = model.DisplayName.Trim(),
SystemProfileId = systemProfile.Id,
isAdmin = true,
EmailConfirmed = true, // Auto-confirm email for initial admin
EmailConfirmed = !string.IsNullOrWhiteSpace(model.Email), // Auto-confirm email for initial admin, if one was provided
HasChosenUsername = true,
CreatedAt = DateTime.UtcNow
};

Expand Down Expand Up @@ -229,12 +235,12 @@ public async Task<IActionResult> Logout()
{
// Get the token hash from claims
var tokenHash = User.FindFirst(System.Security.Claims.ClaimTypes.Hash)?.Value;
var userEmail = User.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value;
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;

if (string.IsNullOrEmpty(tokenHash) || string.IsNullOrEmpty(userEmail))
if (string.IsNullOrEmpty(tokenHash) || string.IsNullOrEmpty(userId))
return BadRequest("Missing token information");

var user = await _userManager.FindByEmailAsync(userEmail);
var user = await _userManager.FindByIdAsync(userId);
if (user == null)
return NotFound("User not found");

Expand All @@ -259,11 +265,11 @@ public async Task<IActionResult> Logout()
public async Task<IActionResult> LogoutAll()
{
// Logout from all devices/browsers
var userEmail = User.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value;
if (string.IsNullOrEmpty(userEmail))
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId))
return BadRequest("Missing user information");

var user = await _userManager.FindByEmailAsync(userEmail);
var user = await _userManager.FindByIdAsync(userId);
if (user == null)
return NotFound("User not found");

Expand Down Expand Up @@ -345,7 +351,7 @@ public IActionResult Me()
[HttpPost("login")]
public async Task<IActionResult> Login(Authorization_UserLoginDTO model)
{
var user = await _userManager.FindByEmailAsync(model.Email);
var user = await _userManager.FindByNameAsync(model.Username);
if (user == null)
return Unauthorized(new Authorization_LoginResponseDTO
{
Expand Down Expand Up @@ -623,16 +629,23 @@ private string GenerateJwtToken(ApplicationUser user, string sessionHash = "")
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var userAgent = Request.Headers["User-Agent"].ToString() ?? "unknown";

var claims = new[]
var claims = new List<Claim>
{
new Claim(ClaimTypes.Email, user.Email),
new Claim(ClaimTypes.Name, user.UserName ?? user.Id),
new Claim(ClaimTypes.NameIdentifier, user.Id),
new Claim("ip", ipAddress),
new Claim("ua", userAgent),
new Claim(ClaimTypes.Version, "1.0"),
new Claim(ClaimTypes.Hash, sessionHash)
};

// Email is optional now - only include the claim when the account actually has one,
// since a Claim's value can't be null.
if (!string.IsNullOrEmpty(user.Email))
{
claims.Add(new Claim(ClaimTypes.Email, user.Email));
}

var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["Jwt:Key"]));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

Expand Down
10 changes: 5 additions & 5 deletions RustRconServerManager.Backend/Controllers/DashboardController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ public async Task<IActionResult> BanPlayer([FromBody] BanPlayerRequest request)
DateTime? expiryDate = banExpiredAt == now ? null : banExpiredAt;

// Get current user email for audit trail
var userEmail = User.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value ?? "System";
var userEmail = User.FindFirst(System.Security.Claims.ClaimTypes.Name)?.Value ?? "System";

// Create PlayerBan record (single source of truth for bans)
// If global ban, use ServerId = -1
Expand Down Expand Up @@ -445,7 +445,7 @@ public async Task<IActionResult> UnbanPlayer([FromBody] UnbanPlayerRequest reque
if (banRecord == null && globalBan == null)
return NotFound("Ban record not found");

var userEmail = User.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value ?? "System";
var userEmail = User.FindFirst(System.Security.Claims.ClaimTypes.Name)?.Value ?? "System";
var now = DateTime.UtcNow;

if (globalBan != null)
Expand Down Expand Up @@ -873,7 +873,7 @@ public async Task<IActionResult> ToggleGlobalBan(int banId, [FromBody] ToggleGlo
_logger.LogInformation("[GLOBAL UNBAN] Toggling off global ban for {SteamId}. Unbanning from all servers...", ban.SteamId?.Replace("\r", "").Replace("\n", ""));

// Get current user email for audit trail
var userEmail = User.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value ?? "System";
var userEmail = User.FindFirst(System.Security.Claims.ClaimTypes.Name)?.Value ?? "System";
var now = DateTime.UtcNow;

// Find all bans for this player with the note indicating they're from a global ban
Expand Down Expand Up @@ -960,7 +960,7 @@ public async Task<IActionResult> DeleteBan(int banId, [FromBody] DeleteBanReques
}

// Get current user email for audit trail
var userEmail = User.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value ?? "System";
var userEmail = User.FindFirst(System.Security.Claims.ClaimTypes.Name)?.Value ?? "System";
var now = DateTime.UtcNow;

// If this is a global ban, unban from all servers
Expand Down Expand Up @@ -1100,7 +1100,7 @@ public async Task<IActionResult> UpdateBan(int banId, [FromBody] UpdateBanReques
return Unauthorized();
}

var userEmail = User.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value ?? "System";
var userEmail = User.FindFirst(System.Security.Claims.ClaimTypes.Name)?.Value ?? "System";
var now = DateTime.UtcNow;

// Calculate new expiry
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,13 @@ public async Task<IActionResult> Create([FromBody] Moderator_CreateDTO dto)
var moderator = new ApplicationUser
{
UserName = dto.Username,
Email = dto.Email ?? $"{dto.Username}@moderator.local",
Email = string.IsNullOrWhiteSpace(dto.Email) ? null : dto.Email,
EmailConfirmed = !string.IsNullOrWhiteSpace(dto.Email),
DisplayName = dto.DisplayName,
SystemProfileId = profile.Id,
IsModerator = true,
isLoginBlocked = false,
HasChosenUsername = true,
CreatedAt = DateTime.UtcNow
};

Expand Down
35 changes: 6 additions & 29 deletions RustRconServerManager.Backend/Controllers/SecurityController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,38 +110,15 @@ public async Task<IActionResult> ChangeEmail([FromBody] Security_ChangeEmailDTO
return BadRequest(string.Join(", ", setEmailResult.Errors.Select(e => e.Description)));
}

await _userManager.SetUserNameAsync(user, dto.NewEmail);
// Username is a separate, independent identifier now (login is by username,
// not email) - it must NOT be overwritten here.
user.EmailConfirmed = true;
await _userManager.UpdateAsync(user);

// The current session's JWT still carries the OLD email claim, and
// SecurityBindingMiddleware re-resolves the user by that claim on every
// request (including the frontend's follow-up logout call and even loading
// the login page itself) - once the email no longer matches any user, that
// middleware would 401 all of those with a raw, unstyled error response
// instead of letting the app load. Revoke the session and clear the cookie
// here so the browser has no stale credential left to send.
var tokenHash = User.FindFirst(System.Security.Claims.ClaimTypes.Hash)?.Value;
if (!string.IsNullOrEmpty(tokenHash))
{
var userSession = await _dbContext.UserSessions
.FirstOrDefaultAsync(s => s.UserId == user.Id && s.SessionHash == tokenHash);
if (userSession != null)
{
userSession.IsRevoked = true;
await _dbContext.SaveChangesAsync();
}
}

Response.Cookies.Delete("rrsm_auth", new CookieOptions
{
HttpOnly = true,
Secure = Request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = "/"
});

return Ok(new { message = "Email changed successfully. Please log in again with your new email address." });
// Identity/session resolution is keyed on the user's Id (see GetUser(),
// SecurityBindingMiddleware), not email, so changing it doesn't invalidate the
// current session - no need to log the user out.
return Ok(new { message = "Email changed successfully." });
}
catch (Exception ex)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ public static async Task<SystemProfile> GetUserSystemProfile(this ClaimsPrincipa

public static async Task<ApplicationUser> GetUser(this ClaimsPrincipal user, AppDbContext context)
{
var userEmail = user.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value;
var userId = user.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value;
var sessionHash = user.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Hash)?.Value;

if (string.IsNullOrWhiteSpace(userEmail) || string.IsNullOrWhiteSpace(sessionHash))
if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(sessionHash))
throw new UserNotAuthenticatedException();

return await context.Users.FirstOrDefaultAsync(u => u.Email == userEmail)
return await context.Users.FirstOrDefaultAsync(u => u.Id == userId)
?? throw new UserNotAuthenticatedException();
}

Expand Down
Loading
Loading