Files
postgresqlfpk/src/PostgresService.WebApi/Security.cs
T

161 lines
4.8 KiB
C#

using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
namespace PostgresService.WebApi;
public sealed class SecretStore
{
private const int Iterations = 210_000;
private readonly string _dataRoot;
private readonly object _sync = new();
public SecretStore(IConfiguration configuration)
{
_dataRoot = configuration["POSTGRES_SERVICE_DATA_ROOT"]
?? Environment.GetEnvironmentVariable("POSTGRES_SERVICE_DATA_ROOT")
?? Path.Combine(AppContext.BaseDirectory, "data");
Directory.CreateDirectory(_dataRoot);
}
public bool VerifyAdminPassword(string value) => Verify("admin-password", value);
public bool VerifyEnrollmentToken(string value) => Verify("enrollment-token", value);
public string RotateEnrollmentToken()
{
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
WriteHash("enrollment-token", token);
return token;
}
public void EnsureInitialized()
{
lock (_sync)
{
PromoteSeed("admin-password");
PromoteSeed("enrollment-token");
}
}
private void PromoteSeed(string name)
{
var hashPath = Path.Combine(_dataRoot, $"{name}.hash");
if (File.Exists(hashPath))
{
return;
}
var seedPath = Path.Combine(_dataRoot, $"{name}.seed");
if (!File.Exists(seedPath))
{
throw new InvalidOperationException($"缺少 {name} 初始化文件。");
}
var seed = File.ReadAllText(seedPath).TrimEnd('\r', '\n');
if (string.IsNullOrWhiteSpace(seed))
{
throw new InvalidOperationException($"{name} 不能为空。");
}
WriteHash(name, seed);
File.Delete(seedPath);
}
private bool Verify(string name, string value)
{
if (string.IsNullOrEmpty(value))
{
return false;
}
var path = Path.Combine(_dataRoot, $"{name}.hash");
if (!File.Exists(path))
{
return false;
}
var parts = File.ReadAllText(path).Trim().Split('$');
if (parts.Length != 4 || parts[0] != "pbkdf2-sha256" || !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(value), salt, iterations, HashAlgorithmName.SHA256, expected.Length);
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
catch (FormatException)
{
return false;
}
}
private void WriteHash(string name, string value)
{
var salt = RandomNumberGenerator.GetBytes(16);
var hash = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(value), salt, Iterations, HashAlgorithmName.SHA256, 32);
var content = $"pbkdf2-sha256${Iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}\n";
var destination = Path.Combine(_dataRoot, $"{name}.hash");
var temporary = destination + ".tmp";
File.WriteAllText(temporary, content, new UTF8Encoding(false));
File.Move(temporary, destination, true);
if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(destination, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
}
}
public sealed class AdminSessionStore
{
private static readonly TimeSpan Lifetime = TimeSpan.FromHours(12);
private readonly ConcurrentDictionary<string, DateTimeOffset> _sessions = new();
public string Create()
{
RemoveExpired();
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
_sessions[token] = DateTimeOffset.UtcNow.Add(Lifetime);
return token;
}
public bool Validate(string? token)
{
if (string.IsNullOrWhiteSpace(token) || !_sessions.TryGetValue(token, out var expiresAt))
{
return false;
}
if (expiresAt <= DateTimeOffset.UtcNow)
{
_sessions.TryRemove(token, out _);
return false;
}
_sessions[token] = DateTimeOffset.UtcNow.Add(Lifetime);
return true;
}
public void Revoke(string? token)
{
if (!string.IsNullOrWhiteSpace(token))
{
_sessions.TryRemove(token, out _);
}
}
private void RemoveExpired()
{
var now = DateTimeOffset.UtcNow;
foreach (var entry in _sessions.Where(item => item.Value <= now))
{
_sessions.TryRemove(entry.Key, out _);
}
}
}