多因素認證(Multiple Factor Authentication,MFA)網頁常會用到一種技巧,介面停在登入網頁,等待手機 App 操作,網頁能偵測 App 動作是否完成,若驗證成功自動導向已登入畫面。

以 Facebook 為例,若有啟動兩階段驗證,第一次登入網站,輸入密碼後會出現以下畫面:

除了開代碼產生器(Authenticator App)查驗證碼在網頁輸入,有安裝 Facebook App 且為登入狀態的手機也會出現「剛剛是你本人登入的嗎?」登入,只要按「是」即可跳過輸入驗證碼步驟,自動進入登入後畫面:

換言之,「需要雙重驗證碼」網頁必定可持續從網站接收最新狀態,才能做到手機 App 一按「是」馬上接到通知。對前端網頁來說,這不是什麼新鮮技術,做法很多,從雖可恥但有用的 setInterval 定期查詢、Long Polling、Server-Sent EventWebSocket,乃至更高階的程式庫如 SignalR,都是解法。我偏好 Servent-Sent Event (SSE),理由是比輪詢即時有效率,不像 WebSocket 會因防火牆、Proxy 或網站伺服器不支援而失效,又比 SignalR 來得輕巧。

之前寫過 ASPX 版 SSE 範例,新專案已改用 ASP.NET Core,做法不同。查了一下,發現 NuGet 套件 Lib.AspNetCore.ServerSentEvents,文件清楚且擴充彈性不錯,沒必要自己造輪子。

先看試做成果:

我建了一個 ASP.NET Core MVC 站台,首頁放了一顆鈕,按下會彈出 QR Code 網頁,10 秒內掃瞄 QR Code 呼叫指定網址,首頁會從 SSE 接獲通知確認掃瞄完成。為方便本機測試,我加了一顆鈕用 JavaScript 程式瀏覽指定 URL 模擬掃瞄 QR Code 動作。而等待掃瞄過程會計時,未在時限內完成將判定逾時。

Index.cshtml 寫法如下,原理是開啟 EventSource() 接收伺服器端 SSE 回應,新開啟的 QR Code 視窗引導使用者呼叫特定連結觸發從 SSE 連線回傳結果,正常由 onmessage 事件接收、逾時或出錯則由 onerror 事件接收:

@{
    ViewData["Title"] = "Home Page";
}

<div class="text-center">
    <h1 id="h" class="display-4">Welcome</h1>
    <button id="btn" onclick="showQRCode()">Scan QR Code</button>
</div>
<script>
    var qrCodeWin;
    var source = new EventSource('/sse/@ViewBag.Token');
    function showResult(msg, error) {
        qrCodeWin && qrCodeWin.close();
        document.getElementById('btn').remove();
        let h = document.getElementById('h');
        h.innerText = msg;
        if (error) h.style.color = 'red';
    }
    source.onmessage = function (event) {
        showResult(event.data);
    };
    source.onerror = function (event) {
        showResult(event.data, true);
    };
    function showQRCode() {
        qrCodeWin = window.open('@Url.Content($"~/Home/QRCode/{ViewBag.Token}")', '_blank', 'width=300,height=300');
    }
</script>

初步測試成功,接著改寫成模仿手機掃 QR Code 的登入流程。在我 Home 加一個 Test 方法,/Views/Home/Test.cshtml 如下,直接在頁面顯示 QR Code,QR Code 指定連結被呼叫後由 SSE 傳來通知,網頁導向測試成功畫面:

@{
}
<div class="text-center">
    <img id="qrc" src="@ViewBag.QRCodePng" alt="QR Code" />
    <div id="msg">
        Timeout: <span id="t">@ViewBag.TimeoutSecs</span>s
    </div>
</div>
<script>
    var source = new EventSource('/sse/@ViewBag.Token');

    source.onmessage = function (event) {
        location.href = '@Url.Action("Succ", "Home")';
    };
    source.onerror = function (event) {
        document.getElementById('qrc').remove();
        document.getElementById('msg').innerText = event.data;
    };
    var timeout = @ViewBag.TimeoutSecs;
    var h = setInterval(function () {
        timeout--;
        document.getElementById('t').innerText = timeout;
        if (timeout == 0) clearInterval(h);
    }, 1000);
</script>

將網站丟上 Azure App Service 用手機實測也成功:

手機操作展示

以上範例中,以手機掃瞄並瀏覽 QR Code 內含網址判定成功,改為 App 使用手機實體保存的金鑰對隨機產生內容做數位簽章,將可達到專業水準的安全防護,有機會做出土砲版多重因素驗證或免密碼登入。

簡單說一下伺服器端程式寫法。SSE 部分主要靠 Lib.AspNetCore.ServerSentEvents 程式庫處理,Program.cs 有以下幾個地方要改:(中文註解處)

using Lib.AspNetCore.ServerSentEvents;
using sse_notify.Models;

var builder = WebApplication.CreateBuilder(args);

// 註冊 SSE 服務
builder.Services.AddServerSentEvents();
// 改由 URL 包含的 Guid 取得 ClientId
builder.Services.AddSingleton<IServerSentEventsClientIdProvider, SseClientIdFromPathProvider>();
// 自訂一個繼承 ServerSentEventsService 及實作 IServerSentEventsService 的類別處理通知
// 使用程式庫提供的 AddServerSentEvents 擴充方法註冊
builder.Services.AddServerSentEvents<SseNotifyService, InProcSseNotifyService>(options =>
{
    // 程式庫提供 KeepAlive 功能
    options.KeepaliveMode = ServerSentEventsKeepaliveMode.Always;
    options.KeepaliveInterval = 15;
});


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

var app = builder.Build();

app.UseStaticFiles();

app.UseRouting();

app.UseAuthorization();

// 定義 SSE 對映的服務及路由及型別,每次等待的掃瞄操作產生隨機 GUID 識別
app.MapServerSentEvents<InProcSseNotifyService>("/sse/{regex(^[=0-9a-z].+)$)}");

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

app.Run();

自訂類別要繼承 ServerSentEventsService,寫法可參考 API 文件,以下是我的做法。QR Code 掃瞄執行結果打算用訂閱與發布設計模式,測試開發階段靠記憶體中的 Dictionary、IMemoryCache 交換資料;在負載平衡架構會有多台並行,則需要分散式的訂閱發布架構,可使用 RedisRabbitMQ 之類的解決方案。為此,我宣告了 SseNotifyService 抽象型別,先做了 InProcessSseNotifyService 用 IMemoryCache 簡單搞定,正式運轉如有需要再改用 Redis 或其他 MQ 機制實作。

using Lib.AspNetCore.ServerSentEvents;
using Microsoft.Extensions.Options;

namespace sse_notify.Models
{
    // https://tpeczek.github.io/Lib.AspNetCore.ServerSentEvents/articles/getting-started.html
    public abstract class SseNotifyService : ServerSentEventsService, IServerSentEventsService
    {

        public SseNotifyService(IOptions<ServerSentEventsServiceOptions<ServerSentEventsService>> options) :
            base(options.ToBaseServerSentEventsServiceOptions<ServerSentEventsService>())
        {
        }

        public abstract void Subscribe(Guid token, int timeoutSecs = 300);

        public async Task SendEventAsync(Guid token, string type, string message)
        {
            var client = this.GetClients().SingleOrDefault(o =>
                o.Id == token);
            if (client != null)
            {
                await client.SendEventAsync(new ServerSentEvent()
                {
                    Type = type,
                    Data = new List<string> { message }
                });
            }
        }

        public abstract Task Notify(Guid token, string message);
    }
    
    public class InProcSseNotifyService : SseNotifyService, IServerSentEventsService
    {
        private readonly IMemoryCache _cache;
    
        public InProcSseNotifyService(IOptions<ServerSentEventsServiceOptions<ServerSentEventsService>> options, 
            IMemoryCache cache) :
            base(options.ToBaseServerSentEventsServiceOptions<ServerSentEventsService>())
        {
            _cache = cache;
        }
    
        public override void Subscribe(Guid token, int timeoutSecs = 300)
        {
            var key = $"S:{token}";
            var semaphore = new SemaphoreSlim(0);
            var timeout = TimeSpan.FromSeconds(timeoutSecs);
            _cache.Set(key, semaphore, timeout);
            var task = Task.Factory.StartNew(async () =>
            {
                //wait for semaphore to be released
                if (!await semaphore.WaitAsync(TimeSpan.FromSeconds(timeoutSecs)))
                    await SendEventAsync(token, "error", "Timeout");
                //try get response
                else if (!_cache.TryGetValue<string>($"R:{token}", out var res))
                    await SendEventAsync(token, "error", "No response");
                else
                    await SendEventAsync(token, "message", res);
            });
    
        }
    
        public async override Task Notify(Guid token, string message)
        {
            var key = $"S:{token}";
            if (!_cache.TryGetValue(key, out SemaphoreSlim semaphore))
                throw new ApplicationException("Token not found");
            _cache.Set("R:" + token.ToString(), message);
            semaphore.Release();
            _cache.Remove(key);
        }
    }
}

SSE 程式庫識別客戶端連線的預設做法是從 HttpContext.User.Identity 抓使用者身分,若使用者有多條 SSE 連線,每條連線都傳訊息也無妨。但我的應用情境,每次顯示 QR Code 為獨立傳輸通道,會以 GUID 識別,故要自訂識別 Client Id 邏輯。做法是寫個自訂類別實作 IServerSentEventsClientIdProvider,在 Program.cs DI 註冊成 Singleton:

public class SseClientIdFromPathProvider : IServerSentEventsClientIdProvider
{
    public Guid AcquireClientId(HttpContext context)
    {
        var path = context.Request.Path.Value;
        var m = Regex.Match(path, @"(?i)/(?<g>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$");
        if (m.Success) return Guid.Parse(m.Groups["g"].Value);
        return Guid.NewGuid();
    }

    public void ReleaseClientId(Guid clientId, HttpContext context) { }
}

瑣碎細節還不少,就不一一介紹了。範例專案已放上 Github,大家如有興趣再下載回去研究。

ASP.NET Core example project using SSE to get notification when QR code is scanned by phone.


Comments

Be the first to post a comment

Post a comment