選單
GSS 技術部落格
在這個園地裡我們將從技術、專案管理、客戶對談面和大家分享我們多年的經驗,希望大家不管是喜歡或是有意見,都可以回饋給我們,讓我們有機會和大家對話並一起成長!
若有任何問題請來信:gss_crm@gss.com.tw
12 分鐘閱讀時間 (2365 個字)

從零開始:在 ABP MVC 專案中實現多因素驗證 (MFA) 的完整教學

從零開始:在 ABP MVC 專案中實現多因素驗證 (MFA) 的完整教學

前言

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 的 MVC 專案

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
}
}

  • 註: 如果沒安裝 ABP CLI 請先在 Command Window 中執行 dotnet tool install -g Volo.Abp.Studio.Cli 來安裝
  • 註: 環境中要有 Node JS

步驟 2:在 個人資料管理頁面(Account/Manage)中 增加 MFA 設定功能

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 AuthenticatorMicrosoft 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欄位已變成1AbpUserTokens資料表,也有對應 User 的資料,如下圖,

當啟用 MFA 後,重新登入後,系統就會發生 NotImplementedException: The method or operation is not implemented. 的錯誤,如下圖,

這是因為預設的ABP登入功能沒有實作TwoFactorLoginResultAsync這個 Method,接下來,我們就來補上這一塊

步驟 3:實作登入後 雙重身份驗證 (MFA) 的驗證

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.cshtmlLogin.cshtml.csLogin.jsLoginWith2fa.cshtmlLogin.cshtml.cs 等 5 個檔案。
Login.cshtmlLogin.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 的畫面,如下圖,

到了這裡,我們已經完成了讓使用者可以啟用/停用 MFA,啟用 MFA 的使用者,登入後,會再進行 MFA 的驗證。
當有勾選 記住這個裝置,則下次再登入時,如果是同樣的裝置,就不需要再進行 MFA 的驗證。
但是那些沒設定啟用 MFA 的使用者要如何強迫他們啟用 MFA 呢?
接下來, 我們可以使用 Middleware 來處理

步驟 4:建立 Middleware 強制引導使用者完成 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);
}
}

2.在 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

參考資源

兩種語言,一個目標:從衝突到協作的過程
Modular Monolith Architecture

相關文章

 

評論

尚無評論
已經注冊了? 這裡登入
Guest
2025/05/04, 週日

Captcha 圖像