Files
mixtape/zero.Core/Tokens/ZeroTokenProvider.cs
T

279 lines
9.3 KiB
C#
Raw Normal View History

2020-12-21 00:20:28 +01:00
using Microsoft.AspNetCore.Cryptography.KeyDerivation;
using Raven.Client.Documents.Session;
using System;
2020-12-22 13:59:49 +01:00
using System.Collections.Generic;
2020-12-21 00:20:28 +01:00
using System.Linq;
using System.Security.Cryptography;
using System.Text;
2020-12-18 16:51:32 +01:00
using System.Threading.Tasks;
2020-12-21 00:20:28 +01:00
using zero.Core.Database;
using zero.Core.Extensions;
2020-12-18 16:51:32 +01:00
namespace zero.Core.Tokens
{
2020-12-21 00:20:28 +01:00
public class ZeroTokenProvider : IZeroTokenProvider
2020-12-18 16:51:32 +01:00
{
2020-12-21 00:20:28 +01:00
readonly char[] chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-".ToCharArray();
2020-12-18 16:51:32 +01:00
2020-12-21 00:20:28 +01:00
readonly RandomNumberGenerator randonNumberGenerator;
2020-12-18 16:51:32 +01:00
2020-12-21 00:20:28 +01:00
protected IZeroStore Store { get; private set; }
2020-12-18 16:51:32 +01:00
2020-12-21 00:20:28 +01:00
public ZeroTokenProvider(IZeroStore store)
2020-12-18 16:51:32 +01:00
{
2020-12-21 00:20:28 +01:00
Store = store;
randonNumberGenerator = RandomNumberGenerator.Create();
2020-12-18 16:51:32 +01:00
}
/// <inheritdoc />
2020-12-22 13:59:49 +01:00
public virtual async Task<string> Create(string key, TimeSpan expires, int length = 82, Dictionary<string, string> metadata = default)
2020-12-18 16:51:32 +01:00
{
2020-12-21 00:20:28 +01:00
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 SecurityToken()
{
Id = TokenToId(tokenKey),
Token = tokenKey,
2020-12-22 13:59:49 +01:00
Key = HashKey(key),
Metadata = metadata ?? new()
2020-12-21 00:20:28 +01:00
};
// saves the token
using IAsyncDocumentSession session = Store.OpenAsyncSession();
await session.StoreAsync(securityToken);
// set the expires flag for the token
2020-12-22 13:59:49 +01:00
IMetadataDictionary tokenMetadata = session.Advanced.GetMetadataFor(securityToken);
tokenMetadata[Constants.Database.Expires] = DateTime.UtcNow.AddSeconds(expires.TotalSeconds);
2020-12-21 00:20:28 +01:00
await session.SaveChangesAsync();
return tokenKey;
}
/// <inheritdoc />
public virtual async Task<bool> Verify(string key, string token)
{
if (token.IsNullOrWhiteSpace() || key.IsNullOrWhiteSpace())
2020-12-18 16:51:32 +01:00
{
return false;
}
2020-12-21 00:20:28 +01:00
// try to find a valid token
using IAsyncDocumentSession session = Store.OpenAsyncSession();
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;
}
2020-12-22 13:59:49 +01:00
/// <inheritdoc />
public virtual async Task<SecurityToken> VerifyAndReturn(string key, string token)
{
if (token.IsNullOrWhiteSpace() || key.IsNullOrWhiteSpace())
{
return null;
}
// try to find a valid token
using IAsyncDocumentSession session = Store.OpenAsyncSession();
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;
}
2020-12-21 00:20:28 +01:00
/// <inheritdoc />
public string Random(int length)
{
byte[] data = new byte[4 * length];
using RNGCryptoServiceProvider crypto = new();
crypto.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();
}
/// <summary>
/// Converts the token to a database id
/// </summary>
string TokenToId(string token)
{
string collection = Store.Conventions.GetCollectionName(typeof(SecurityToken));
string idPrefix = Store.Conventions.TransformTypeCollectionNameToDocumentIdPrefix(collection);
return idPrefix + Store.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 = 100;
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);
int embeddedIterCount = default(int);
try
{
KeyDerivationPrf prf = (KeyDerivationPrf)ReadNetworkByteOrder(decodedStoredKey, 1);
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 password and verify it
byte[] actualSubkey = KeyDerivation.Pbkdf2(key, salt, prf, embeddedIterCount, subkeyLength);
return true;
}
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]));
2020-12-18 16:51:32 +01:00
}
}
2020-12-21 00:20:28 +01:00
public interface IZeroTokenProvider
2020-12-18 16:51:32 +01:00
{
/// <summary>
2020-12-21 00:20:28 +01:00
/// Generates a token for a <paramref name="key"/> with a specified <paramref name="expires"/> lifespan.
2020-12-18 16:51:32 +01:00
/// </summary>
2020-12-21 00:20:28 +01:00
/// <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>
2020-12-22 13:59:49 +01:00
/// <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>
2020-12-18 16:51:32 +01:00
/// <returns>
2020-12-21 00:20:28 +01:00
/// The generated token with the specified <paramref name="length"/>.
2020-12-18 16:51:32 +01:00
/// </returns>
2020-12-22 13:59:49 +01:00
Task<string> Create(string key, TimeSpan expires, int length = 82, Dictionary<string, string> metadata = default);
2020-12-18 16:51:32 +01:00
/// <summary>
2020-12-21 00:20:28 +01:00
/// Validates the passed <paramref name="token"/> for the specified <paramref name="key"/>.
2020-12-18 16:51:32 +01:00
/// </summary>
2020-12-21 00:20:28 +01:00
/// <param name="key">The purpose the token was used for.</param>
/// <param name="token">The previously generated token.</param>
2020-12-18 16:51:32 +01:00
/// <returns>
2020-12-21 00:20:28 +01:00
/// False, if the token could not be found or it has already expired.
2020-12-18 16:51:32 +01:00
/// </returns>
2020-12-21 00:20:28 +01:00
Task<bool> Verify(string key, string token);
2020-12-22 13:59:49 +01:00
/// <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);
2020-12-21 00:20:28 +01:00
/// <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);
2020-12-18 16:51:32 +01:00
}
}