using System.Security.Cryptography;
using System.Text;
using Forge.Core.Models;
using Forge.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Forge.Web.Auth;
public partial class ConfiguredAuthService : IAuthService
{
private readonly AuthOptions _options;
private readonly ForgeDbContext _db;
public ConfiguredAuthService(IOptions<AuthOptions> options, ForgeDbContext db)
{
_options = options.Value;
_db = db;
}
public async Task<UserAccount?> ValidateCredentialsAsync(string? username, string? password)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
return null;
}
var normalizedUsername = NormalizeUsername(username);
if (normalizedUsername is null)
{
return null;
}
var user = await _db.UserAccounts.SingleOrDefaultAsync(u => u.Username == normalizedUsername);
return user is not null && PasswordHasher.Verify(password, user.PasswordHash)
? user
: null;
}
public async Task<UserAccount?> GetUserAsync(string username)
{
var normalizedUsername = NormalizeUsername(username);
return normalizedUsername is null
? null
: await _db.UserAccounts.SingleOrDefaultAsync(u => u.Username == normalizedUsername);
}
public async Task<IReadOnlyList<UserAccount>> GetUsersAsync() =>
await _db.UserAccounts
.OrderBy(u => u.Username)
.ToListAsync();
public async Task<UserAccount> CreateUserAsync(string username, string password, string role)
{
var normalizedUsername = NormalizeUsername(username)
?? throw new InvalidOperationException("Username is required.");
var normalizedRole = NormalizeRole(role);
if (!UsernamePattern().IsMatch(normalizedUsername))
{
throw new InvalidOperationException("Username may only contain letters, numbers, dots, dashes, and underscores.");
}
ValidatePassword(password);
if (await _db.UserAccounts.AnyAsync(u => u.Username == normalizedUsername))
{
throw new InvalidOperationException("A user with that username already exists.");
}
var user = new UserAccount
{
Id = Guid.NewGuid(),
Username = normalizedUsername,
PasswordHash = PasswordHasher.Hash(password),
Role = normalizedRole,
CreatedAt = DateTime.UtcNow
};
_db.UserAccounts.Add(user);
await _db.SaveChangesAsync();
return user;
}
public async Task UpdatePasswordAsync(string username, string newPassword)
{
var normalizedUsername = NormalizeUsername(username)
?? throw new InvalidOperationException("Username is required.");
ValidatePassword(newPassword);
var user = await _db.UserAccounts.SingleOrDefaultAsync(u => u.Username == normalizedUsername)
?? throw new InvalidOperationException("User not found.");
user.PasswordHash = PasswordHasher.Hash(newPassword);
await _db.SaveChangesAsync();
if (string.Equals(normalizedUsername, _options.Username, StringComparison.Ordinal)
&& !string.IsNullOrWhiteSpace(_options.PasswordFile))
{
File.WriteAllText(_options.PasswordFile, newPassword);
}
}
public async Task EnsureConfiguredAdminAsync()
{
var username = NormalizeUsername(_options.Username);
var configuredPassword = GetConfiguredPassword();
if (username is null || string.IsNullOrWhiteSpace(configuredPassword))
{
return;
}
var existing = await _db.UserAccounts.SingleOrDefaultAsync(u => u.Username == username);
var passwordHash = PasswordHasher.Hash(configuredPassword);
if (existing is null)
{
_db.UserAccounts.Add(new UserAccount
{
Id = Guid.NewGuid(),
Username = username,
PasswordHash = passwordHash,
Role = UserRoles.Admin,
CreatedAt = DateTime.UtcNow
});
}
else
{
existing.Role = UserRoles.Admin;
existing.PasswordHash = passwordHash;
}
await _db.SaveChangesAsync();
}
private static string? NormalizeUsername(string? username)
{
var normalized = username?.Trim();
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
}
private static string NormalizeRole(string? role)
{
var normalized = role?.Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
return UserRoles.User;
}
var matchedRole = UserRoles.All.FirstOrDefault(available =>
string.Equals(available, normalized, StringComparison.OrdinalIgnoreCase));
return matchedRole
?? throw new InvalidOperationException($"Unsupported role '{normalized}'.");
}
private static void ValidatePassword(string password)
{
if (password.Length < 6)
{
throw new InvalidOperationException("Password must be at least 6 characters.");
}
}
private string GetConfiguredPassword()
{
if (!string.IsNullOrWhiteSpace(_options.Password))
{
return _options.Password;
}
if (string.IsNullOrWhiteSpace(_options.PasswordFile))
{
return string.Empty;
}
try
{
return File.Exists(_options.PasswordFile)
? File.ReadAllText(_options.PasswordFile).Trim()
: string.Empty;
}
catch
{
return string.Empty;
}
}
[System.Text.RegularExpressions.GeneratedRegex("^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$")]
private static partial System.Text.RegularExpressions.Regex UsernamePattern();
}
internal static class PasswordHasher
{
private const int SaltSize = 16;
private const int KeySize = 32;
private const int Iterations = 100_000;
public static string Hash(string password)
{
var salt = RandomNumberGenerator.GetBytes(SaltSize);
var hash = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(password),
salt,
Iterations,
HashAlgorithmName.SHA256,
KeySize);
return $"pbkdf2-sha256${Iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}";
}
public static bool Verify(string password, string storedHash)
{
var parts = storedHash.Split('$', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 4 || !string.Equals(parts[0], "pbkdf2-sha256", StringComparison.Ordinal))
{
return false;
}
if (!int.TryParse(parts[1], out var iterations))
{
return false;
}
try
{
var salt = Convert.FromBase64String(parts[2]);
var expected = Convert.FromBase64String(parts[3]);
var actual = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(password),
salt,
iterations,
HashAlgorithmName.SHA256,
expected.Length);
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
catch
{
return false;
}
}
}