Last active
December 10, 2021 04:54
-
-
Save apples/41a650652eac932ed1e01781d599ec9a to your computer and use it in GitHub Desktop.
Copper Token - cryptographically secure tokens for ASP.NET Core WebAPI.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; } | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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."); | |
} | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Collections.Generic; | |
using System.Threading.Tasks; | |
namespace Copper | |
{ | |
public interface ICopperTokenAuthenticationService<TToken> | |
where TToken : CopperSessionToken | |
{ | |
Task<TToken> Revalidate(TToken temp); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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