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
16 changes: 11 additions & 5 deletions Libraries/SPTarkov.Server.Web/Pages/UserCredentialsPage.razor
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,13 @@
private void RefreshCredentials()
{
_credentials.Clear();
_credentials.AddRange(AuthService.Credentials.OrderBy(credential => credential.Username));

// The panel only needs the username and role. Empty the password.
_credentials.AddRange(
AuthService.Credentials
.OrderBy(credential => credential.Username)
.Select(credential => credential with { Password = string.Empty })
);

if (_selectedUsername is not null && _credentials.All(credential => credential.Username != _selectedUsername))
{
Expand All @@ -282,7 +288,7 @@
private async Task CreateCredential()
{
var username = _newUsername.Trim();
var password = _newPassword.Trim();
var password = _newPassword;
var isAdministrator = _newIsAdministrator;

if (string.IsNullOrWhiteSpace(username))
Expand All @@ -306,7 +312,7 @@
}

_selectedUsername = username;
_editPassword = password;
_editPassword = string.Empty;
_newUsername = string.Empty;
_newPassword = string.Empty;
_newIsAdministrator = true;
Expand All @@ -322,7 +328,7 @@
}

var username = SelectedCredential.Username;
var password = _editPassword.Trim();
var password = _editPassword;

await RunCredentialAction(async () =>
{
Expand Down Expand Up @@ -410,7 +416,7 @@
}

_selectedUsername = args.Item.Username;
_editPassword = args.Item.Password;
_editPassword = string.Empty;
_deleteConfirmation = string.Empty;

return Task.CompletedTask;
Expand Down
5 changes: 3 additions & 2 deletions Libraries/SPTarkov.Server.Web/SPTWeb.cs
Original file line number Diff line number Diff line change
Expand Up @@ -163,14 +163,15 @@ private static async Task<IResult> HandleLogin(HttpContext context, AuthService
var username = form["username"].ToString();
var password = form["password"].ToString();

if (!authService.TryValidateCredentials(username, password, context, out var principal) || principal is null)
var authenticatedUser = await authService.ValidateCredentialsAsync(username, password, context);
if (authenticatedUser is null)
{
return Results.Redirect(AuthService.AddLoginError(failureUrl));
}

await context.SignInAsync(
AuthService.AuthenticationScheme,
principal,
authenticatedUser,
new AuthenticationProperties { IsPersistent = true, AllowRefresh = true }
);

Expand Down
1 change: 1 addition & 0 deletions Libraries/SPTarkov.Server.Web/SPTarkov.Server.Web.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<None Include="..\..\LICENSE" Pack="true" Visible="false" PackagePath="" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Argon2Sharp" Version="4.0.1" />
<PackageReference Include="MudBlazor" Version="9.4.0" />
</ItemGroup>
<ItemGroup>
Expand Down
82 changes: 82 additions & 0 deletions Libraries/SPTarkov.Server.Web/Services/Argon2idPasswordHasher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using Argon2Sharp;
using SPTarkov.DI.Annotations;

namespace SPTarkov.Server.Web.Services;

[Injectable(InjectionType.Singleton)]
public class Argon2idPasswordHasher : IPasswordHasher
{
// Cost parameters for newly created hashes. RFC 9106's memory-constrained "uniformly safe" recommendation is currently (2026-06-15)
// 64 MiB of memory, 3 iterations, 4 lanes, a 128-bit salt, and a 256-bit tag.
private const int MemorySizeKB = 65536;
private const int Iterations = 3;
private const int Parallelism = 4;
private const int HashLength = 32;
private const int SaltLength = 16;
private const Argon2Type Variant = Argon2Type.Argon2id;

// Immutable cost template reused across calls. The salt is a placeholder.
private readonly Argon2Parameters _parameters = Argon2Parameters
.CreateBuilder()
.WithType(Variant)
.WithMemorySizeKB(MemorySizeKB)
.WithIterations(Iterations)
.WithParallelism(Parallelism)
.WithHashLength(HashLength)
.WithRandomSalt(SaltLength)
.Build();

public Argon2idPasswordHasher()
{
DummyHash = Hash(Guid.NewGuid().ToString("N"));
}

public string DummyHash { get; }

public string Hash(string password)
{
return Argon2PhcFormat.HashToPhcStringWithAutoSalt(password, _parameters, SaltLength);
}

public bool IsEncodedHash(string value)
{
// Argon2 hashes are always strings that start with the variant marker, e.g. "$argon2id$".
return !string.IsNullOrEmpty(value) && value.StartsWith("$argon2", StringComparison.Ordinal);
}

public bool Verify(string password, string encodedHash, out bool needsRehash)
{
needsRehash = false;

if (string.IsNullOrEmpty(encodedHash) || string.IsNullOrEmpty(password))
{
return false;
}

bool isValid;
Argon2Parameters? stored;
try
{
(isValid, stored) = Argon2PhcFormat.VerifyPhcString(password, encodedHash);
}
catch
{
return false; // Always fail closed.
}

if (!isValid || stored is null)
{
return false;
}

// Flag the hash for upgrade on the next login when it was produced with cost parameters other than current.
needsRehash =
stored.MemorySizeKB != MemorySizeKB
|| stored.Iterations != Iterations
|| stored.Parallelism != Parallelism
|| stored.HashLength != HashLength
|| stored.Type != Variant;

return true;
}
}
92 changes: 65 additions & 27 deletions Libraries/SPTarkov.Server.Web/Services/AuthService.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
using System.Net;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using SPTarkov.Common.Models.Logging;
using SPTarkov.DI.Annotations;
using SPTarkov.Server.Core.DI;
Expand All @@ -11,7 +9,7 @@
namespace SPTarkov.Server.Web.Services;

[Injectable(InjectionType.Singleton)]
public class AuthService(ISptLogger<AuthService> logger, HttpConfig httpConfig, JsonUtil jsonUtil) : IOnLoad
public class AuthService(ISptLogger<AuthService> logger, HttpConfig httpConfig, JsonUtil jsonUtil, IPasswordHasher passwordHasher) : IOnLoad
{
internal const string AuthenticationScheme = "SptWebCookie";
internal const string AdministratorPolicy = "Administrator";
Expand Down Expand Up @@ -71,7 +69,7 @@ internal async Task<bool> TryCreateUser(string username, string password, bool i
new AuthUserCredential()
{
Username = username,
Password = password,
Password = passwordHasher.Hash(password),
IsAdministrator = isAdministrator,
}
);
Expand All @@ -87,7 +85,7 @@ internal async Task<bool> TryUpdateUserPassword(string username, string password
return false;
}

credential.Password = password;
credential.Password = passwordHasher.Hash(password);
await SaveCredentials();

return true;
Expand Down Expand Up @@ -122,46 +120,59 @@ internal async Task<bool> TryUpdateUserAdministrator(string username, bool isAdm
return true;
}

internal bool TryValidateCredentials(string username, string password, HttpContext httpContext, out ClaimsPrincipal? principal)
internal async Task<ClaimsPrincipal?> ValidateCredentialsAsync(string username, string password, HttpContext httpContext)
{
principal = null;
var authenticationConfig = httpConfig.WebAuthenticationConfig;

if (!authenticationConfig.Enabled)
{
principal = CreateDefaultPrincipal();
return true;
return CreateDefaultPrincipal();
}

var defaultUser = authenticationConfig.DefaultUser;
if (username == defaultUser.Username)
{
if (!authenticationConfig.EnableDefaultUser)
{
return false;
return null;
}

if (!authenticationConfig.AllowDefaultUserFromAnyIp && !IsLocalRequest(httpContext))
{
return false;
return null;
}
}

if (!TryGetCredentials(username, out var credentials) || credentials is null)
{
return false;
// Spend the same work on a missing user as a real verification.
_ = passwordHasher.Verify(password, passwordHasher.DummyHash, out _);

return null;
}

var usernameMatches = SecureEquals(username, credentials.Username);
var passwordMatches = SecureEquals(password, credentials.Password);
if (!passwordHasher.Verify(password, credentials.Password, out var needsRehash))
{
return null;
}

if (!(usernameMatches & passwordMatches))
// Upgrade hashes created with out-of-date parameters on the next successful login.
// ReSharper disable once InvertIf
if (needsRehash)
{
return false;
credentials.Password = passwordHasher.Hash(password);

try
{
await SaveCredentials();
}
catch (Exception exception)
{
logger.Warning($"Failed to persist rehashed web credential for '{credentials.Username}': {exception.Message}");
}
}

principal = CreatePrincipal(credentials.Username, credentials.IsAdministrator);
return true;
return CreatePrincipal(credentials.Username, credentials.IsAdministrator);
}

internal static string GetSafeReturnUrl(string? returnUrl)
Expand Down Expand Up @@ -229,14 +240,6 @@ private static bool IsLocalRequest(HttpContext httpContext)
return localIpAddress is not null && remoteIpAddress.Equals(localIpAddress);
}

private static bool SecureEquals(string suppliedValue, string expectedValue)
{
var suppliedBytes = Encoding.UTF8.GetBytes(suppliedValue);
var expectedBytes = Encoding.UTF8.GetBytes(expectedValue);

return CryptographicOperations.FixedTimeEquals(suppliedBytes, expectedBytes);
}

private async Task CreateOrLoadUserCredentials()
{
var fullPath = Path.GetFullPath(UserCredentialsPath);
Expand All @@ -253,13 +256,48 @@ private async Task CreateOrLoadUserCredentials()
await jsonUtil.DeserializeFromFileAsync<List<AuthUserCredential>>(credentialsPath)
?? throw new NullReferenceException("Could not deserialize credentials.json");

await MigratePlaintextCredentials();

return;
}

_credentials = new List<AuthUserCredential>([httpConfig.WebAuthenticationConfig.DefaultUser]);
// Seed from the config default user, hashing its plaintext password before it touches disk.
var defaultUser = httpConfig.WebAuthenticationConfig.DefaultUser;
_credentials =
[
new AuthUserCredential
{
Username = defaultUser.Username,
Password = passwordHasher.Hash(defaultUser.Password),
IsAdministrator = defaultUser.IsAdministrator,
},
];
await SaveCredentials();
}

private async Task MigratePlaintextCredentials()
{
// Unhashed, plaintext passwords are hashed in place. This allows server administrators to manually set account passwords and have
// them hashed on next server reload. Empty entries are left alone as they cannot be meaningfully hashed.
var migratedCount = 0;
foreach (var credential in _credentials)
{
if (string.IsNullOrEmpty(credential.Password) || passwordHasher.IsEncodedHash(credential.Password))
{
continue;
}

credential.Password = passwordHasher.Hash(credential.Password);
migratedCount++;
}

if (migratedCount > 0)
{
await SaveCredentials();
logger.Warning($"Migrated {migratedCount} plaintext web credentials to hashes.");
}
}

private async Task SaveCredentials(List<AuthUserCredential>? credentials = null)
{
var text = jsonUtil.Serialize(credentials ?? _credentials);
Expand Down
36 changes: 36 additions & 0 deletions Libraries/SPTarkov.Server.Web/Services/IPasswordHasher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
namespace SPTarkov.Server.Web.Services;

/// <summary>
/// Hashes and verifies web-panel account passwords.
/// </summary>
public interface IPasswordHasher
{
/// <summary>
/// Hashes a plaintext password into a hash suitable for storage.
/// </summary>
string Hash(string password);

/// <summary>
/// Verifies a plaintext password against a previously stored encoded hash. Returns false for a malformed or
/// corrupted hash.
/// </summary>
/// <param name="password">The plaintext password supplied at login.</param>
/// <param name="encodedHash">The stored encoded hash to verify against.</param>
/// <param name="needsRehash">
/// True when the password was correct but the stored hash was produced with cost parameters that differ from the
/// current ones, so the caller should re-hash and persist it. Always false on a failed verification.
/// </param>
bool Verify(string password, string encodedHash, out bool needsRehash);

/// <summary>
/// Returns true when the supplied value is already one of this hasher's encoded hashes, rather than a plaintext
/// secret. Used to detect pre-hashing credentials that still need migrating.
/// </summary>
bool IsEncodedHash(string value);

/// <summary>
/// A throwaway hash whose verification cost matches a real one. Verify a failed login against this so response
/// timing is the same whether the supplied username exists.
/// </summary>
string DummyHash { get; }
}
Loading
Loading