shoc-backend/SeaHaven.Services/Implementation/SessionStampService.cs

110 lines
4 KiB
C#
Raw Normal View History

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);
}
}