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();
}
///
public virtual async Task Create(string key, TimeSpan expires, int length = 82, Dictionary 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;
}
///
public virtual async Task 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(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;
}
///
public virtual async Task 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(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;
}
///
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();
}
///
public virtual async Task Exists(string token)
{
if (token.IsNullOrWhiteSpace())
{
return false;
}
IAsyncDocumentSession session = Store.Session();
// try to find a valid token
SecurityToken securityToken = await session.LoadAsync(TokenToId(token));
return securityToken != null;
}
///
/// Converts the token to a database id
///
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;
}
///
/// 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)
///
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);
}
///
/// 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)
///
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
{
///
/// Generates a token for a with a specified lifespan.
///
/// The purpose the token will be used for. The key must match when verifying the token and should always be hidden from the user.
/// The token will automatically expire after this timespan.
/// Length of the geneated token.
/// Additional metadata to store with the token. This data can be retrieved on verification with
///
/// The generated token with the specified .
///
Task Create(string key, TimeSpan expires, int length = 82, Dictionary metadata = default);
///
/// Validates the passed for the specified .
///
/// The purpose the token was used for.
/// The previously generated token.
///
/// False, if the token could not be found or it has already expired.
///
Task Verify(string key, string token);
///
/// Validates the passed for the specified .
///
/// The purpose the token was used for.
/// The previously generated token.
///
/// Null, if the token could not be found or it has already expired.
///
Task VerifyAndReturn(string key, string token);
///
/// Generates a random token with the specified using the RNGCryptoServiceProvider.
///
///
/// This method won't store the token in the database and can't be used for verification. Use instead.
///
string Random(int length);
///
/// Determines whether a token exists in the database.
///
Task Exists(string token);
}