上回提到我想做過手機掃 QR Code 條碼登入 ASP.NET Core 網站,這涉及自訂登入身分檢查邏輯,在 ASP.NET 時代可以呼叫 FormsAuthentication.SetAuthCookie 寫入 Cookie (範例) 模擬登入成功狀態,但換成 ASP.NET Core 要怎麼做?這是個好問題,也是本篇文章的研究重點。

我假想了一種不用密碼,回答挑戰問題的登入方式(Challenge Response Authentication),為求簡便,範例是用兩個數字相加當成挑戰,回應正確答案即登入成功。實務應用時,可將挑戰問題換成雜湊或公私鑰相關的演算,即能達到資安要求的強度。

我使用 ASP.NET Core MVC 專案實作,加一個 VIPServiceController,加註 [Authorize] 要求先登入才能使用:

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace DemoWeb.Controllers
{
    [Authorize]
    public class VIPServiceController : Controller
    {
        public IActionResult Index()
        {
            return View();
        }
    }
}

登入身分驗證邏輯寫在 AuthController.cs。挑戰問題的解答我存入 IDistributedCache,開發測試用記憶體模擬,正式應用時可改用 Redis、SQL 或 NCache,程式不必修改就能適用負載平衡架構。挑戰答案 Cache Key 採隨機 GUID,與答案一起送回後端,再從 IDistributedCache 取出答案比對。至於寫入身分認證 Cookie 的方法,我是參考微軟這篇: Use cookie authentication without ASP.NET Core Identity),用 HttpContext.SignInAsync() 搞定:

using System.Security.Claims;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using DemoWeb.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Distributed;

namespace DemoWeb.Controllers
{
    public class AuthController : Controller
    {
        private readonly IDistributedCache _cache;

        public AuthController(IDistributedCache cache)
        {
            _cache = cache;
        }
        public IActionResult Index()
        {
            return Content("OK");
        }

        void CreateChallenge()
        {
            var token = Guid.NewGuid().ToString();
            var rnd = new Random();
            var a = rnd.Next(9) + 1;
            var b = rnd.Next(9) + 1;
            var answer = a + b;
            var challenge = $"{a}+{b}=";
            _cache.SetString(token, answer.ToString(), new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
            });
            ViewBag.Challenge = challenge;
            ViewBag.Token = token;
        }

        [HttpGet]
        public IActionResult Login(string ReturnUrl)
        {
            CreateChallenge();
            ViewBag.ReturnUrl = ReturnUrl;
            return View();
        }
        
        [HttpPost]
        public async Task<IActionResult> Login(string token, string name, string response, string ReturnUrl)
        {
            var message = string.Empty;
            try
            {
                var answer = await _cache.GetStringAsync(token);
                if (answer == null)
                    throw new ApplicationException("Invalid Token");
                await _cache.RemoveAsync(token);
                if (answer == response)
                {
                    await SignIn(name);
                    return Redirect(ReturnUrl ?? "~/");
                }
                else
                {
                    message = "Login Failed";
                }
            }
            catch (Exception ex)
            {
                message = ex.Message;
            }
            ViewBag.Message = message;
            ViewBag.Name = name;
            CreateChallenge();
            ViewBag.ReturnUrl = ReturnUrl;
            return View();
        }

        public async Task<IActionResult> Logout()
        {
            await HttpContext.SignOutAsync();
            return Redirect("~/");
        }

        async Task SignIn(string name)
        {
            var claims = new List<Claim>
            {
                new Claim(ClaimTypes.Name, name),
                new Claim("FullName", name),
                new Claim(ClaimTypes.Role, "Administrator"),
            };

            var claimsIdentity = new ClaimsIdentity(
                claims, CookieAuthenticationDefaults.AuthenticationScheme);

            var authProperties = new AuthenticationProperties
            {
                //AllowRefresh = <bool>,
                // Refreshing the authentication session should be allowed.

                //ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(10),
                // The time at which the authentication ticket expires. A 
                // value set here overrides the ExpireTimeSpan option of 
                // CookieAuthenticationOptions set with AddCookie.

                //IsPersistent = true,
                // Whether the authentication session is persisted across 
                // multiple requests. When used with cookies, controls
                // whether the cookie's lifetime is absolute (matching the
                // lifetime of the authentication ticket) or session-based.

                //IssuedUtc = <DateTimeOffset>,
                // The time at which the authentication ticket was issued.

                //RedirectUri = <string>
                // The full path or absolute URI to be used as an http 
                // redirect response value.
            };

            await HttpContext.SignInAsync(
                CookieAuthenticationDefaults.AuthenticationScheme,
                new ClaimsPrincipal(claimsIdentity),
                authProperties);

        }
    }
}

Program.cs 部分只加三個地方:(見中文註解)

using DemoWeb.Models;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Identity;

var builder = WebApplication.CreateBuilder(args);

// 使用記憶體模擬 IDistributedCache,正式環境可用 Redis/SQL/NCache
builder.Services.AddDistributedMemoryCache();
// 設定 Cookie 式登入驗證,指定登入登出 Action
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
    {
        options.LoginPath = "/Auth/Login";
        options.LogoutPath = "/Auth/Logout";
        //options.AccessDeniedPath = "/Auth/AccessDenied";
    });

// Add services to the container.
builder.Services.AddControllersWithViews();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Home/Error");
}
app.UseStaticFiles();

app.UseRouting();

// 啟用身分認證
app.UseAuthentication();
app.UseAuthorization();

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");

app.Run();

實測結果如下:

範例專案已放上 Github,想試玩的同學可自取參考。

Example of customized login logic with cookie authentication in ASP.NET Core.


Comments

# by quintos

私以为,巨硬在 auth schema 名称这里设计的const名称 CookieAuthenticationDefaults.AuthenticationScheme过于冗长,不如直接用 ”cookie“ 来得清晰,或者自己定义一套常量, AuthSchema.Cookie, AuthSchema.Jwt, AuthSchema.other

# by Masa

謝謝,受益良多

Post a comment