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 _);
+ }
+}