MFA(多因素驗證)是提升帳號安全性的重要手段,能有效防止因密碼洩漏而導致的未授權存取。然而,在 ABP Framework 中,若想使用官方完整支援的 MFA 功能,通常需要升級至 Pro 版本。
事實上,ASP.NET Core Identity 本身就內建了基本的 MFA 支援。若不追求 Pro 版的進階與全面安全機制,只需透過少量設定與簡單的 UI 介面,即可實現大部分的多因素驗證需求。
本文將引導你一步一步地,從基礎開始,逐步完成在 ABP Framework MVC 專案中啟用 MFA 的完整流程。
1.使用者登入後,如果沒啟用 MFA ,則會被導到啟用 MFA 畫面
2.使用者啟用 MFA 後,下次登入後,會需要輸入 MFA 驗證碼來登入驗證。
1.建立 single-layer, leptonx-lite theme, mssql 的測試專案,在 Command 視窗中執行以下 Command:abp new Sun -t app-nolayers --theme leptonx-lite -csf
2.cd Sun/Sun
執行 abp install-libs
安裝 ABP 所需要的 Client Library
3.透過 VSCode or VS.NET 開啟 Sun 方案,為了簡單測試,開啟 SunModule.cs
將其中的 IsMultiTenant
設定成 false
(public const bool IsMultiTenant = false;
)
4.透過 migration 來建立資料表,
確定 appsettings.json
中的資料庫連線字串是正確的。
在 Command 視窗執行 dotnet run --migrate-database
來建立資料庫及資料表
5.在 appsettings.json
加入啟用 MFA 的設定值 (Abp.Identity.EnableTwoFactorAuthentication
) 為 true
{
//.. others
"Settings": {
//.. others
"Abp.Identity.EnableTwoFactorAuthentication": true
}
}
dotnet tool install -g Volo.Abp.Studio.Cli
來安裝1.新增 QRCoder
Nuget 套件(dotnet add package QRCoder
)(因為要產生 QRCode 讓 Google/Microsoft Authenticator 去掃描)
2.要在 Volo.Abp.Account.Web/Pages/Account/Components/ProfileManagementGroup
中新增一個頁面,所以在 Pages 目錄中,新增 Account/Components/ProfileManagementGroup/TwoFactorAuthentication
這 4 個目錄
3.在 TwoFactorAuthentication 目錄下,
新增 AccountProfileTwoFactorAuthenticationManagementGroupViewComponent.cs
負責建立使用者要註冊的 QRCode,再將它導到 Default.cshtml
呈現在使用者登入後,點選頭像,選擇 我的帳號 ,程式如下,
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using QRCoder;
using Volo.Abp.AspNetCore.Mvc;
using Volo.Abp.Users;
using IdentityUser = Volo.Abp.Identity.IdentityUser;
namespace Sun.Pages.Account.Components.ProfileManagementGroup.TwoFactorAuthentication;
public class AccountProfileTwoFactorAuthenticationManagementGroupViewComponent : AbpViewComponent
{private readonly UserManager<IdentityUser> _userManager;
private readonly ICurrentUser _currentUser;
private readonly IConfiguration _configuration;
public AccountProfileTwoFactorAuthenticationManagementGroupViewComponent(
UserManager<IdentityUser> userManager,
ICurrentUser currentUser,
IConfiguration configuration)
{
_userManager = userManager;
_currentUser = currentUser;
_configuration = configuration;
}
public async Task<IViewComponentResult> InvokeAsync()
{
var user = await _userManager.FindByEmailAsync(_currentUser.Email);
var key = await _userManager.GetAuthenticatorKeyAsync(user);
if (string.IsNullOrEmpty(key))
{
await _userManager.ResetAuthenticatorKeyAsync(user);
key = await _userManager.GetAuthenticatorKeyAsync(user);
}
var appName = _configuration["App:Name"] ?? "AbpApp";
var otpauthUri = $"otpauth://totp/{appName}:{user.Email}?secret={key}&issuer={appName}&digits=6";
var qrGenerator = new QRCodeGenerator();
var qrCodeData = qrGenerator.CreateQrCode(otpauthUri, QRCodeGenerator.ECCLevel.Q);
var qrCode = new PngByteQRCode(qrCodeData);
var qrCodeBytes = qrCode.GetGraphic(10);
var base64 = Convert.ToBase64String(qrCodeBytes);
var base64Image = $"data:image/png;base64,{base64}";
return View("~/Pages/Account/Components/ProfileManagementGroup/TwoFactorAuthentication/Default.cshtml", new TwoFactorAuthModel
{
OtpUri = otpauthUri,
IsEnabled = user.TwoFactorEnabled,
QrCodeBase64 = base64Image
});
}
public class TwoFactorAuthModel
{
public string OtpUri { get; set; }
public bool IsEnabled { get; set; }
public string QrCodeBase64 { get; set; }
}
}
被執行時,會透過 CurrentUser 的 Email 來取得使用者,再透過 UserManager 來取得 AuthenticatorKey ,組出 otpauth 並建立它的 QRCode 圖檔。最後將這些資料傳給 Default.cshtml
otpauthUri
是一種標準化的 URI 格式,通常用來在多因素驗證(MFA)流程中產生 QR Code,供使用者透過像是 Google Authenticator、Microsoft Authenticator 等 TOTP(Time-based One-Time Password)App 掃描並註冊帳號。
4.建立使用者要註冊啟用或停用的畫面(Default.cshtml
)
@using Microsoft.AspNetCore.Mvc.Localization
@using Sun.Localization
@using Sun.Pages.Account.Components.ProfileManagementGroup.TwoFactorAuthentication
@inject IHtmlLocalizer<SunResource> L
@model AccountProfileTwoFactorAuthenticationManagementGroupViewComponent.TwoFactorAuthModel
<form id="TwoFactorAuthForm"><div class="mb-3">
<div id="divDisableMFA">
<div class="alert alert-success">@L["EnableMFAInfo"]</div>
<abp-button id="btnDisableMFA" type="button" button-type="Danger" text="@L["DisableMfa"].Value" />
</div>
<div id="divEnableMFA">
<div>
<p>@L["UseGoogleAuthenticator"]</p>
<img src="/@Model.QrCodeBase64" alt="QR Code for Authenticator" class="img-fluid rounded border" width="150" height="150" />
</div>
<div class="mt-3">
<label for="verification-code" class="form-label">@L["EnterVerificationCode"]</label>
<input id="verification-code" type="text" class="form-control" maxlength="6" required />
</div>
<div class="mt-3">
<abp-button type="submit" button-type="Primary" text="@L["EnableMfa"].Value" />
</div>
</div>
</div>
</form>
有 2 個 div,當使用者已啟用 MFA 時,可以停用它。當使用者沒啟用時,可以輸入驗證碼去啟用它。
5.建立 Default.js
去控制 div 的顯示與 Button Click 的處理
(function ($) {
$(function () {
var l = abp.localization.getResource("Sun");
reloadTwoFactorAuthentication();
$("#TwoFactorAuthForm").submit(function (e) {
e.preventDefault();
if (!$("#TwoFactorAuthForm").valid()) {
return false;
}
const vc = $("#verification-code").val();
sun.services.twoFactorAuth
.verifyTwoFactorToken(vc)
.then(function (result) {
abp.notify.success(l("EnableMfaSuccess"));
reloadTwoFactorAuthentication();
});
});
$("#btnDisableMFA").click(function (e) {
abp.message.confirm(
l("DisableMfaSuccess", l("DisableMfa")),
l("Confirm"),
function (confirmed) {
if (confirmed) {
sun.services.twoFactorAuth
.enableTwoFactor(false)
.then(function (result) {
abp.notify.success(l("DisableMfaSuccess"));
reloadTwoFactorAuthentication();
});
}
}
);
});
function reloadTwoFactorAuthentication() {
const divEnableMFA = $("#divEnableMFA");
const divDisableMFA = $("#divDisableMFA");
divEnableMFA.hide();
divDisableMFA.hide();
sun.services.twoFactorAuth.twoFactorTokenStatus().then(function (result) {
console.log(result);
if (result) {
divDisableMFA.show();
} else {
divEnableMFA.show();
}
});
}
});
})(jQuery);
註: sun.services.twoFactorAuth
是 ABP 自動為 AppService 建立的 Client Proxy ,可讓 JS 呼叫 AppService 的 Method
6.建立 TwoFactorAuthAppService
來處理使用者針對 MFA 的相關處理
在 Services
目錄中,新增 TwoFactorAuthAppService.cs ,來處理使用者的 MFA 狀態,啟用/禁用 MFA 等功能,程式如下,
using Microsoft.AspNetCore.Identity;
using Volo.Abp;
using IdentityUser = Volo.Abp.Identity.IdentityUser;
namespace Sun.Services;
public class TwoFactorAuthAppService : SunAppService
{private readonly UserManager<Volo.Abp.Identity.IdentityUser> _userManager;
public TwoFactorAuthAppService(UserManager<Volo.Abp.Identity.IdentityUser> userManager)
{
_userManager = userManager;
}
private Task<IdentityUser> GetUser()
{
return _userManager.FindByEmailAsync(CurrentUser.Email);
}
public async Task<bool> TwoFactorTokenStatus()
{
var user = await GetUser();
return user.TwoFactorEnabled;
}
public async Task EnableTwoFactorAsync(bool isEnable)
{
var user = await GetUser();
if (user == null)
{
throw new UserFriendlyException(L["UserNotExist"]);
}
// Use the UserManager's SetTwoFactorEnabledAsync method to enable two-factor authentication
var result = await _userManager.SetTwoFactorEnabledAsync(user, isEnable);
if (!result.Succeeded)
{
throw new UserFriendlyException(L["EnableMfaFail", string.Join(",", result.Errors.Select(err => err.ToString()))]);
}
await _userManager.UpdateAsync(user);
}
public async Task<bool> VerifyTwoFactorToken(string code)
{
var user = await GetUser();
if (user == null)
{
throw new UserFriendlyException(L["UserNotExist"]);
}
var isValid = await _userManager.VerifyTwoFactorTokenAsync(
user, TokenOptions.DefaultAuthenticatorProvider, code);
if (!isValid)
{
throw new UserFriendlyException(L["VerificationCodeError"]);
}
await EnableTwoFactorAsync(true);
return isValid;
}
}
7.要將畫面加到個人資料管理頁面(Account/Manage)中,需要將資料加入到 ProfileManagementPageCreationContext
之中 (context.Groups.Add
),所以在專案中新增Contributors
目錄,新增CustomAccountProfileManagementPageContributor.cs ,程式如下,
using Microsoft.Extensions.Localization;
using Sun.Localization;
using Sun.Pages.Account.Components.ProfileManagementGroup.TwoFactorAuthentication;
using Volo.Abp.Account.Web.ProfileManagement;
namespace Sun.Contributors;
public class CustomAccountProfileManagementPageContributor : IProfileManagementPageContributor
{public async Task ConfigureAsync(ProfileManagementPageCreationContext context)
{
var l = context.ServiceProvider.GetRequiredService<IStringLocalizer<SunResource>>();
context.Groups.Add(
new ProfileManagementPageGroup(
"Volo.Abp.Account.TwoFactorAuthentication",
l["ProfileTab:TwoFactorAuthentication"],
typeof(AccountProfileTwoFactorAuthenticationManagementGroupViewComponent)
)
);
}
}
8.在 SunModule.cs
中加入在個人資料管理頁面增加 MFA 畫面的相關設定,並且把 Default.js
加到 Bundles 中,
public override void ConfigureServices(ServiceConfigurationContext context)
{// other ....
ConfigureProfileManagementPage(configuration);
}
private void ConfigureProfileManagementPage(IConfiguration configuration)
{var enableTwoFactor = configuration.GetValue<bool>("Settings:Abp.Identity.EnableTwoFactorAuthentication");
if (enableTwoFactor)
{
//using Volo.Abp.Account.Web.ProfileManagement;
Configure<ProfileManagementPageOptions>(options =>
{
//using Sun.Contributors;
options.Contributors.AddFirst(new CustomAccountProfileManagementPageContributor());
});
Configure<AbpBundlingOptions>(options =>
{
//using Volo.Abp.Account.Web.Pages.Account;
options.ScriptBundles.Configure(
typeof(ManageModel).FullName,
configuration =>
{
configuration.AddFiles("/Pages/Account/Components/ProfileManagementGroup/TwoFactorAuthentication/Default.js");
});
});
}
}
9.增加多國語系設定值, 在 Localization/Sun 目錄中,請針對需要的語系新增相關內容,例如,
{"culture": "en",
"texts": {
"AppName": "Sun",
"Welcome_Title": "Welcome",
"Welcome_Text": "This is a minimalist, single layer application startup template for the ABP Framework.",
"Menu:Home": "Home",
"ProfileTab:TwoFactorAuthentication": "Two-factor authentication settings",
"UserNotExist": "The user does not exist",
"EnableMfaFail": "Two-factor authentication can't be enabled:{0}",
"EnableMfaSuccess": "Enable Two-factor authentication Successful",
"DisableMfaSuccess": "Disable Two-factor authentication Successful",
"DisableMfa": "Disable Two-factor authentication",
"EnableMfa": "Enable Two-factor authentication",
"Confirm": "Confirm",
// .....
}
}
詳細內容請參考Source: en.json
做到這裡,已成功將畫面加到 個人資料管理頁面(Account/Manage) 功能之中,請執行 dotnet run
來驗證 MFA 畫面是否有正常被加進去,使用預設帳號 admin
,預設密碼 1q2w3E*
登入後,點選右上方的 admin
,選擇 我的帳戶,如下圖,
進到我的帳戶第一個畫面就是雙重驗證設定,如下圖,
用 Google/Microsoft Authenticator 掃描 QRCode 的圖後,輸入驗證碼,就會啟用 MFA 設定,然後畫面就會切換到讓使用者停用 MFA
查看資料庫中AbpUsers
資料表可以發現TwoFactorEnabled
欄位已變成1,AbpUserTokens
資料表,也有對應 User 的資料,如下圖,
當啟用 MFA 後,重新登入後,系統就會發生 NotImplementedException: The method or operation is not implemented. 的錯誤,如下圖,
這是因為預設的ABP登入功能沒有實作TwoFactorLoginResultAsync這個 Method,接下來,我們就來補上這一塊
1.實作 Login 的 TwoFactorLoginResultAsync Method
目前使用的是 Abp 9.1 所以 Copy ABP Account Login.cshtml 及 Login.js
來,並繼承Volo.Abp.Account.Web.Pages.Account.LoginModel
來實作 MFA 功能。
在Pages/Account
目錄下,新增Login.cshtml
, Login.cshtml.cs
, Login.js
, LoginWith2fa.cshtml
, Login.cshtml.cs
等 5 個檔案。
Login.cshtml 及 Login.js 請直接 Copy 自 ABP Source
Login.cshtml.cs如下,
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using System.Diagnostics;
using Volo.Abp.Account.Settings;
using Volo.Abp.Identity;
using Volo.Abp.Identity.AspNetCore;
using Volo.Abp.Settings;
using Volo.Abp.Account.Web;
namespace Sun.Pages.Account;
public class CustomLoginModel : Volo.Abp.Account.Web.Pages.Account.LoginModel
{private readonly IConfiguration _configuration;
public CustomLoginModel(
IAuthenticationSchemeProvider schemeProvider,
IOptions<AbpAccountOptions> accountOptions,
IOptions<IdentityOptions> identityOptions,
IdentityDynamicClaimsPrincipalContributorCache identityDynamicClaimsPrincipalContributorCache,
IConfiguration configuration)
: base(schemeProvider, accountOptions, identityOptions, identityDynamicClaimsPrincipalContributorCache)
{
_configuration = configuration;
}
protected override Task<IActionResult> TwoFactorLoginResultAsync()
{
TempData["remember_me"] = LoginInput.RememberMe;
return Task.FromResult<IActionResult>(RedirectToPage("./LoginWith2fa"));
}
}
覆寫 TwoFactorLoginResultAsync Method,將 RememberMe 選項存到 TempData 之中,並導往 LoginWith2fa
進行 MFA 驗證,
2.增加LoginWith2fa
畫面來處理 MFA 驗證碼的驗證,主要會有 驗證碼、RememberMe 及 Remember this device 這3個欄位
LoginWith2fa.cshtml.cs,如下
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Localization;
using Sun.Localization;
using System.ComponentModel.DataAnnotations;
using Volo.Abp.Account.Localization;
using Volo.Abp.AspNetCore.Mvc.UI.RazorPages;
using IdentityUser = Volo.Abp.Identity.IdentityUser;
namespace Sun.Pages.Account;
public class LoginWith2faModel : AbpPageModel
{private readonly SignInManager<IdentityUser> _signInManager;
public LoginWith2faModel(SignInManager<IdentityUser> signInManager)
{
LocalizationResourceType = typeof(SunResource);
_signInManager = signInManager;
}
[BindProperty]
public InputModel Input { get; set; } = new();
public void OnGet()
{
Input.RememberMe = TempData.ContainsKey("remember_me") && Convert.ToBoolean(TempData["remember_me"]);
}
public async Task<IActionResult> OnPostAsync()
{
ValidateModel();
var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
if (user == null)
{
Alerts.Warning(L["UserLogoutOrSessionExpired"]);
return RedirectToPage("/Login");
}
var result = await _signInManager.TwoFactorAuthenticatorSignInAsync(
Input.TwoFactorCode.Replace(" ", string.Empty).Replace("-", string.Empty),
Input.RememberMe,
Input.RememberMachine
);
if (result.Succeeded)
{
return RedirectToPage("/Index");
}
if (result.IsLockedOut)
{
Alerts.Warning(L["UserLockedOutMessage"]);
return Page();
}
Alerts.Warning(L["VerificationCodeError"]);
return Page();
}
public class InputModel
{
[Required]
[StringLength(7, MinimumLength = 6)]
public string TwoFactorCode { get; set; }
public bool RememberMachine { get; set; }
public bool RememberMe { get; set; }
}
}
LoginWith2fa.cshtml,如下
@page
@using Microsoft.AspNetCore.Mvc.Localization
@using Volo.Abp.Account.Localization
@using Volo.Abp.AspNetCore.Mvc.UI.Theming
@using Sun.Pages.Account
@using Sun.Localization
@model LoginWith2faModel
@inject IHtmlLocalizer<SunResource> L
@inject IHtmlLocalizer<AccountResource> LA
@inject IThemeManager ThemeManager
@{Layout = ThemeManager.CurrentTheme.GetAccountLayout();
}
@section scripts
{<abp-script-bundle name="@typeof(LoginWith2faModel).FullName">
<abp-script src="/Pages/Account/LoginWith2fa.js" />
</abp-script-bundle>
}
<div class="card mt-3 shadow-sm rounded"><div class="card-body p-5">
<h4>@L["MFAVerification"]</h4>
<form method="post" class="mt-4">
<div class="mb-3">
<label asp-for="Input.TwoFactorCode" class="form-label">
@L["EnterVerificationCode"]</label>
<input asp-for="Input.TwoFactorCode" class="form-control" />
<span asp-validation-for="Input.TwoFactorCode" class="text-danger"></span>
</div>
<div class="mb-3">
<input asp-for="Input.RememberMachine" class="form-check-input" />
<label asp-for="Input.RememberMachine" class="form-label">@L["RememberDevice"]</label>
<span asp-validation-for="Input.RememberMachine" class="text-danger"></span>
</div>
<div class="mb-3">
<input asp-for="Input.RememberMe" class="form-check-input" />
<label asp-for="Input.RememberMe" class="form-label">@L["RememberMe"]</label>
<span asp-validation-for="Input.RememberMe" class="text-danger"></span>
</div>
<div class="d-grid gap-2">
<abp-button type="submit" button-type="Primary" name="Action" value="Login"
class="btn-lg mt-3">@LA["Login"]</abp-button>
</div>
</form>
</div>
</div>
dotnet run
後,使用 帳號 admin
登入後,可以發現會被導到了驗證 MFA 的畫面,如下圖,1.新增Middlewares
目錄,新增EnforceMfaMiddleware.cs
的 Middleware,
using Microsoft.AspNetCore.Identity;
using Volo.Abp.Uow;
using IdentityUser = Volo.Abp.Identity.IdentityUser;
namespace Sun.Middlewares;
public class EnforceMfaMiddleware
{private readonly RequestDelegate _next;
public EnforceMfaMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
if (context.User.Identity?.IsAuthenticated == true)
{
var path = context.Request.Path.Value?.ToLower();
// 排除登入、MFA 設定頁等
if (!path.StartsWith("/account/login") &&
!path.StartsWith("/account/manage") &&
!path.StartsWith("/abp") &&
!path.StartsWith("/api") &&
!path.StartsWith("/account/logout"))
{
var userManager = context.RequestServices.GetRequiredService<UserManager<IdentityUser>>();
var user = await userManager.GetUserAsync(context.User);
var isMfaEnabled = await userManager.GetTwoFactorEnabledAsync(user);
var unitOfWorkManager = context.RequestServices.GetRequiredService<IUnitOfWorkManager>();
var hasAuthenticatorKey = false;
using (var uow = unitOfWorkManager.Begin(requiresNew: true))
{
hasAuthenticatorKey = !string.IsNullOrWhiteSpace(await userManager.GetAuthenticatorKeyAsync(user));
}
if (!isMfaEnabled || !hasAuthenticatorKey)
{
context.Response.Redirect("/Account/Manage");
return;
}
}
}
await _next(context);
}
}
SunModule.cs
中的OnApplicationInitialization
Method 中判斷設定值,如果為 true 就判斷使用者是否有啟用 MFA ,沒有的話,就強迫轉到 MFA 設定功能。public override void OnApplicationInitialization(ApplicationInitializationContext context)
{var app = context.GetApplicationBuilder();
var env = context.GetEnvironment();
// other codes ...
if (IsMultiTenant)
{
app.UseMultiTenancy();
}
var configuration = context.ServiceProvider.GetRequiredService<IConfiguration>();
if (configuration.GetValue<bool>("Settings:Abp.Identity.EnableTwoFactorAuthentication"))
{
//using Sun.Middlewares;
app.UseMiddleware<EnforceMfaMiddleware>();
}
// other codes ...
}
dotnet run
後,使用 帳號 admin
登入後,可以發現,當使用者沒啟用 MFA 就會被強迫導到 MFA 啟用畫面透過本篇教學,我們完整實現了在 ABP Framework MVC 專案中啟用多因素驗證 (MFA) 的功能,從專案初始化到 MFA 設定畫面、登入驗證以及強制啟用 MFA 的 Middleware,涵蓋了完整的實作流程。這不僅提升了應用程式的安全性,也展示了如何靈活運用 ABP Framework 及 ASP.NET Core Identity 的擴展能力。
希望這篇文章能幫助你在實務中更好地應用 ABP Framework,完整的程式碼請參考 Github abpmvcmfa