這篇是我寫給自己的筆記,記錄如何在 ASP.NET Core 實作自訂表單驗證。就是自己用資料庫存帳號密碼,要設計註冊及登入介面,並考慮忘記密碼重設... 等實作細節。

自幹帳密登入是件麻煩又複雜的事,弄不好還會導致資安風險,決定這麼做之前,建議先想想其他解決方案的可能性,例如:讓使用者用 Google、FB、Outlook/Hotmail、Twitter 帳號登入、使用 ASP.NET Identity 會員管理機制... 等等,都是好選項,基本上會比自己做簡單且安全。

但如果最後仍決定要自己做,相信一定會有很好的理由,那麼就深吸一口氣謹慎設計吧,願原力與大家同在。

一般 HTML 表單登入成功後,建立 Session Cookie 時需留意內容加密、逾時、防止 JavaScript 竊取等細節,這塊 ASP.NET Core 的內建機制已做得十分完善及彈性,建議直接引用,不要自己搞。

我的範例專案採用 .NET 10,包含以下功能:

  1. Login.cshtml 介面輸入帳號密碼,檢查密碼相符後以 HttpContext.SignInAsync() 寫入 Cookie 完成登入
  2. Index.cshtml 限登入後使用,可識別使用者登入身分,展示用 [Authorize] Attribute 限定必須登入才能存取
  3. /Home/AdminOnly 限定 Admin 角色(Role)才能存取,展示如何用 [Authorize] Attribute 控制存取角色
  4. /Home/Logout 展示用 HttpContext.SignOutAsync() 登出

以上邏輯主要集中在 Program.cs 及 HomeController.cs,說明如下。

在 Programe.cs 要呼叫 Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie() 宣告使用標準 HTML 表單 Cookie 式登入(其他選項還有 JWT Bearer、OpenIdConnect/OIDC、OAuth... 等),並且記得不要遺漏 Services.AddAuthorization()、app.UseAuthentication()、 app.UseAuthorization()。

using Microsoft.AspNetCore.Authentication.Cookies;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews();

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.LoginPath = "/Home/Login";
        options.LogoutPath = "/Home/Logout";
        options.AccessDeniedPath = "/Home/AccessDenied";
        // 禁止 JavaScript 存取
        options.Cookie.HttpOnly = true;
        // 強制使用 HTTPS 傳輸 Cookie
        options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        // 持續使用時自動延長 Cookie 生命週期
        options.SlidingExpiration = true;
        options.ExpireTimeSpan = TimeSpan.FromMinutes(30);
    });

builder.Services.AddAuthorization();

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseAuthentication();
app.UseAuthorization();

app.MapDefaultControllerRoute();

app.Run();

AddCookie() options 的常用選項如下:參考

選項預設值說明
LoginPath/Account/Login未認證時導向的登入頁
LogoutPath/Account/Logout登出時導向的路徑
AccessDeniedPath/Account/AccessDenied授權拒絕(Forbid)時導向的頁面
ReturnUrlParameter"ReturnUrl"登入後回原頁的 Query String 參數名稱
Cookie.Name.AspNetCore.CookiesCookie 名稱
Cookie.HttpOnlytrue禁止 JavaScript 存取 Cookie
Cookie.SecurePolicySameAsRequestHTTPS 限制(Always / SameAsRequest / None
Cookie.SameSiteLax跨站請求限制,防 CSRF(Strict / Lax / None
Cookie.DomainnullCookie 作用網域
Cookie.Path/Cookie 作用路徑
Cookie.MaxAgenull瀏覽器端 Cookie 最長存活時間(與 ExpireTimeSpan 不同)
ExpireTimeSpan14認證 ticket 有效期限(優先於 Cookie.Expiration
SlidingExpirationfalse超過半段時間後自動延長 ticket 效期
Events預設不實作事件掛載事件回呼函式(見下方事件列表)
SessionStorenull可自訂將識別身分資料存於 Server 端,Cookie 只存 Session ID
TicketDataFormat自動建立自訂 Cookie 內容的加密/解密格式
DataProtectionProvidernull指定 Data Protection 提供者
CookieManagerChunkingCookieManager自訂 Cookie 讀寫元件(自動分塊處理大 Cookie)
ClaimsIssuernull指定 Claims 的 Issuer

Events 可掛載回呼(options.Events

事件時機
OnValidatePrincipal每次請求驗證 Cookie 時,可用來拒絕已失效的身份
OnSigningInSignInAsync 執行前
OnSignedInSignInAsync 執行後
OnSigningOutSignOutAsync 執行前
OnRedirectToLogin即將重導向至登入頁前(可自訂回傳 401)
OnRedirectToLogout即將重導向至登出頁前
OnRedirectToAccessDenied即將重導向至拒絕頁前(可自訂回傳 403)
OnRedirectToReturnUrl登入成功後即將重導向回原頁前

為求簡化,範例專案沒使用 DB 存密碼,直接寫在 appsettings.json (共有兩個帳號,user 及 admin,密碼都是 P@ssW0rd),雜湊則使用寫個符合 OWASP 要求的密碼雜湊公用函式一文的 Argon2idPasswordHasher 計算及驗證。

程式重點幾乎都 HomeController.cs:

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;

namespace cust_form_auth.Controllers;


public class AppUser
{
    public string UserId { get; set; } = string.Empty;
    public string PasswordHash { get; set; } = string.Empty;
}

public class HomeController(IConfiguration config) : Controller
{
    // GET /Home/Index
    [Authorize]
    public IActionResult Index() 
    {
        return View();
    }

    [Authorize(Roles = "Admin")]
    public IActionResult AdminOnly()
    {
        return Content("Admin 專屬功能");
    }

    // GET /Home/AccessDenied
    // 已登入但權限不足時,Cookie 驗證會導向這裡。
    [Authorize]
    public IActionResult AccessDenied() => View();

    // GET /Home/Login
    public IActionResult Login(string? returnUrl = null)
    {
        if (User.Identity?.IsAuthenticated == true)
            return RedirectToAction(nameof(Index));

        ViewBag.ReturnUrl = returnUrl;
        return View();
    }

    // POST /Home/Login
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Login(string userId, string passwd, string? returnUrl = null)
    {
        if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrEmpty(passwd))
        {
            ViewBag.ReturnUrl = returnUrl;
            ViewBag.ErrorMessage = "帳號與密碼不可空白。";
            ViewBag.UserId = userId;
            return View();
        }

        // TODO: 實務應用多會以資料庫儲存,此處使用 JSON 設定檔示意
        var users = config.GetSection("AppUsers").Get<List<AppUser>>() ?? [];
        var matched = users.FirstOrDefault(u =>
            string.Equals(u.UserId, userId, StringComparison.Ordinal));

        if (matched is null ||
            !await Argon2idPasswordHasher.VerifyPasswordAsync(passwd, matched.PasswordHash))
        {
            ViewBag.ReturnUrl = returnUrl;
            // 不告知使用者是帳號錯誤還是密碼錯誤,減少資訊外洩
            ViewBag.ErrorMessage = "帳號或密碼錯誤,請重新輸入。";
            ViewBag.UserId = userId;
            return View();
        }

        // ClaimTypes.Name 會成為 User.Identity.Name,用以識別登入者
        // ClaimTypes.Role 可搭配 [Authorize(Roles = "User")] 做角色授權
        var claims = new List<Claim>
        {
            new(ClaimTypes.Name, matched.UserId),
            new(ClaimTypes.Role, "User")
        };
        if (matched.UserId == "admin")
        {
            claims.Add(new Claim(ClaimTypes.Role, "Admin"));
        }

        // 使用 CookieAuthenticationDefaults.AuthenticationScheme 建立身分識別
        // 這個 Scheme 必須和 Program.cs 裡 AddAuthentication/AddCookie 設定的一致
        var identity  = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);

        // ClaimsPrincipal 代表目前登入的使用者,ASP.NET Core 會把它序列化到驗證 Cookie。
        var principal = new ClaimsPrincipal(identity);

        // 寫入登入 Cookie,讓後續請求可通過 [Authorize] 驗證。
        await HttpContext.SignInAsync(
            CookieAuthenticationDefaults.AuthenticationScheme,
            principal,
            new AuthenticationProperties { 
                // 關閉瀏覽器時清除 Cookie,不記憶已登入狀態
                IsPersistent = false
            });

        // 防止 Open Redirect 導向至第三方網站
        if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl))
            return Redirect(returnUrl);

        return RedirectToAction(nameof(Index));
    }

    // POST /Home/Logout
    [HttpPost]
    [ValidateAntiForgeryToken]
    [Authorize]
    public async Task<IActionResult> Logout()
    {
        await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
        return RedirectToAction(nameof(Login));
    }
}

重點說明:

  1. /Home/Index 加上 [Authorize],未登入時會被導向 /Home/Login
  2. /Home/AdminOnly 加上 [Authorize(Roles = "Admin")] ,ASP.NET Core 會檢查登入者 Claims 資料必須包含 Admin Role 才能存取,否則導向 AccessDenied.cshtml
  3. /Home/Login POST 時檢查帳號密碼,密碼正確時建立 Claim 資料集合 List<Claim>,寫入 Name、Role 等資料,這些內容會被加密寫入 Cookie。
    接著將 Claim 資料包成 ClaimsIdentity 建立 ClaimsPrincipal,再傳入 HttpContext.SignInAsync() 完成登入。
  4. /Home/Logout 則呼叫 HttpContext.SignOutAsync() 完成登出。

操作畫面如下:

完整專案範例在 Github

A personal note on implementing custom form authentication in ASP.NET Core using cookie authentication, Claims, roles, login/logout, authorization attributes, and secure password verification with Argon2id. Includes key options and a GitHub sample.


Comments

# by Fatina

弄不還會導致資安風險 => 弄不好還會導致資安風險

# by Jeffrey

to Fatina,已修正,謝~

Post a comment