diff --git a/BootstrapBlazor.Extensions.sln b/BootstrapBlazor.Extensions.sln index 4948adcd..1296917b 100644 --- a/BootstrapBlazor.Extensions.sln +++ b/BootstrapBlazor.Extensions.sln @@ -182,6 +182,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BootstrapBlazor.UniverIcon" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BootstrapBlazor.ChatBot", "src\components\BootstrapBlazor.ChatBot\BootstrapBlazor.ChatBot.csproj", "{2F37FBF4-5C1C-4493-B614-0E8361432621}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BootstrapBlazor.Authenticator", "src\components\BootstrapBlazor.Authenticator\BootstrapBlazor.Authenticator.csproj", "{1FDDF0AD-7AB6-4706-A183-26C680817BB4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -492,6 +494,10 @@ Global {2F37FBF4-5C1C-4493-B614-0E8361432621}.Debug|Any CPU.Build.0 = Debug|Any CPU {2F37FBF4-5C1C-4493-B614-0E8361432621}.Release|Any CPU.ActiveCfg = Release|Any CPU {2F37FBF4-5C1C-4493-B614-0E8361432621}.Release|Any CPU.Build.0 = Release|Any CPU + {1FDDF0AD-7AB6-4706-A183-26C680817BB4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1FDDF0AD-7AB6-4706-A183-26C680817BB4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1FDDF0AD-7AB6-4706-A183-26C680817BB4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1FDDF0AD-7AB6-4706-A183-26C680817BB4}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -577,6 +583,7 @@ Global {E30AAB64-BF28-4960-89C1-1F521025F531} = {FF1089BE-C704-4374-B629-C57C08E1798F} {A657E04C-1495-439E-BC2E-1EEAB2D1B4DA} = {FF1089BE-C704-4374-B629-C57C08E1798F} {2F37FBF4-5C1C-4493-B614-0E8361432621} = {FF1089BE-C704-4374-B629-C57C08E1798F} + {1FDDF0AD-7AB6-4706-A183-26C680817BB4} = {FF1089BE-C704-4374-B629-C57C08E1798F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D5EB1960-6F30-4CE1-B375-EAE1F787D6FF} diff --git a/exclusion.dic b/exclusion.dic index d5ea49ef..37ac74b7 100644 --- a/exclusion.dic +++ b/exclusion.dic @@ -101,3 +101,4 @@ vimeo scrlang Validata Validatable +Totp diff --git a/src/components/BootstrapBlazor.Authenticator/BootstrapBlazor.Authenticator.csproj b/src/components/BootstrapBlazor.Authenticator/BootstrapBlazor.Authenticator.csproj new file mode 100644 index 00000000..92338cfe --- /dev/null +++ b/src/components/BootstrapBlazor.Authenticator/BootstrapBlazor.Authenticator.csproj @@ -0,0 +1,18 @@ + + + + 9.0.0 + + + + Bootstrap Blazor WebAssembly wasm Authenticator 2FA MFA OTP TOTP HOTP + Bootstrap UI components extensions of Authenticator + BootstrapBlazor.Components + + + + + + + + diff --git a/src/components/BootstrapBlazor.Authenticator/Extensions/OTPExtensions.cs b/src/components/BootstrapBlazor.Authenticator/Extensions/OTPExtensions.cs new file mode 100644 index 00000000..3b5104b7 --- /dev/null +++ b/src/components/BootstrapBlazor.Authenticator/Extensions/OTPExtensions.cs @@ -0,0 +1,21 @@ +// Copyright (c) Argo Zhang (argo@163.com). All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// Website: https://www.blazor.zone or https://argozhang.github.io/ + +namespace BootstrapBlazor.Components; + +internal static class OtpExtensions +{ + public static OtpNet.OtpHashMode ToMode(this OtpHashMode mode) => mode switch + { + OtpHashMode.Sha256 => OtpNet.OtpHashMode.Sha256, + OtpHashMode.Sha512 => OtpNet.OtpHashMode.Sha512, + _ => OtpNet.OtpHashMode.Sha1 + }; + + public static OtpNet.OtpType ToType(this OtpType type) => type switch + { + OtpType.Hotp => OtpNet.OtpType.Hotp, + _ => OtpNet.OtpType.Totp + }; +} diff --git a/src/components/BootstrapBlazor.Authenticator/Extensions/ServiceCollectionExtension.cs b/src/components/BootstrapBlazor.Authenticator/Extensions/ServiceCollectionExtension.cs new file mode 100644 index 00000000..6c53a9c4 --- /dev/null +++ b/src/components/BootstrapBlazor.Authenticator/Extensions/ServiceCollectionExtension.cs @@ -0,0 +1,35 @@ +// Copyright (c) Argo Zhang (argo@163.com). All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// Website: https://www.blazor.zone or https://argozhang.github.io/ + +using Microsoft.Extensions.DependencyInjection; + +namespace BootstrapBlazor.Components; + +/// +/// BootstrapBlazor service extensions +/// +public static class ServiceCollectionExtension +{ + /// + /// Inject service extension method. + /// + /// + /// + /// + public static IServiceCollection AddBootstrapBlazorTotpService(this IServiceCollection services, Action< + OtpOptions>? configOptions = null) + { + services.AddSingleton(); + services.Configure(options => + { + configOptions?.Invoke(options); + + options.AccountName ??= "BootstrapBlazor"; + options.UserName ??= "Simulator"; + options.IssuerName ??= options.AccountName; + options.SecretKey ??= "OMM2LVLFX6QJHMYI"; + }); + return services; + } +} diff --git a/src/components/BootstrapBlazor.Authenticator/Services/DefaultTotpService.cs b/src/components/BootstrapBlazor.Authenticator/Services/DefaultTotpService.cs new file mode 100644 index 00000000..9a54e19c --- /dev/null +++ b/src/components/BootstrapBlazor.Authenticator/Services/DefaultTotpService.cs @@ -0,0 +1,74 @@ +// Copyright (c) Argo Zhang (argo@163.com). All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// Website: https://www.blazor.zone or https://argozhang.github.io/ + +using Microsoft.Extensions.Options; +using OtpNet; + +namespace BootstrapBlazor.Components; + +class DefaultTotpService(IOptionsMonitor optionsMonitor) : ITotpService +{ + [NotNull] + public TotpInstanceBase? Instance { get; private set; } + + public string GenerateOtpUri(OtpOptions? options = null) + { + options ??= optionsMonitor.CurrentValue; + var type = options.Type.ToType(); + var mode = options.Algorithm.ToMode(); + var uri = new OtpUri(type, options.SecretKey, options.UserName, options.IssuerName, mode, options.Digits, options.Period, options.Counter); + return uri.ToString(); + } + + public string Compute(string secretKey, int period = 30, OtpHashMode mode = OtpHashMode.Sha1, int digits = 6, DateTime? timestamp = null) + { + var instance = new Totp(Base32Encoding.ToBytes(secretKey), period, mode.ToMode(), digits, timeCorrection: null); + Instance = new DefaultTotpInstance(instance); + return timestamp == null ? instance.ComputeTotp() : instance.ComputeTotp(timestamp.Value); + } + + public int GetRemainingSeconds(DateTime? timestamp = null) + { + if (Instance != null) + { + return timestamp == null ? Instance.GetRemainingSeconds() : Instance.GetRemainingSeconds(timestamp.Value); + } + var instance = new Totp(Base32Encoding.ToBytes("OMM2LVLFX6QJHMYI")); + return timestamp == null ? instance.RemainingSeconds() : instance.RemainingSeconds(timestamp.Value); + } + + public string GenerateSecretKey(int length = 20) + { + var secretKey = KeyGeneration.GenerateRandomKey(length); + return Base32Encoding.ToString(secretKey); + } + + public byte[] GetSecretKeyBytes(string input) + { + return Base32Encoding.ToBytes(input); + } + + public bool Verify(string code, DateTime? timestamp = null) + { + if (Instance != null) + { + return timestamp == null ? Instance.Verify(code) : Instance.Verify(code, timestamp.Value); + } + var instance = new Totp(Base32Encoding.ToBytes("OMM2LVLFX6QJHMYI")); + return timestamp == null ? instance.VerifyTotp(code, out _) : instance.VerifyTotp(timestamp.Value, code, out _); + } +} + +class DefaultTotpInstance(Totp instance) : TotpInstanceBase +{ + public override int GetRemainingSeconds(DateTime? timestamp = null) + { + return timestamp == null ? instance.RemainingSeconds() : instance.RemainingSeconds(timestamp.Value); + } + + public override bool Verify(string code, DateTime? timestamp = null) + { + return timestamp == null ? instance.VerifyTotp(code, out _) : instance.VerifyTotp(timestamp.Value, code, out _); + } +}