Skip to content

Instantly share code, notes, and snippets.

@apples
Last active December 10, 2021 04:54
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save apples/41a650652eac932ed1e01781d599ec9a to your computer and use it in GitHub Desktop.
Save apples/41a650652eac932ed1e01781d599ec9a to your computer and use it in GitHub Desktop.
Copper Token - cryptographically secure tokens for ASP.NET Core WebAPI.
using System;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
namespace Copper
{
public static class CopperSession
{
public static async Task<CopperSession<TToken>> FromContext<TToken>(HttpContext context)
where TToken : CopperSessionToken, new()
{
var handler = await CopperTokenAuthenticationHandler<TToken>.FromContext(context);
return new CopperSession<TToken>(handler);
}
}
public class CopperSession<TToken> : IDisposable
where TToken : CopperSessionToken, new()
{
private readonly CopperTokenAuthenticationHandler<TToken> _handler;
private TToken? _unsavedToken;
private bool disposedValue;
public CopperSession(CopperTokenAuthenticationHandler<TToken> handler)
{
if (handler == null)
{
throw new ArgumentNullException(nameof(handler));
}
_handler = handler;
}
public void SetToken(TToken claims)
{
_unsavedToken = claims;
}
public TToken? GetToken()
{
if (_unsavedToken != null)
{
return _unsavedToken;
}
return _handler.Token;
}
public void Destroy()
{
_unsavedToken = null;
_handler.DestroyToken();
}
private void Save()
{
if (_unsavedToken != null)
{
_handler.WriteToken(_unsavedToken);
_unsavedToken = null;
}
}
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
Save();
}
disposedValue = true;
}
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}
using System;
using System.Collections.Generic;
namespace Copper
{
public class CopperSessionToken
{
public string? Name { get; set; }
public List<string> Roles { get; set; } = new List<string>();
public DateTimeOffset CreatedOn { get; set; }
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Copper
{
public class CopperTokenAuthenticationHandler<TToken> : AuthenticationHandler<CopperTokenAuthenticationOptions>
where TToken : CopperSessionToken
{
public static readonly string AuthenticationScheme = "CopperTokenAuthentication";
private readonly NullabilityInfoContext _nullabilityContext = new NullabilityInfoContext();
private readonly ICopperTokenAuthenticationService<TToken> _service;
private TToken? _token;
public readonly JsonSerializerOptions jsonOptions = new JsonSerializerOptions
{
IgnoreReadOnlyProperties = true,
};
public TToken? Token => _token;
public static async Task<CopperTokenAuthenticationHandler<TToken>> FromContext(HttpContext context)
{
var provider = context.RequestServices.GetService<IAuthenticationHandlerProvider>();
if (provider == null)
{
throw new InvalidOperationException("IAuthenticationHandlerProvider not found.");
}
var handler = await provider.GetHandlerAsync(context, AuthenticationScheme) as CopperTokenAuthenticationHandler<TToken>;
if (handler == null)
{
throw new InvalidOperationException("CopperTokenAuthenticationHandler not found.");
}
return handler;
}
public CopperTokenAuthenticationHandler(
IOptionsMonitor<CopperTokenAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock,
ICopperTokenAuthenticationService<TToken> service)
: base(options, logger, encoder, clock)
{
if (service == null)
{
throw new ArgumentNullException("service");
}
_service = service;
}
public string Secret => Options.Secret ?? throw new InvalidOperationException("Missing Options.Secret");
public string CookieName => "CuTok_" + Options.CookieSuffix;
public void WriteToken(TToken tokenData)
{
tokenData.CreatedOn = DateTimeOffset.UtcNow;
var json = JsonSerializer.Serialize(tokenData, jsonOptions);
var seal = new CopperSeal(Secret);
var token = seal.Seal(json);
Response.Cookies.Append(CookieName, token, new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Lax,
Secure = true,
});
_token = tokenData;
}
public void DestroyToken()
{
Response.Cookies.Delete(CookieName);
_token = null;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
try
{
var result = await HandleAuthenticateAsyncImpl();
if (!result.Succeeded)
{
DestroyToken();
}
return result;
}
catch (Exception e)
{
DestroyToken();
Logger.LogError(e, "Token authentication failed with exception.");
return AuthenticateResult.Fail("Token authentication failed with exception.");
}
}
private async Task<AuthenticateResult> HandleAuthenticateAsyncImpl()
{
_token = null;
// Get token string from cookie
var tokenCookie = Request.Cookies[CookieName];
if (tokenCookie == null)
{
return AuthenticateResult.Fail("Missing token cookie");
}
var seal = new CopperSeal(Secret);
var tokenData = seal.Unseal(tokenCookie);
if (tokenData == null)
{
return AuthenticateResult.Fail("Cookie does not contain a valid token");
}
Logger.LogInformation($"Token JSON: {tokenData}");
// Deserialize
var token = JsonSerializer.Deserialize<TToken>(tokenData, jsonOptions);
if (token == null)
{
return AuthenticateResult.Fail("Failed to deserialize token.");
}
// Check expiration
var tokenAgeSecs = (DateTimeOffset.UtcNow - token.CreatedOn).TotalSeconds;
if (tokenAgeSecs < 0 || tokenAgeSecs >= Options.InvalidAfterSecs)
{
return AuthenticateResult.Fail("Token is too old.");
}
if (tokenAgeSecs >= Options.RevalidateAfterSecs)
{
// Revalidate
try
{
var temp = token;
token = null;
token = await _service.Revalidate(temp);
}
catch (Exception e)
{
Logger.LogError(e, "Token revalidation failed with exception.");
}
if (token == null)
{
return AuthenticateResult.Fail("Token revalidation failed.");
}
WriteToken(token);
}
// Token successfully validated
_token = token;
// Populate claims
var claims = new List<Claim>();
foreach (var prop in typeof(TToken).GetProperties())
{
switch (prop.Name)
{
case nameof(CopperSessionToken.Name):
{
if (token.Name == null)
{
return AuthenticateResult.Fail("Name claim cannot be null.");
}
claims.Add(new Claim(ClaimTypes.Name, token.Name));
break;
}
case nameof(CopperSessionToken.Roles):
if (token.Roles == null)
{
token.Roles = new List<string>();
}
claims.AddRange(token.Roles.Select(role => new Claim(ClaimTypes.Role, role)));
break;
default:
{
var value = prop.GetValue(token);
var nullabilityInfo = _nullabilityContext.Create(prop);
if (nullabilityInfo.WriteState == NullabilityState.NotNull && value == null)
{
return AuthenticateResult.Fail("Claim property " + prop.Name + " cannot be set to null.");
}
if (value == null)
{
break;
}
claims.Add(value switch
{
string s => new Claim(prop.Name, s),
int x => new Claim(prop.Name, x.ToString(), ClaimValueTypes.Integer),
bool b => new Claim(prop.Name, b.ToString(), ClaimValueTypes.Boolean),
DateTime d => new Claim(prop.Name, d.ToString("o"), ClaimValueTypes.DateTime),
DateTimeOffset d => new Claim(prop.Name, d.ToString("o"), ClaimValueTypes.DateTime),
_ => new Claim(prop.Name, JsonSerializer.Serialize(value, value.GetType(), jsonOptions), "json"),
});
break;
}
}
}
var identity = new ClaimsIdentity(claims, nameof(CopperTokenAuthenticationHandler<TToken>));
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), this.Scheme.Name);
return AuthenticateResult.Success(ticket);
}
}
}
using System;
using Microsoft.AspNetCore.Authentication;
namespace Copper
{
public class CopperTokenAuthenticationOptions : AuthenticationSchemeOptions
{
public string? Secret { get; set; }
public string? CookieSuffix { get; set; }
public int RevalidateAfterSecs { get; set; } = 5 * 60;
public int InvalidAfterSecs { get; set; } = 7 * 24 * 60 * 60;
public override void Validate()
{
base.Validate();
if (string.IsNullOrEmpty(Secret))
{
throw new InvalidOperationException($"CopperTokenAuthentication: {nameof(Secret)} not provided.");
}
if (Secret.Length < CopperSeal.MinimumSecretLength)
{
throw new InvalidOperationException($"CopperTokenAuthentication: {nameof(Secret)} must be at least {CopperSeal.MinimumSecretLength} chars.");
}
if (string.IsNullOrEmpty(CookieSuffix))
{
throw new InvalidOperationException($"CopperTokenAuthentication: {nameof(CookieSuffix)} not provided.");
}
}
}
}
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Cryptography.KeyDerivation;
namespace Copper
{
public static class Crypto
{
public static byte[] GenerateSaltRaw(int bits)
{
var bytes = new byte[bits / 8];
using var rng = RandomNumberGenerator.Create();
rng.GetNonZeroBytes(bytes);
return bytes;
}
public static string GenerateSalt(int bits = 128)
{
return Convert.ToBase64String(GenerateSaltRaw(bits));
}
public static byte[] CreateKey(int bits, string password, byte[] salt)
{
return KeyDerivation.Pbkdf2(
password: password,
salt: salt,
prf: KeyDerivationPrf.HMACSHA256,
iterationCount: 310000,
numBytesRequested: bits / 8);
}
public static string HashPassword(string password, string saltBase64)
{
var saltBytes = Convert.FromBase64String(saltBase64);
return Convert.ToBase64String(CreateKey(256, password, saltBytes));
}
public static EncryptedValue EncryptRaw(string plaintext, string secretKey, int saltBits, int keyBits)
{
using var aes = Aes.Create();
var keySalt = GenerateSaltRaw(saltBits);
aes.Key = CreateKey(keyBits, secretKey, keySalt);
using var encryptor = aes.CreateEncryptor();
using var memoryStream = new MemoryStream();
{
using var cryptoStream = new CryptoStream(memoryStream, encryptor, CryptoStreamMode.Write);
using var writer = new StreamWriter(cryptoStream);
writer.Write(plaintext);
}
var encryptedBytes = memoryStream.ToArray();
return new EncryptedValue(
encryptedBytes: encryptedBytes,
salt: keySalt,
iv: aes.IV);
}
public static string DecryptRaw(EncryptedValue encrypted, string secretKey, int keyBits)
{
using var aes = Aes.Create();
aes.Key = CreateKey(keyBits, secretKey, encrypted.Salt);
aes.IV = encrypted.IV;
using var decryptor = aes.CreateDecryptor();
using var memoryStream = new MemoryStream(encrypted.EncryptedBytes);
using var cryptoStream = new CryptoStream(memoryStream, decryptor, CryptoStreamMode.Read);
using var reader = new StreamReader(cryptoStream);
return reader.ReadToEnd();
}
}
public class EncryptedValue
{
public byte[] EncryptedBytes { get; set; }
public byte[] Salt { get; set; }
public byte[] IV { get; set; }
public EncryptedValue(byte[] encryptedBytes, byte[] salt, byte[] iv)
{
EncryptedBytes = encryptedBytes;
Salt = salt;
IV = iv;
}
}
}
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Copper
{
public interface ICopperTokenAuthenticationService<TToken>
where TToken : CopperSessionToken
{
Task<TToken> Revalidate(TToken temp);
}
}
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Cryptography.KeyDerivation;
namespace Copper
{
public class CopperSeal
{
public static int MinimumSecretLength { get; } = 32;
public string SecretKey { get; set; }
public int EncryptionSaltBits { get; set; }
public int EncryptionKeyBits { get; set; }
public int IntegritySaltBits { get; set; }
public int IntegrityKeyBits { get; set; }
public CopperSeal(string secretKey, int encryptionSaltBits = 256, int encryptionKeyBits = 256, int integritySaltBits = 256, int integrityKeyBits = 256)
{
if (secretKey.Length < MinimumSecretLength)
{
throw new InvalidOperationException($"{nameof(secretKey)} needs to be at least {MinimumSecretLength} characters.");
}
SecretKey = secretKey;
EncryptionSaltBits = encryptionSaltBits;
EncryptionKeyBits = encryptionKeyBits;
IntegritySaltBits = integritySaltBits;
IntegrityKeyBits = integrityKeyBits;
}
public string Seal(string data)
{
var encoded = Encrypt(data);
var token = Sign(encoded);
return token;
}
public string? Unseal(string token)
{
Console.WriteLine("Token: " + token);
var encoded = Verify(token);
Console.WriteLine("Encoded: " + encoded);
var data = encoded != null ? Decrypt(encoded) : null;
Console.WriteLine("Data: " + data);
return data;
}
private string Encrypt(string plaintext)
{
var encrypted = Crypto.EncryptRaw(plaintext, SecretKey, saltBits: EncryptionSaltBits, keyBits: EncryptionKeyBits);
return $"{B64UrlEncode(encrypted.EncryptedBytes)}.{B64UrlEncode(encrypted.Salt)}.{B64UrlEncode(encrypted.IV)}";
}
private string? Decrypt(string encoded)
{
var parts = encoded.Split('.');
Console.WriteLine("Parts: " + parts.Length);
if (parts.Length != 3)
{
return null;
}
Console.WriteLine(" 0: " + parts[0]);
Console.WriteLine(" 1: " + parts[1]);
Console.WriteLine(" 2: " + parts[2]);
var encryptedBytes = B64UrlDecode(parts[0]);
var salt = B64UrlDecode(parts[1]);
var iv = B64UrlDecode(parts[2]);
Console.WriteLine(nameof(encryptedBytes) + ": " + encryptedBytes);
Console.WriteLine(nameof(salt) + ": " + salt);
Console.WriteLine(nameof(iv) + ": " + iv);
if (encryptedBytes == null || salt == null || iv == null)
{
return null;
}
var encrypted = new EncryptedValue(
encryptedBytes: encryptedBytes,
salt: salt,
iv: iv);
return Crypto.DecryptRaw(encrypted, SecretKey, keyBits: EncryptionKeyBits);
}
private string Sign(string encoded)
{
var salt = Crypto.GenerateSaltRaw(IntegritySaltBits);
var key = Crypto.CreateKey(IntegrityKeyBits, SecretKey, salt);
using var hmac = new HMACSHA256(key);
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(encoded));
return $"{B64UrlEncode(hash)}.{B64UrlEncode(salt)}.{encoded}";
}
private string? Verify(string token)
{
var parts = token.Split('.', 3);
if (parts.Length != 3)
{
return null;
}
var tokenHash = B64UrlDecode(parts[0]);
var tokenHashSalt = B64UrlDecode(parts[1]);
var encoded = parts[2];
if (tokenHash == null || tokenHashSalt == null)
{
return null;
}
var key = Crypto.CreateKey(IntegrityKeyBits, SecretKey, tokenHashSalt);
using var hmac = new HMACSHA256(key);
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(encoded));
if (new Span<byte>(hash).SequenceEqual(new Span<byte>(tokenHash)))
{
return encoded;
}
else
{
return null;
}
}
private static string B64UrlEncode(byte[] bytes)
{
return Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('=');
}
private static byte[]? B64UrlDecode(string str)
{
var len = str.Length;
if (len % 4 != 0)
len += 4 - len % 4;
try
{
return Convert.FromBase64String(str.Replace('-', '+').Replace('_', '/').PadRight(len, '='));
}
catch (FormatException)
{
return null;
}
}
}
public struct Result<T>
{
public T? result;
public string? error;
}
}
using System;
using Microsoft.AspNetCore.Authentication;
namespace Copper
{
public static class ServiceExtensions
{
public static AuthenticationBuilder AddCopperToken<TToken>(this AuthenticationBuilder builder, Action<CopperTokenAuthenticationOptions> configureOptions)
where TToken: CopperSessionToken
{
return builder.AddScheme<CopperTokenAuthenticationOptions, CopperTokenAuthenticationHandler<TToken>>(
CopperTokenAuthenticationHandler<TToken>.AuthenticationScheme,
configureOptions
);
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment