Files
mixtape/Finch.Raven/Tokens/FinchTokenProvider.cs
T
2026-04-07 14:23:29 +02:00

296 lines
9.0 KiB
C#

using Microsoft.AspNetCore.Cryptography.KeyDerivation;
using Raven.Client.Documents.Session;
using System.Security.Cryptography;
using System.Text;
namespace Finch.Raven;
public class FinchTokenProvider : IFinchTokenProvider
{
readonly char[] chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-".ToCharArray();
readonly RandomNumberGenerator randonNumberGenerator;
protected IFinchStore Store { get; private set; }
public FinchTokenProvider(IFinchStore store)
{
Store = store;
randonNumberGenerator = RandomNumberGenerator.Create();
}
/// <inheritdoc />
public virtual async Task<string> Create(string key, TimeSpan expires, int length = 82, Dictionary<string, string> metadata = default)
{
if (key.IsNullOrWhiteSpace())
{
throw new ArgumentException("Key cannot be empty");
}
if (length < 16)
{
throw new ArgumentOutOfRangeException("Use at least a length of 16 for the token");
}
string tokenKey = Random(length);
SecurityToken securityToken = new()
{
Id = TokenToId(tokenKey),
Token = tokenKey,
Key = HashKey(key),
Metadata = metadata ?? new()
};
IAsyncDocumentSession session = Store.Session();
// saves the token
await session.StoreAsync(securityToken);
// set the expires flag for the token
session.Expires(securityToken, expires);
await session.SaveChangesAsync();
return tokenKey;
}
/// <inheritdoc />
public virtual async Task<bool> Verify(string key, string token)
{
if (token.IsNullOrWhiteSpace() || key.IsNullOrWhiteSpace())
{
return false;
}
IAsyncDocumentSession session = Store.Session();
// try to find a valid token
SecurityToken securityToken = await session.LoadAsync<SecurityToken>(TokenToId(token));
bool isValid = securityToken != null && VerifyKey(securityToken.Key, key);
// remove token from DB if it is valid
if (isValid)
{
session.Delete(securityToken);
await session.SaveChangesAsync();
}
return isValid;
}
/// <inheritdoc />
public virtual async Task<SecurityToken> VerifyAndReturn(string key, string token)
{
if (token.IsNullOrWhiteSpace() || key.IsNullOrWhiteSpace())
{
return null;
}
IAsyncDocumentSession session = Store.Session();
// try to find a valid token
SecurityToken securityToken = await session.LoadAsync<SecurityToken>(TokenToId(token));
bool isValid = securityToken != null && VerifyKey(securityToken.Key, key);
// remove token from DB if it is valid
if (isValid)
{
session.Delete(securityToken);
await session.SaveChangesAsync();
return securityToken;
}
return null;
}
/// <inheritdoc />
public string Random(int length)
{
byte[] data = new byte[4 * length];
randonNumberGenerator.GetBytes(data);
StringBuilder result = new(length);
for (int i = 0; i < length; i++)
{
var rnd = BitConverter.ToUInt32(data, i * 4);
var idx = rnd % chars.Length;
result.Append(chars[idx]);
}
return result.ToString();
}
/// <inheritdoc />
public virtual async Task<bool> Exists(string token)
{
if (token.IsNullOrWhiteSpace())
{
return false;
}
IAsyncDocumentSession session = Store.Session();
// try to find a valid token
SecurityToken securityToken = await session.LoadAsync<SecurityToken>(TokenToId(token));
return securityToken != null;
}
/// <summary>
/// Converts the token to a database id
/// </summary>
string TokenToId(string token)
{
string collection = Store.Raven.Conventions.GetCollectionName(typeof(SecurityToken));
string idPrefix = Store.Raven.Conventions.TransformTypeCollectionNameToDocumentIdPrefix(collection);
return idPrefix + Store.Raven.Conventions.IdentityPartsSeparator + token;
}
/// <summary>
/// Creates a hash for the security token key.
/// Borrowed from the .NET Core PasswordHasher
/// (see: https://github.com/dotnet/aspnetcore/blob/release/5.0/src/Identity/Extensions.Core/src/PasswordHasher.cs)
/// </summary>
string HashKey(string key)
{
key = key.ToLowerInvariant();
KeyDerivationPrf prf = KeyDerivationPrf.HMACSHA256;
int iterationCount = 10000;
int saltSize = 128 / 8;
int numBytesRequested = 256 / 8;
byte[] salt = new byte[saltSize];
randonNumberGenerator.GetBytes(salt);
byte[] subkey = KeyDerivation.Pbkdf2(key, salt, prf, iterationCount, numBytesRequested);
var outputBytes = new byte[13 + salt.Length + subkey.Length];
outputBytes[0] = 0x01;
WriteNetworkByteOrder(outputBytes, 1, (uint)prf);
WriteNetworkByteOrder(outputBytes, 5, (uint)iterationCount);
WriteNetworkByteOrder(outputBytes, 9, (uint)saltSize);
Buffer.BlockCopy(salt, 0, outputBytes, 13, salt.Length);
Buffer.BlockCopy(subkey, 0, outputBytes, 13 + saltSize, subkey.Length);
return Convert.ToBase64String(outputBytes);
}
/// <summary>
/// Verifies the given key.
/// Borrowed from the .NET Core PasswordHasher
/// (see: https://github.com/dotnet/aspnetcore/blob/release/5.0/src/Identity/Extensions.Core/src/PasswordHasher.cs)
/// </summary>
bool VerifyKey(string storedKey, string key)
{
byte[] decodedStoredKey = Convert.FromBase64String(storedKey);
try
{
KeyDerivationPrf prf = (KeyDerivationPrf)ReadNetworkByteOrder(decodedStoredKey, 1);
int embeddedIterCount = (int)ReadNetworkByteOrder(decodedStoredKey, 5);
int saltLength = (int)ReadNetworkByteOrder(decodedStoredKey, 9);
// Read the salt: must be >= 128 bits
if (saltLength < 128 / 8)
{
return false;
}
byte[] salt = new byte[saltLength];
Buffer.BlockCopy(decodedStoredKey, 13, salt, 0, salt.Length);
// Read the subkey (the rest of the payload): must be >= 128 bits
int subkeyLength = decodedStoredKey.Length - 13 - salt.Length;
if (subkeyLength < 128 / 8)
{
return false;
}
byte[] expectedSubkey = new byte[subkeyLength];
Buffer.BlockCopy(decodedStoredKey, 13 + salt.Length, expectedSubkey, 0, expectedSubkey.Length);
// Hash the incoming key and verify it
byte[] actualSubkey = KeyDerivation.Pbkdf2(key, salt, prf, embeddedIterCount, subkeyLength);
return CryptographicOperations.FixedTimeEquals(actualSubkey, expectedSubkey);
}
catch
{
return false;
}
}
static void WriteNetworkByteOrder(byte[] buffer, int offset, uint value)
{
buffer[offset + 0] = (byte)(value >> 24);
buffer[offset + 1] = (byte)(value >> 16);
buffer[offset + 2] = (byte)(value >> 8);
buffer[offset + 3] = (byte)(value >> 0);
}
static uint ReadNetworkByteOrder(byte[] buffer, int offset)
{
return ((uint)(buffer[offset + 0]) << 24)
| ((uint)(buffer[offset + 1]) << 16)
| ((uint)(buffer[offset + 2]) << 8)
| ((uint)(buffer[offset + 3]));
}
}
public interface IFinchTokenProvider
{
/// <summary>
/// Generates a token for a <paramref name="key"/> with a specified <paramref name="expires"/> lifespan.
/// </summary>
/// <param name="key">The purpose the token will be used for. The key must match when verifying the token and should always be hidden from the user.</param>
/// <param name="expires">The token will automatically expire after this timespan.</param>
/// <param name="length">Length of the geneated token.</param>
/// <param name="metadata">Additional metadata to store with the token. This data can be retrieved on verification with <see cref="VerifyAndReturn(string, string)"/></param>
/// <returns>
/// The generated token with the specified <paramref name="length"/>.
/// </returns>
Task<string> Create(string key, TimeSpan expires, int length = 82, Dictionary<string, string> metadata = default);
/// <summary>
/// Validates the passed <paramref name="token"/> for the specified <paramref name="key"/>.
/// </summary>
/// <param name="key">The purpose the token was used for.</param>
/// <param name="token">The previously generated token.</param>
/// <returns>
/// False, if the token could not be found or it has already expired.
/// </returns>
Task<bool> Verify(string key, string token);
/// <summary>
/// Validates the passed <paramref name="token"/> for the specified <paramref name="key"/>.
/// </summary>
/// <param name="key">The purpose the token was used for.</param>
/// <param name="token">The previously generated token.</param>
/// <returns>
/// Null, if the token could not be found or it has already expired.
/// </returns>
Task<SecurityToken> VerifyAndReturn(string key, string token);
/// <summary>
/// Generates a random token with the specified <paramref name="length"/> using the RNGCryptoServiceProvider.
/// </summary>
/// <remarks>
/// This method won't store the token in the database and can't be used for verification. Use <see cref="Create(string, TimeSpan, int)"/> instead.
/// </remarks>
string Random(int length);
/// <summary>
/// Determines whether a token exists in the database.
/// </summary>
Task<bool> Exists(string token);
}