mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 22:23:12 +00:00
Tokens now carry a keyed hash of the account's security stamp, and every authenticated request compares it with the stored stamp (cached for 60 s, evicted in-process on change). A password reset or change, a deactivation and a deletion all rotate or remove the stamp, so tokens issued before them get 401. Tokens without the claim get 401 too.
109 lines
4 KiB
C#
109 lines
4 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using Microsoft.Extensions.Options;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using SeaHaven.DataServices.Interfaces;
|
|
using SeaHaven.Services.Configuration;
|
|
using SeaHaven.Services.Interfaces;
|
|
|
|
namespace SeaHaven.Services.Implementation
|
|
{
|
|
public class SessionStampService : ISessionStampService
|
|
{
|
|
private readonly IUserDataService _userDataService;
|
|
private readonly ISessionStampCache _cache;
|
|
private readonly byte[] _key;
|
|
|
|
public SessionStampService(
|
|
IUserDataService userDataService,
|
|
ISessionStampCache cache,
|
|
IOptions<JwtOptions> jwtOptions)
|
|
{
|
|
_userDataService = userDataService;
|
|
_cache = cache;
|
|
_key = HKDF.DeriveKey(
|
|
HashAlgorithmName.SHA256,
|
|
Encoding.UTF8.GetBytes(jwtOptions.Value.Secret),
|
|
32,
|
|
info: Encoding.UTF8.GetBytes("session-stamp-v1"));
|
|
}
|
|
|
|
public string ClaimValueFor(string securityStamp)
|
|
{
|
|
ArgumentException.ThrowIfNullOrEmpty(securityStamp);
|
|
return Base64UrlEncoder.Encode(HMACSHA256.HashData(_key, Encoding.UTF8.GetBytes(securityStamp)));
|
|
}
|
|
|
|
public async Task<bool> IsCurrentAsync(string userId, string? claimValue, CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(claimValue))
|
|
return false;
|
|
|
|
if (!_cache.TryGet(userId, out var expected))
|
|
{
|
|
var generation = _cache.Generation;
|
|
var stamp = await _userDataService.GetActiveSecurityStampAsync(userId, cancellationToken);
|
|
// A missing, deleted or stampless account has no current value: nothing matches it.
|
|
expected = string.IsNullOrEmpty(stamp) ? null : ClaimValueFor(stamp);
|
|
_cache.Set(userId, expected, generation);
|
|
}
|
|
|
|
return expected != null && CryptographicOperations.FixedTimeEquals(
|
|
Encoding.UTF8.GetBytes(expected),
|
|
Encoding.UTF8.GetBytes(claimValue));
|
|
}
|
|
|
|
public void Forget(string userId) => _cache.Remove(userId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Holds each expected value for <see cref="Lifetime"/>, so a change saved by another
|
|
/// instance is enforced here within that time; a change saved by this instance is
|
|
/// enforced at once through <see cref="Remove"/>.
|
|
/// </summary>
|
|
public sealed class InMemorySessionStampCache : ISessionStampCache
|
|
{
|
|
public static readonly TimeSpan Lifetime = TimeSpan.FromSeconds(60);
|
|
|
|
private readonly ConcurrentDictionary<string, Entry> _entries = new(StringComparer.Ordinal);
|
|
private readonly TimeProvider _timeProvider;
|
|
private long _generation;
|
|
|
|
public InMemorySessionStampCache(TimeProvider timeProvider)
|
|
{
|
|
_timeProvider = timeProvider;
|
|
}
|
|
|
|
public long Generation => Interlocked.Read(ref _generation);
|
|
|
|
public bool TryGet(string userId, out string? expected)
|
|
{
|
|
if (_entries.TryGetValue(userId, out var entry) && entry.ExpiresAt > _timeProvider.GetUtcNow())
|
|
{
|
|
expected = entry.Expected;
|
|
return true;
|
|
}
|
|
|
|
expected = null;
|
|
return false;
|
|
}
|
|
|
|
public void Set(string userId, string? expected, long generation)
|
|
{
|
|
var entry = new Entry(expected, _timeProvider.GetUtcNow().Add(Lifetime));
|
|
_entries[userId] = entry;
|
|
// A removal since the read may have raced it: drop the value rather than keep it.
|
|
if (Generation != generation)
|
|
_entries.TryRemove(new KeyValuePair<string, Entry>(userId, entry));
|
|
}
|
|
|
|
public void Remove(string userId)
|
|
{
|
|
Interlocked.Increment(ref _generation);
|
|
_entries.TryRemove(userId, out _);
|
|
}
|
|
|
|
private sealed record Entry(string? Expected, DateTimeOffset ExpiresAt);
|
|
}
|
|
}
|