Skip to content

Commit

Permalink
Use Custom RFC6238 to generate pins
Browse files Browse the repository at this point in the history
  • Loading branch information
MikeAlhayek committed Apr 10, 2024
1 parent ffcda7e commit 198e586
Show file tree
Hide file tree
Showing 13 changed files with 288 additions and 39 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,8 @@ public class AuthenticatorAppStartup : StartupBase
public override void ConfigureServices(IServiceCollection services)
{
var authenticatorProviderType = typeof(AuthenticatorTokenProvider<>).MakeGenericType(typeof(IUser));
services.AddTransient(authenticatorProviderType);

services.Configure<IdentityOptions>(options =>
services.AddTransient(authenticatorProviderType)
.Configure<IdentityOptions>(options =>
{
options.Tokens.AuthenticatorTokenProvider = TokenOptions.DefaultAuthenticatorProvider;
options.Tokens.ProviderMap[TokenOptions.DefaultAuthenticatorProvider] = new TokenProviderDescriptor(authenticatorProviderType);
Expand All @@ -94,12 +93,11 @@ public class EmailAuthenticatorStartup : StartupBase
{
public override void ConfigureServices(IServiceCollection services)
{
var emailProviderType = typeof(EmailTokenProvider<>).MakeGenericType(typeof(IUser));
services.AddTransient(emailProviderType)
services.AddTransient<TwoFactorEmailTokenProvider>()
.Configure<TwoFactorOptions>(options => options.Providers.Add(TokenOptions.DefaultEmailProvider))
.Configure<IdentityOptions>(options =>
{
options.Tokens.ProviderMap[TokenOptions.DefaultEmailProvider] = new TokenProviderDescriptor(emailProviderType);
options.Tokens.ProviderMap[TokenOptions.DefaultEmailProvider] = new TokenProviderDescriptor(typeof(TwoFactorEmailTokenProvider));
});

services.AddScoped<IDisplayDriver<TwoFactorMethod>, TwoFactorMethodLoginEmailDisplayDriver>();
Expand All @@ -113,8 +111,8 @@ public class SmsAuthenticatorStartup : StartupBase
public override void ConfigureServices(IServiceCollection services)
{
var phoneNumberProviderType = typeof(PhoneNumberTokenProvider<>).MakeGenericType(typeof(IUser));
services.AddTransient(phoneNumberProviderType);
services.Configure<IdentityOptions>(options =>
services.AddTransient(phoneNumberProviderType)
.Configure<IdentityOptions>(options =>
{
options.Tokens.ChangePhoneNumberTokenProvider = TokenOptions.DefaultPhoneProvider;
options.Tokens.ProviderMap[TokenOptions.DefaultPhoneProvider] = new TokenProviderDescriptor(phoneNumberProviderType);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
using System;
using Microsoft.AspNetCore.Identity;
using OrchardCore.Users.Services;

namespace OrchardCore.Users.Models;

public sealed class ChangeEmailTokenProviderOptions : DataProtectionTokenProviderOptions
public sealed class ChangeEmailTokenProviderOptions : TotpEmailTokenProviderOptions
{
public ChangeEmailTokenProviderOptions()
{
Name = "ChangeEmailDataProtectionTokenProvider";
Name = nameof(ChangeEmailTokenProvider);
TokenLifespan = TimeSpan.FromMinutes(15);
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
using System;
using Microsoft.AspNetCore.Identity;
using OrchardCore.Users.Services;

namespace OrchardCore.Users.Models;

public sealed class EmailConfirmationTokenProviderOptions : DataProtectionTokenProviderOptions
public sealed class EmailConfirmationTokenProviderOptions : TotpEmailTokenProviderOptions
{
public EmailConfirmationTokenProviderOptions()
{
Name = "EmailConfirmationDataProtectorTokenProvider";
Name = nameof(EmailConfirmationTokenProvider);
TokenLifespan = TimeSpan.FromHours(48);
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
using System;
using Microsoft.AspNetCore.Identity;
using OrchardCore.Users.Services;

namespace OrchardCore.Users.Models;

public sealed class PasswordResetTokenProviderOptions : DataProtectionTokenProviderOptions
public sealed class PasswordResetTokenProviderOptions : TotpEmailTokenProviderOptions
{
public PasswordResetTokenProviderOptions()
{
Name = "PasswordResetDataProtectorTokenProvider";
Name = nameof(PasswordResetTokenProvider);
TokenLifespan = TimeSpan.FromMinutes(15);
}
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OrchardCore.Modules;
using OrchardCore.Users.Models;

namespace OrchardCore.Users.Services;

public sealed class ChangeEmailTokenProvider : DataProtectorTokenProvider<IUser>
public sealed class ChangeEmailTokenProvider : TotpEmailTokenProvider
{
public ChangeEmailTokenProvider(
IDataProtectionProvider dataProtectionProvider,
IOptions<ChangeEmailTokenProviderOptions> options,
ILogger<ChangeEmailTokenProvider> logger)
: base(dataProtectionProvider, options, logger)
IClock clock)
: base(options, clock)
{
}
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OrchardCore.Modules;
using OrchardCore.Users.Models;

namespace OrchardCore.Users.Services;

public sealed class EmailConfirmationTokenProvider : DataProtectorTokenProvider<IUser>
public sealed class EmailConfirmationTokenProvider : TotpEmailTokenProvider
{
public EmailConfirmationTokenProvider(
IDataProtectionProvider dataProtectionProvider,
IOptions<EmailConfirmationTokenProviderOptions> options,
ILogger<EmailConfirmationTokenProvider> logger)
: base(dataProtectionProvider, options, logger)
IClock clock)
: base(options, clock)
{
}
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OrchardCore.Modules;
using OrchardCore.Users.Models;

namespace OrchardCore.Users.Services;

public sealed class PasswordResetTokenProvider : DataProtectorTokenProvider<IUser>
public sealed class PasswordResetTokenProvider : TotpEmailTokenProvider
{
public PasswordResetTokenProvider(
IDataProtectionProvider dataProtectionProvider,
IOptions<PasswordResetTokenProviderOptions> options,
ILogger<PasswordResetTokenProvider> logger)
: base(dataProtectionProvider, options, logger)
IClock clock)
: base(options, clock)
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
using System;
using System.Diagnostics;
using System.Globalization;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using OrchardCore.Modules;

namespace OrchardCore.Users.Services;

/// <summary>
/// The following code is influenced by <see href="https://github.com/dotnet/aspnetcore/blob/main/src/Identity/Extensions.Core/src/Rfc6238AuthenticationService.cs"/>
/// </summary>
public sealed class Rfc6238AuthenticationService
{
private static readonly UTF8Encoding _encoding = new(false, true);

private readonly TimeSpan _timeSpan;
private readonly TwoFactorEmailTokenLength _length;
private readonly IClock _clock;

private int? _modulo;
private string _format;

public Rfc6238AuthenticationService(
TimeSpan timeSpan,
TwoFactorEmailTokenLength length,
IClock clock)
{
_timeSpan = timeSpan;
_length = length;
_clock = clock;
}

private int GetModuloValue()
{
// Number of 0's is length of the generated PIN.
_modulo ??= _length switch
{
TwoFactorEmailTokenLength.Six => 1000000,
TwoFactorEmailTokenLength.Seven => 10000000,
TwoFactorEmailTokenLength.Eight or TwoFactorEmailTokenLength.Default => 100000000,
_ => throw new NotSupportedException("Unsupported token length.")
};

return _modulo.Value;
}

private string GetStringFormat()
{
_format ??= _length switch
{
TwoFactorEmailTokenLength.Six => "D6",
TwoFactorEmailTokenLength.Seven => "D7",
TwoFactorEmailTokenLength.Eight or TwoFactorEmailTokenLength.Default => "D8",
_ => throw new NotSupportedException("Unsupported token length.")
};

return _format;
}

public string GetString(int code)
=> code.ToString(GetStringFormat(), CultureInfo.InvariantCulture);

public int ComputeTOTP(byte[] key, ulong timestepNumber, byte[] modifierBytes)
{
// See https://tools.ietf.org/html/rfc4226
// We can add an optional modifier.
Span<byte> timestepAsBytes = stackalloc byte[sizeof(long)];
var res = BitConverter.TryWriteBytes(timestepAsBytes, IPAddress.HostToNetworkOrder((long)timestepNumber));
Debug.Assert(res);

var modifierCombinedBytes = timestepAsBytes;
if (modifierBytes is not null)
{
modifierCombinedBytes = ApplyModifier(timestepAsBytes, modifierBytes);
}

Span<byte> hash = stackalloc byte[HMACSHA256.HashSizeInBytes];
res = HMACSHA256.TryHashData(key, modifierCombinedBytes, hash, out var written);

// Generate DT string.
var offset = hash[hash.Length - 1] & 0xf;
Debug.Assert(offset + 4 < hash.Length);
var binaryCode = (hash[offset] & 0x7f) << 24
| (hash[offset + 1] & 0xff) << 16
| (hash[offset + 2] & 0xff) << 8
| (hash[offset + 3] & 0xff);

return binaryCode % GetModuloValue();
}

private static byte[] ApplyModifier(Span<byte> input, byte[] modifierBytes)
{
var combined = new byte[checked(input.Length + modifierBytes.Length)];
input.CopyTo(combined);
Buffer.BlockCopy(modifierBytes, 0, combined, input.Length, modifierBytes.Length);

return combined;
}

/// <summary>
/// More info: https://tools.ietf.org/html/rfc6238#section-4
/// </summary>
private ulong GetCurrentTimeStepNumber()
{
var delta = _clock.UtcNow - DateTimeOffset.UnixEpoch;

return (ulong)(delta.Ticks / _timeSpan.Ticks);
}

public int GenerateCode(byte[] securityToken, string modifier = null)
{
ArgumentNullException.ThrowIfNull(securityToken);

var currentTimeStep = GetCurrentTimeStepNumber();

var modifierBytes = modifier is not null ? _encoding.GetBytes(modifier) : null;

return ComputeTOTP(securityToken, currentTimeStep, modifierBytes);
}

public bool ValidateCode(byte[] securityToken, int code, string modifier = null)
{
ArgumentNullException.ThrowIfNull(securityToken);

var currentTimeStep = GetCurrentTimeStepNumber();

var modifierBytes = modifier is not null ? _encoding.GetBytes(modifier) : null;

// Check the current, previous, and next time steps.
for (var i = -1; i <= 1; i++)
{
var computedTOTP = ComputeTOTP(securityToken, (ulong)((long)currentTimeStep + i), modifierBytes);

if (computedTOTP == code)
{
return true;
}
}

// No match.
return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using OrchardCore.Modules;

namespace OrchardCore.Users.Services;

public class TotpEmailTokenProvider : IUserTwoFactorTokenProvider<IUser>
{
private readonly TotpEmailTokenProviderOptions _options;
private readonly IClock _clock;

private Rfc6238AuthenticationService _service;

public TotpEmailTokenProvider(
IOptions<TotpEmailTokenProviderOptions> options,
IClock clock)
{
_options = options.Value;
_clock = clock;
}

public virtual Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<IUser> manager, IUser user)
=> Task.FromResult(false);

public async Task<string> GenerateAsync(string purpose, UserManager<IUser> manager, IUser user)
{
ArgumentNullException.ThrowIfNull(user);
var token = await manager.CreateSecurityTokenAsync(user);
var modifier = await GetUserModifierAsync(purpose, manager, user);

var pin = _service.GenerateCode(token, modifier);

_service ??= new Rfc6238AuthenticationService(_options.TokenLifespan, _options.TokenLength, _clock);

return _service.GetString(pin);
}

public async Task<bool> ValidateAsync(string purpose, string token, UserManager<IUser> manager, IUser user)
{
ArgumentNullException.ThrowIfNull(user);

if (!int.TryParse(token, out var code))
{
return false;
}

var securityToken = await manager.CreateSecurityTokenAsync(user);
var modifier = await GetUserModifierAsync(purpose, manager, user);

_service ??= new Rfc6238AuthenticationService(_options.TokenLifespan, _options.TokenLength, _clock);

return securityToken != null &&
_service.ValidateCode(securityToken, code, modifier);
}

private static async Task<string> GetUserModifierAsync(string purpose, UserManager<IUser> manager, IUser user)
{
ArgumentNullException.ThrowIfNull(user);
var userId = await manager.GetUserIdAsync(user);

return $"Totp:{purpose}:{userId}";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Identity;

namespace OrchardCore.Users.Services;

public class TotpEmailTokenProviderOptions : DataProtectionTokenProviderOptions
{
/// <summary>
/// Gets or sets the generated token's length. Default value is 8 digits long.
/// </summary>
public TwoFactorEmailTokenLength TokenLength { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace OrchardCore.Users.Services;

public enum TwoFactorEmailTokenLength
{
Default,
Six,
Seven,
Eight,
}
Loading

0 comments on commit 198e586

Please sign in to comment.