顯示具有 ASP.NET 標籤的文章。 顯示所有文章
顯示具有 ASP.NET 標籤的文章。 顯示所有文章

2019年9月9日 星期一

ASP.NET MVC 非同步程式設計,發生無法更新 ViewBag 的問題探討

ASP.NET MVC 非同步程式設計,發生無法更新 ViewBag 的問題探討

當在 ASP.NET MVC 專案下進行非同步程式設計的時候,相信很多人會遇到一些詭異的事情,這是因為程式碼使用非同不方式進行設計,這與傳統的同步方式設計產生了比較難以除錯的現象,想要徹底了解這些問題是如何產生的以及日後可以使用正確的方式來解決此一問題,就需要了解隱藏在背後的運作原理。

了解更多關於 [使用 async 和 await 進行非同步程式設計] 的使用方式


在這裡請先使用 ASP.NET MVC 專案樣本建立起一個 ASP.NET MVC 的專案,接著,打開 HomeController.cs 檔案,找到 Index() 這個 Action,將其方法使用底下程式碼來替換;在這裡個程式碼中,將會使用 WebClient 這個類別所產生的執行個體,進行非同步的遠端 Web 呼叫 ,這裡將會透過 wc.DownloadStringAsync() 方法呼叫來啟動非同步執行作業,而該 URL 將會把 99 與 87 進行加總計算,並請等候 2 秒鐘之後,才會結束這次的非同步作業。
當 WebClient 完成非同步作業之後,將會觸發 DownloadStringCompleted 事件,在底下的程式碼中,將會使用 Lambda 匿名委派方法來訂閱該事件,所以,便會執行該 Lambda 內的程式碼,這裡將會看到會執行 ViewBag.Name = "OK" + e.Result; 這個敘述,這個 ViewBag.Name 將會在這個 View 中顯示出來。
為了避免這個 WebClient 非同步作業尚未結束之前,就直接呼叫 return View(); 敘述,把這個 Action 相對應的 View 顯示出來,所以在呼叫 wc.DownloadStringAsync() 方法之後,會採用輪詢的方式來檢查 WebClient 的非同步作業是否已經完成了,若沒有完成,將會每 30 ms 進行輪詢檢查 WebClient.IsBusy 這個屬性值。
C Sharp / C#
public async Task<ActionResult> Index()
{
    WebClient wc = new WebClient();

    wc.DownloadStringCompleted += (s, e) =>
    {
        ViewBag.Name = "OK" + e.Result;
    };
    wc.DownloadStringAsync(new Uri("https://lobworkshop.azurewebsites.net/api/RemoteSource/Add/99/87/2"));
    while (wc.IsBusy)
    {
        Thread.Sleep(30);
    }
    return View();
}
另外,請打開這個 Action 相對應的 View,也就是 Index.cshtml 這個檔案,將 <p>@Html.Raw(ViewBag.Name)</p> 標記宣告內容,加入此 Index.cshtml 檔案內
HTML
<div class="jumbotron">
    <h1>ASP.NET</h1>
    <p>@Html.Raw(ViewBag.Name)</p>
    <p class="lead">ASP.NET is a free web framework for building great Web sites and Web applications using HTML, CSS and JavaScript.</p>
    <p><a href="https://asp.net" class="btn btn-primary btn-lg">Learn more &raquo;</a></p>
</div>

開始執行有問題的專案

現在請執行這個 ASP.NET MVC 專案,當首頁顯示在瀏覽器之後,會發現到並沒有看到 ViewBag.Name 顯示在螢幕上。

了解發生了甚麼問題

若針對這個 Index 方法設定中斷點,並且進行除錯,你將會發現到當 WebClient 完成非同步作業的時候,wc.IsBusy 的屬性值將會為 false,這代表了這個無窮迴圈將會停止執行,接下來將會直接執行 return View() 敘述,這樣就會直接顯示該 View 的內容。
可是,這個時候 WebClient 執行個體才要準備進行執行 DownloadStringCompleted 所訂閱的事件方法,並且更新 ViewBag 的內容,可是,這個時候該系統已經正在顯示完成 View 的網頁內容,理所當然是看不到該網頁上有 ViewBag.Name 的內容顯示出來。

暫緩一段時間才要來執行 return View()

既然 WebClient 的完成 callback 要呼叫之前,就已經執行了 return View() 敘述,而造成問題,此時,這個非同步 Web API 的呼叫大約需要2秒鐘的時間,因此,將程式碼修改成為底下的內容,應該就不會有問題了吧。
這裡在無窮迴圈之後,加入這個敘述 Thread.Sleep(3000); 也就是強制讓這個 HTTP 請求執行緒 (HTTP Request Thread) 休息 3 秒鐘的時間,讓 WebClient 可以有足夠的時間來觸發其 DownloadStringCompleted 事件的委派方法,並且將 ViewBag.Name 的物件值設定進去,之後,才會執行 return View() 敘述,讓 ASP.NET MVC 顯示出 View 的內容,這樣總沒有問題了吧。
可是,當實際再度執行這個專案的時候,卻發現,不論 Thread.Sleep 的休息時間值設定為多少,都不會正常將 WebClient 呼叫 API 取得的結果顯在螢幕上,並且當休息時間設定越久,將會造成整個網頁要顯示出來的時間也越久。這到底是哪裡出了問題呢?
C Sharp / C#
public async Task<ActionResult> Index()
{
    WebClient wc = new WebClient();

    wc.DownloadStringCompleted += (s, e) =>
    {
        ViewBag.Name = "OK" + e.Result;
    };
    wc.DownloadStringAsync(new Uri("https://lobworkshop.azurewebsites.net" +
             $"/api/RemoteSource/Add/99/87/2"));
    while (wc.IsBusy)
    {
        Thread.Sleep(30);
    }

    Thread.Sleep(3000);
    return View();
}

了解 WebClient 的 DownloadStringCompleted 行為

其實,實際的原因那就是若呼叫 WebCleint.DownloadStringAsync 的時候,此時的執行緒(當然這個時候的執行緒是當時 HTTP Request 所配置使用的執行緒) 將會使用到 SynchronizationContext 這個技術,這裡可以在 呼叫 WebCleint.DownloadStringAsync 方法前來檢查 SynchronizationContext.Current 這個屬性值是否為空值,若為空值,則表示這個執行緒沒有使用到 SynchronizationContext。根據 ASP.NET MVC 開發框架預設的規劃,每次當使用者對該網站請求一個 HTTP Request 請求的時候,IIS 就會配置一個執行緒來執行此次的 HTTP Request 的請求,而在這個執行緒內將會產生出一個 SynchronizationContext 物件。
好,那這樣會有甚麼問題呢?
會產生這樣的問題那是因為 當 WebClient.DownloadStringCompleted 事件被觸發的時候,將不會直接使用一個新的執行緒來執行這個委派方法,而是因為呼叫 WebClient.DownloadStringAsync 方法的時候,因為偵測到這個執行緒內的 SynchronizationContext.Current 有配置一個物件,所以,會先把這個物件狀態儲存取來,當完成非同步作業之後,會取出 SynchronizationContext ,並且把訂閱事件的委派方法,傳送到 SynchronizationContext 內的工作單元 Unit of Work 內的佇列來排隊等候執行;而在 SynchronizationContext 當初設計的機制內,同一個時間僅會執行一個委派方法,然而,此時,該 HTTP Request 執行緒內因為還在執行,所以,就需要等到這個執行緒執行完畢之後,才會接著從 SynchronizationContext 工作單元佇列中,找到下一個要執行的委派方法,也就是 WebClient.DownloadStringCompleted 所指定的訂閱委派方法,接著來繼續執行。
所以,在剛剛所設計的程式碼中,使用了 Thread.Sleep(3000) 暫停了 3秒鐘的時間,這也代表了這個 HTTP Request 執行緒需要暫時停止 3 秒鐘的執行,當然,對於所訂閱的委派方法,也需要在 3 秒鐘之後才能夠繼續執行,不過,在休息 3秒鐘之後,就會直接執行 return View(); 敘述,當然,View 也就已經產生出來了,就算這個時候當初事件訂閱的委派發法開始要執行了,也不會影響到已經產生 View 網頁內容的結果了。

使用 WebClient TPL API 來替換掉 EAP 的事件 API

請查閱 WebClient API 文件,將會發現到 WebClient 有提供同樣呼叫 Web API 方法的 API,不過,使用的是 TPL 的方式,那就是 DownloadStringTaskAsync,這個方法將會回傳一個 Task 物件,此時,可以透過這個工作物件可以使用 await 運算子來等候呼叫 API 的回傳字串內容,如底下的程式碼所顯示的做法。
C Sharp / C#
public async Task<ActionResult> Index()
{
    WebClient wc = new WebClient();

    var task = wc.DownloadStringTaskAsync(new Uri("https://lobworkshop.azurewebsites.net" +
    $"/api/RemoteSource/Add/99/87/2"));
    string result = await task;
    ViewBag.Name = "OK" + result;

    return View();
}
當然,也可以使用底下的作法,當取得 WebClient 呼叫 Web API 的回傳的字串,是可以透過另外一個執行緒將回傳字串設定到 ViewBag.Name,這樣的作法將會與上面的做法得到相同的網頁內容。
C Sharp / C#
public async Task<ActionResult> Index()
{
    WebClient wc = new WebClient();

    var task = wc.DownloadStringTaskAsync(new Uri("https://lobworkshop.azurewebsites.net" +
    $"/api/RemoteSource/Add/99/87/2"));
    string result = await task;
    await Task.Run(() => ViewBag.Name = "OK" + result);

    return View();
}
然而,若將程式碼改寫成底下的方式,對於 task 這個工作物件,不是使用 await 運算子來等待,而是使用 Wait() 方法來進行封鎖式的等候,此時,將會看到悲慘的現象,那就是整個網頁凍結了,瀏覽器一直在轉圈圈,無法將網頁顯示出來,也就是不論等多久,都只會看到底下的畫面內容。
C Sharp / C#
public async Task<ActionResult> Index()
{
    WebClient wc = new WebClient();

    var task = wc.DownloadStringTaskAsync(new Uri("https://lobworkshop.azurewebsites.net" +
    $"/api/RemoteSource/Add/99/87/2"));
    task.Wait();
    string result = task.Result;
    ViewBag.Name = "OK" + result;

    return View();
}


了解更多關於 [使用 async 和 await 進行非同步程式設計] 的使用方式



2018年9月4日 星期二

ASP.NET Web API & JWT 使用者身分驗證練習

ASP.NET Web API & JWT 使用者身分驗證練習

至於要如何在使用端 Client 來進行使用者身分驗證,並且取得 JWT Token 權杖,接著使用該權杖來呼叫其他 Web API 的方法,可以參考 使用 HttpClient 進行 JWT 身分驗證與呼叫需要授權的 API 和重新更新 Token 權杖的程式設計範例 ;在這篇文章中,也包含了如何在用戶端進行更新 JWT Token 的作法。

這是一份關於 ASP.NET Web API 如何實作出使用者身分驗證與產生、驗證、使用存取權杖的練習說明文件。

了解更多關於 [使用 async 和 await 進行非同步程式設計] 的使用方式
了解更多關於 [Thread Class] 的使用方式
了解更多關於 [Task Class] 的使用方式


各位學員可以先依照這份文件中的說明步驟,逐一進行練習,了解如何設計出可以產生一個存取權杖的登入服務,並且使用該存取權杖,呼叫其他需要需要有通過使用者驗證才能夠使用的 Web API 服務。
我們會使用 PostMan 這個工具來協助我們進行相關 Web API 的測試工作。
這份文件將使用 Visual Studio 2017 工具來展示如何做到這樣的需求

01 建立 ASP.NET Web API 專案

我們首先需要建立一個 ASP.NET Web API (.NET Framework) 專案,接著,從無到有的接著設計出具有存取權杖的身分驗證才能夠存取的 Web API 服務。
  • 開啟 Visual Studio 2017 IDE
  • 從功能表點選 [檔案] > [新增] > [專案]
  • 在 [新增專案] 對話窗左半部,點選 [已安裝] > [Visual C#] > [Web]
  • 在這個對話的中間上方,選擇 [.NET Framework 4.6.1]
  • 接著選取 [ASP.NET Web 應用程式 (.NET Framework)]
  • 在下方 [名稱] 欄位,輸入 AuthToken
  • 在 [方案名稱] 欄位,輸入 AuthTokenSolution
  • 最後,點選右下方的 [確定] 按鈕
新增專案
  • 當出現 [新增 ASP.NET Web 應用程式 - AuthToken] 對話窗
  • 請選擇 [Web API]
  • 右半部的 [驗證] 請設定為 無驗證
  • 其他設定可以參考這個螢幕截圖
AddASPNETWeb
  • 執行這個 Web API 專案,就會看到這個畫面,那就表示您的 Web API 專案已經建立完成
第一次執行 Web API 專案
這個練習的完成專案,可以參考資料夾 [01 CreatProject]

02 ASP.NET Web API 專案之準備工作

在這裡,我們需要安裝 NuGet 套件與建立會用到的回傳資料模型和基本的登入與兩個 Web API服務。

安裝 JWT.NET 套件

  • 滑鼠右擊 [參考] 專案節點,選擇 [管理 NuGet 套件]
  • 在 [NuGet: AuthToken] 視窗中,點選 [瀏覽] 標籤頁次
  • 在 [搜尋] 文字輸入盒內,輸入 JWT.NET,搜尋這個套件
  • 勾選 [包含搶鮮版] 檢查盒,安裝 4.0 以上的版本
JWT.NET
NuGet Preview

建立 Web API 回傳資料模型

  • 滑鼠右擊 [Models] 資料夾,選取 [加入] > [類別]
  • 在 [新增項目] 對話窗中,在下方的 [名稱] 欄位中,輸入 APIResult
  • 點選 [新增] 按鈕,完成新增這個類別檔案
  • 將這個新產生的類別 APIResult 以底下程式碼替換
C Sharp / C#
/// <summary>
/// 呼叫 API 回傳的制式格式
/// </summary>
public class APIResult
{
    /// <summary>
    /// 此次呼叫 API 是否成功
    /// </summary>
    public bool Success { get; set; } = true;
    /// <summary>
    /// 呼叫 API 失敗的錯誤訊息
    /// </summary>
    public string Message { get; set; } = "";
    /// <summary>
    /// 呼叫此API所得到的其他內容
    /// </summary>
    public object Payload { get; set; }
}

建立全域常數類別

  • 滑鼠右擊專案節點 [AuthToken],選擇 [新增資料夾]
  • 在新增的資料夾位置,輸入 Helpers 資料夾
  • 滑鼠右擊 [Helpers] 資料夾,選取 [加入] > [類別]
  • 在 [新增項目] 對話窗中,在下方的 [名稱] 欄位中,輸入 MainHelper
  • 點選 [新增] 按鈕,完成新增這個類別檔案
  • 將這個新產生的類別 MainHelper 以底下程式碼替換
C Sharp / C#
public class MainHelper
{
    // 這裡是要進行雜湊簽章會用到的金鑰
    public const string SecretKey = "GQDstcKsx0NHjPOuXOYg5MbeJ1XT0uFiwDVvVBrk";
}

建立登入驗證 Controllers

  • 滑鼠右擊 [Controllers] 資料夾,選取 [加入] > [控制器]
  • 在 [新增 Scaffold] 對話窗中,選擇 [Web API2 控制器 - 空白]
  • 點選右下方的 [新增] 按鈕
New Scaffold
  • 當出現 [加入控制器] 對話窗後,於 [控制器名稱] 欄位,輸入 Login,使得我們要產生的控制器名稱為 LoginController
  • 點選右下方的 [加入] 按鈕
New Controller
  • 將這個新產生的控制器 LoginController 以底下程式碼替換
    這裡尚未做任何使用者帳號與密碼的身分驗證程式碼
C Sharp / C#
using AuthToken.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Web.Http;

namespace AuthToken.Controllers
{
    public class LoginController : ApiController
    {
        [HttpGet]
        public APIResult Get()
        {
            return new APIResult()
            {
                Success = true,
                Message = "",
                Payload = "Access Token"
            };
        }
    }
}

建立無須驗證便可存取的 Controllers

  • 滑鼠右擊 [Controllers] 資料夾,選取 [加入] > [控制器]
  • 在 [新增 Scaffold] 對話窗中,選擇 [Web API2 控制器 - 空白]
  • 點選右下方的 [新增] 按鈕
  • 當出現 [加入控制器] 對話窗後,於 [控制器名稱] 欄位,輸入 NoAuth,使得我們要產生的控制器名稱為 NoAuthController
  • 點選右下方的 [加入] 按鈕
  • 將這個新產生的控制器 NoAuthController 以底下程式碼替換
C Sharp / C#
using AuthToken.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Web.Http;

namespace AuthToken.Controllers
{
    public class NoAuthController : ApiController
    {
        [HttpGet]
        public APIResult Get()
        {
            return new APIResult()
            {
                Success = true,
                Message = "不需要提供存取權杖 Access Token 就能使用的 API",
                Payload = new string[] { "無須存取權杖1", "無須存取權杖2" }
            };
        }
    }
}

建立需要通過身分驗證便可存取的 Controllers

  • 滑鼠右擊 [Controllers] 資料夾,選取 [加入] > [控制器]
  • 在 [新增 Scaffold] 對話窗中,選擇 [Web API2 控制器 - 空白]
  • 點選右下方的 [新增] 按鈕
  • 當出現 [加入控制器] 對話窗後,於 [控制器名稱] 欄位,輸入 NeedAuth,使得我們要產生的控制器名稱為 NeedAuthController
  • 點選右下方的 [加入] 按鈕
  • 將這個新產生的控制器 NeedAuthController 以底下程式碼替換
    這裡尚未進行存取權杖的驗證與取回權杖內的資料程式碼
C Sharp / C#
using AuthToken.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Web.Http;

namespace AuthToken.Controllers
{
    public class NeedAuthController : ApiController
    {
        [HttpGet]
        public APIResult Get()
        {
            return new APIResult()
            {
                Success = true,
                Message = "需要提供存取權杖 Access Token 才能使用的 API",
                Payload = new string[] { "有提供存取權杖1", "有提供存取權杖2" }
            };
        }
    }
}

進行驗證專案可以正常運作

  • 請建置該專案,確認沒有錯誤產生
  • 執行該專案
    您專案的開啟網站的埠 (Port) 可能會與這個專案不同,請自行修改 Port 號碼成為您專案用的 Port 號碼
  • 開啟 PostMan 工具,選擇 Http 方法為 GET
  • 在 URL 位址輸入 http://localhost:54891/api/Login
  • 確認得到底下結果
JSON
{
    "Success": true,
    "Message": "",
    "Payload": "Access Token"
}
  • 在 URL 位址輸入 http://localhost:54891/api/NoAuth
  • 確認得到底下結果
JSON
{
    "Success": true,
    "Message": "不需要提供存取權杖 Access Token 就能使用的 API",
    "Payload": [
        "無須存取權杖1",
        "無須存取權杖2"
    ]
}
  • 在 URL 位址輸入 http://localhost:54891/api/needAuth
  • 確認得到底下結果
JSON
{
    "Success": true,
    "Message": "需要提供存取權杖 Access Token 才能使用的 API",
    "Payload": [
        "有提供存取權杖1",
        "有提供存取權杖2"
    ]
}
這個練習的完成專案,可以參考資料夾 [02 Setup]

03 設計HTTP基本認證,並回傳有效存取權杖

在這裡,我們將會繼續剛剛完成的專案,設計 LoginController 可以進行 HTTP基本認證,相關 HTTP基本認證 說明,可以參考該連結
  • 在 LoginController.cs 檔案中,加入底下命名空間參考
C Sharp / C#
using System.Net.Http.Formatting;
using System.Net.Http.Headers;
using System.Text;
  • 將 LoginController 類別,修正成為底下程式碼
C Sharp / C#
public class LoginController : ApiController
{
    HttpResponseMessage response;
    public string Account { get; set; }
    public string Password { get; set; }
    [HttpGet]
    public HttpResponseMessage Get()
    {
        if (CanHandleAuthentication(this.Request) == true)
        {
            #region 檢查帳號與密碼是否正確
            // 這裡可以修改成為與後端資料庫內的使用者資料表進行比對
            var expectAccount = "vulcan";
            var expectPassword = "123abc";
            if (expectAccount == Account &&
                expectPassword == Password)
            {
                // 帳號與密碼比對正確,回傳帳密比對正確
                response = this.Request.CreateResponse<APIResult>(HttpStatusCode.OK, new APIResult()
                {
                    Success = true,
                    Message = $"帳號:{Account} / 密碼:{Password}",
                    Payload = "Access Token"
                });
            }
            else
            {
                // 帳號與密碼比對不正確,回傳帳密比對不正確
                response = this.Request.CreateResponse<APIResult>(HttpStatusCode.Unauthorized, new APIResult()
                {
                    Success = false,
                    Message = $"",
                    Payload = "帳號或密碼不正確"
                });
            }
            #endregion
            return response;
        }
        else
        {
            // 沒有收到正確格式的 Authorization 內容,回傳無法驗證訊息
            response = this.Request.CreateResponse<APIResult>(HttpStatusCode.Unauthorized, new APIResult()
            {
                Success = false,
                Message = $"",
                Payload = "沒有收到帳號與密碼"
            });
            return response;
        }
    }

    /// <summary>
    /// 檢查與解析 Authorization 標頭是否存在與解析用戶端傳送過來的帳號與密碼
    /// </summary>
    /// <param name="request"></param>
    /// <returns></returns>
    public bool CanHandleAuthentication(HttpRequestMessage request)
    {
        // 驗證結果是否正確
        bool isSuccess = false;

        #region 檢查是否有使用 Authorization: Basic 傳送帳號與密碼到 Web API 伺服器
        if ((request.Headers != null
                && request.Headers.Authorization != null
                && request.Headers.Authorization.Scheme.ToLowerInvariant() == "basic"))
        {
            #region 取出帳號與密碼,帳號與密碼格式為 帳號:密碼
            var authHeader = request.Headers.Authorization;

            // 取出有 Base64 編碼的帳號與密碼
            var encodedCredentials = authHeader.Parameter;
            // 進行 Base64 解碼
            var credentialBytes = Convert.FromBase64String(encodedCredentials);
            // 取得 .NET 字串
            var credentials = Encoding.ASCII.GetString(credentialBytes);
            // 判斷格式是否正確
            var credentialParts = credentials.Split(':');

            if (credentialParts.Length == 2)
            {
                // 取出使用者傳送過來的帳號與密碼
                Account = credentialParts[0];
                Password = credentialParts[1];
                isSuccess = true;
            }

            #endregion
        }
        #endregion
        return isSuccess;
    }
}

使用 PostMan 來測試登入 API 是否可以正常運作

  • 打開 PostMan 工具
  • 使用這個工具,將帳號與密碼產生出基本認證的標頭;我們使用這個工具,設定帳號為 vulcan / 密碼為 123abc,得到底下標頭內容
coding
Authorization: Basic dnVsY2FuOjEyM2FiYw==
Basic Authentication Header Generator
  • 設定 PostMand 的 Http 方法為 GET,增加一個 Authorization 標頭,其值為 Basic dnVsY2FuOjEyM2FiYw==,如下圖所示,並請點選右上方的 [Send] 按鈕,將會得到底下結果:
JSON
{
    "Success": true,
    "Message": "帳號:vulcan / 密碼:123abc",
    "Payload": "Access Token"
}
使用帳號與密碼正確的測試
  • 產生出不正確的帳號或密碼的標頭,底下的標頭之帳號為 vulcan / 密碼為 123,如下所示,進行測試
coding
Authorization: Basic dnVsY2FuOjEyMw==
  • 設定 PostMand 的 Http 方法為 GET,增加一個 Authorization 標頭,其值為 Basic dnVsY2FuOjEyMw==,如下圖所示,並請點選右上方的 [Send] 按鈕,將會得到底下結果:
JSON
{
    "Success": false,
    "Message": "",
    "Payload": "帳號或密碼不正確"
}
使用帳號與密碼不正確的測試
  • 接著,不要設定 Authorization 標頭,進行測試,將會得到底下結果:
JSON
{
    "Success": false,
    "Message": "",
    "Payload": "沒有收到帳號與密碼"
}
沒有使用 Authorization 標頭的測試

產生使用者的存取權杖 Access Token

  • 在 LoginController.cs 檔案中,加入底下命名空間參考
C Sharp / C#
using AuthToken.Helpers;
using JWT;
using JWT.Algorithms;
using JWT.Builder;
  • 找到這行註解 // 帳號與密碼比對正確,回傳帳密比對正確,在這行註解的上方,加入底下程式碼
C Sharp / C#
#region 產生這次通過身分驗證的存取權杖 Access Token
string secretKey = MainHelper.SecretKey;
#region 設定該存取權杖的有效期限
IDateTimeProvider provider = new UtcDateTimeProvider();
// 這個 Access Token只有一個小時有效
var now = provider.GetNow().AddHours(1);
var unixEpoch = UnixEpoch.Value; // 1970-01-01 00:00:00 UTC
var secondsSinceEpoch = Math.Round((now - unixEpoch).TotalSeconds);
#endregion

var jwtToken = new JwtBuilder()
      .WithAlgorithm(new HMACSHA256Algorithm())
      .WithSecret(secretKey)
      .AddClaim("iss", Account)
      .AddClaim("exp", secondsSinceEpoch)
      .AddClaim("role", new string[] { "Manager", "People" })
      .Build();
#endregion
  • 找到這行註解 // 帳號與密碼比對正確,回傳帳密比對正確,在這行註解的下方,將這行程式碼 this.Request.CreateResponse,修改成為底下程式碼,這樣,當使用者傳入一個合法的帳號與密碼,就可以回傳這次身分驗證的存取權杖
C Sharp / C#
response = this.Request.CreateResponse<APIResult>(HttpStatusCode.OK, new APIResult()
{
    Success = true,
    Message = $"帳號:{Account} / 密碼:{Password}",
    Payload = $"{jwtToken}"
});

使用 PostMan 來測試登入 API 是否可以回傳存取權杖 Access Token

  • 打開 PostMan 工具
  • 使用這個工具,將帳號與密碼產生出基本認證的標頭;我們使用這個工具,設定帳號為 vulcan / 密碼為 123abc,得到底下標頭內容
coding
Authorization: Basic dnVsY2FuOjEyM2FiYw==
Basic Authentication Header Generator
  • 設定 PostMand 的 Http 方法為 GET,增加一個 Authorization 標頭,其值為 Basic dnVsY2FuOjEyM2FiYw==,如下圖所示,並請點選右上方的 [Send] 按鈕,將會得到底下結果:
JSON
{
    "Success": true,
    "Message": "帳號:vulcan / 密碼:123abc",
    "Payload": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ2dWxjYW4iLCJleHAiOjE1MTY5NTg4NzUuMCwicm9sZSI6WyJNYW5hZ2VyIiwiUGVvcGxlIl19.cHAktZhijjdbbP5mhb1ICwGdN1GwUrfAyPObq3ZMi-4"
}
其中,這個字串
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ2dWxjYW4iLCJleHAiOjE1MTY5NTg4NzUuMCwicm9sZSI6WyJNYW5hZ2VyIiwiUGVvcGxlIl19.cHAktZhijjdbbP5mhb1ICwGdN1GwUrfAyPObq3ZMi-4
就是存取權杖,日後要存取需要通過身分驗證的 API 的時候,只需要提供這個存取權杖,後端 Web API就會知道,這次呼叫 Web API是由哪個使用者來呼叫的。
使用帳號與密碼正確的測試
這個練習的完成專案,可以參考資料夾 [03 BasicAuth]

04 設計 Web API 需要有存取權杖,才能夠存取這些API服務

首先,我們來練習,如何在用戶端的專案內,使用 HttpClient 抓取到存取權杖,接著使用不同情境的存取權杖,學習如何將存取權杖傳送到後端 Web API 專案上,接著,驗證與分析這個存取權證是否正確,並回傳 Web API 的執行結果。

如何撰寫用戶端的程式碼,取得存取權杖

  • 滑鼠右擊方案 [AuthTokenSolution],選擇 [加入] > [新增專案]
  • 在 [新增專案] 對話窗左半部,點選 [已安裝] > [Visual C#]
  • 在這個對話的中間上方,選擇 [.NET Framework 4.6.1]
  • 接著選取 [主控台應用程式 (.NET Framework)]
  • 在下方 [名稱] 欄位,輸入 GetAccessToken
  • 最後,點選右下方的 [確定] 按鈕
  • 滑鼠右擊主控台應用程式 GetAccessToken 的參考節點
  • 選擇 [管理 NuGet 套件]
  • 在 [NuGet: GetAccessToken] 視窗中,點選 [瀏覽] 標籤頁次
  • 在 [搜尋] 文字輸入盒內,輸入 JSON.NET,搜尋這個套件
  • 取消勾選 [包含搶鮮版] 檢查盒,選擇第一個套件 Newtonsoft.Json
  • 點選 [安裝] 按鈕
  • 將 GetAccessToken 專案內的 Program.cs 檔案內容,使用底下程式碼替換
C Sharp / C#
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;

namespace GetAccessToken
{
    class Program
    {
        static void Main(string[] args)
        {
            var TARGETURL = "http://localhost:54891/api/Login";

            using (HttpClientHandler handler = new HttpClientHandler())
            {
                using (HttpClient client = new HttpClient(handler))
                {
                    var byteArray = Encoding.ASCII.GetBytes("vulcan:123abc");
                    client.DefaultRequestHeaders.Authorization = 
                        new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", 
                        Convert.ToBase64String(byteArray));

                    HttpResponseMessage response = client.GetAsync(TARGETURL).Result;
                    HttpContent content = response.Content;
                    Console.WriteLine("Response StatusCode: " + (int)response.StatusCode);
                    string result =  content.ReadAsStringAsync().Result;
                    var fooAPIResult = Newtonsoft.Json.JsonConvert.DeserializeObject<APIResult>(result);
                    Console.WriteLine($"存取權杖為 : {fooAPIResult.Payload}");
                    Console.WriteLine("Press any key for continuing...");
                    Console.ReadKey();
                }
            };

        }
    }

    /// <summary>
    /// 呼叫 API 回傳的制式格式
    /// </summary>
    public class APIResult
    {
        /// <summary>
        /// 此次呼叫 API 是否成功
        /// </summary>
        public bool Success { get; set; } = true;
        /// <summary>
        /// 呼叫 API 失敗的錯誤訊息
        /// </summary>
        public string Message { get; set; } = "";
        /// <summary>
        /// 呼叫此API所得到的其他內容
        /// </summary>
        public object Payload { get; set; }
    }

}
  • 此時,您的方案內共有兩個專案,預設起始專案應該為 AuthToken
  • 按下 [F5] 按鍵,開始進行 Web API 專案進行除錯
  • 滑鼠右擊專案 GetAccessToken,點選 [偵錯] > [開始新執行個體]
  • 此時,會顯示一個命令提示字元視窗,如下圖,您會看到,我們已經把存取權杖顯示在螢幕上。
取得存取權杖
  • 請把這個存取權杖複製到剪貼簿內。
  • 開啟 jwt.io 網頁
  • 在 Encoded 區域,將這個存取權杖貼到這裡,此時,您會看到右半部將會出現解碼後的這個存取權杖詳細內容。
存取權杖解碼

建立檢查是否存在合法的存取權杖

  • 滑鼠右擊專案 [AuthToken],選擇 [加入] > [新增資料夾]
  • 在新增的資料夾位置,輸入 Filters 資料夾
  • 滑鼠右擊 [Filters] 資料夾,選取 [加入] > [類別]
  • 在 [新增項目] 對話窗中,在下方的 [名稱] 欄位中,輸入 JwtAuthAttribute
  • 點選 [新增] 按鈕,完成新增這個類別檔案
  • 將這個新產生的類別 JwtAuthAttribute 以底下程式碼替換
C Sharp / C#
using AuthToken.Helpers;
using AuthToken.Models;
using JWT;
using JWT.Algorithms;
using JWT.Builder;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Formatting;
using System.Security.Principal;
using System.Threading;
using System.Web;
using System.Web.Http;
using System.Web.Http.Controllers;
using System.Web.Http.Filters;


namespace AuthToken.Filters
{
    public class JwtAuthAttribute : AuthorizeAttribute
    {
        public string ErrorMessage { get; set; } = "";
        protected override void HandleUnauthorizedRequest(HttpActionContext actionContext)
        {
            if (string.IsNullOrEmpty(ErrorMessage) == false)
            {
                setErrorResponse(actionContext, ErrorMessage);
            }
            else
            {
                base.HandleUnauthorizedRequest(actionContext);
            }
        }

        public override void OnAuthorization(HttpActionContext actionContext)
        {
            // TODO: key應該移至config
            if (actionContext.Request.Headers.Authorization == null || actionContext.Request.Headers.Authorization.Scheme != "Bearer")
            {
                setErrorResponse(actionContext, "沒有看到存取權杖錯誤");
            }
            else
            {
                try
                {
                    #region 進行存取權杖的解碼
                    string secretKey = MainHelper.SecretKey;
                    var json = new JwtBuilder()
                        .WithAlgorithm(new HMACSHA256Algorithm())
                        .WithSecret(secretKey)
                        .MustVerifySignature()
                        .Decode<Dictionary<string, object>>(actionContext.Request.Headers.Authorization.Parameter);
                    #endregion

                    #region 將存取權杖所夾帶的內容取出來
                    var fooRole = json["role"] as Newtonsoft.Json.Linq.JArray;
                    var fooRoleList = fooRole.Select(x => (string)x).ToList<string>();
                    #endregion

                    #region 將存取權杖的夾帶欄位,儲存到 HTTP 要求的屬性
                    actionContext.Request.Properties.Add("user", json["iss"] as string);
                    actionContext.Request.Properties.Add("role", fooRoleList);
                    #endregion

                    #region 設定目前 HTTP 要求的安全性資訊
                    var fooPrincipal =
                        new GenericPrincipal(new GenericIdentity(json["iss"] as string, "MyPassport"), fooRoleList.ToArray());
                    if (HttpContext.Current != null)
                    {
                        HttpContext.Current.User = fooPrincipal;
                    }
                    #endregion

                    #region 角色權限檢查(檢查控制器或動作之屬性(Attribute上設的 Roles的設定內容)
                    if (string.IsNullOrEmpty(Roles) == false)
                    {
                        // 是否有找到匹配的角色設定
                        bool fooCheckRoleResult = false;
                        // 切割成為多個角色成員
                        var fooConditionRoles = Roles.Split(',');
                        // 逐一檢查,這個使用用者是否有在這個角色條件中
                        foreach (var item in fooConditionRoles)
                        {
                            var fooInRole = fooPrincipal.IsInRole(item.Trim());
                            if (fooInRole == true)
                            {
                                fooCheckRoleResult = true;
                                break;
                            }
                        }

                        if(fooCheckRoleResult == false)
                        {
                            setErrorResponse(actionContext, "無效的角色設定,沒有權限使用這個 API");
                        }
                    }
                    #endregion

                }
                catch (TokenExpiredException)
                {
                    setErrorResponse(actionContext, "權杖已經逾期");
                }
                catch (SignatureVerificationException)
                {
                    setErrorResponse(actionContext, "權杖似乎不正確,沒有正確的數位簽名");
                }
                catch (Exception ex)
                {
                    setErrorResponse(actionContext, $"權杖解析發生異常 : {ex.Message}");
                }
            }

            base.OnAuthorization(actionContext);
        }

        private void setErrorResponse(HttpActionContext actionContext, string message)
        {
            ErrorMessage = message;
            var response = actionContext.Request.CreateErrorResponse(HttpStatusCode.Unauthorized, message);
            response.Content = new ObjectContent<APIResult>(new APIResult()
            {
                Success = false,
                Message = ErrorMessage,
                Payload = null
            }, new JsonMediaTypeFormatter());
            actionContext.Response = response;
        }
    }
}
  • 現在,只要把這個 JwtAuthAttribute 屬性,套用到相關的控制器 (Controller) 或者動作 (Action) 方法上,因此,當要存取這些控制器或者動作的時候,就必須要提供有效存取權杖,方能呼叫這些 Web API,否則,會有錯誤回報。
    現在,我們打開這個 NeedAuthController.cs 檔案,將類別 NeedAuthController 替換成為底下程式碼。
C Sharp / C#
[JwtAuth]
public class NeedAuthController : ApiController
{
    [HttpGet]
    public APIResult Get()
    {
        var fooUser = Request.Properties["user"] as string;

        return new APIResult()
        {
            Success = true,
            Message = $"授權使用者為 {fooUser}",
            Payload = new string[] { "有提供存取權杖1", "有提供存取權杖2" }
        };
    }
}
  • 現在,執行這個 Web API 專案,並且使用 PostMan 來存取這個網址 http://localhost:54891/api/needAuth,但是,看到底下的回傳內容,這表示,當要存取這個 NeedAuthWeb API 的時候,我們需要提供有效的存取權杖。
JSON
{
    "Success": false,
    "Message": "沒有看到存取權杖錯誤",
    "Payload": null
}
JSON
{
    "Success": true,
    "Message": "帳號:vulcan / 密碼:123abc",
    "Payload": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ2dWxjYW4iLCJleHAiOjE1MTY5Nzk5MzUuMCwicm9sZSI6WyJNYW5hZ2VyIiwiUGVvcGxlIl19.4iGEoBA0SoCiSTBXdAn0J3J_hvVNS1ZeZIP6OeJAPm8"
}
  • 我們在 PostMan 上,找到這個 API http://localhost:54891/api/needAuth ,在這個API頁面,加入 Authorization 標頭,該標頭的值要為 Bearer Token,在我們這個例子中就是如下的標頭定義;當設定完成之後,按下 [Send] 按鈕,就發現到我們現在可以呼叫這個 Web API了。
coding
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ2dWxjYW4iLCJleHAiOjE1MTY5Nzk5MzUuMCwicm9sZSI6WyJNYW5hZ2VyIiwiUGVvcGxlIl19.4iGEoBA0SoCiSTBXdAn0J3J_hvVNS1ZeZIP6OeJAPm8
  • 但是,若我們竄改了這個存取權杖,或者權杖的內容被刪去了幾個字元,在這裡,我們把剛剛的權杖刪除最後兩個字元,因此,底下的標題將是我們要送出的內容
coding
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ2dWxjYW4iLCJleHAiOjE1MTY5Nzk5MzUuMCwicm9sZSI6WyJNYW5hZ2VyIiwiUGVvcGxlIl19.4iGEoBA0SoCiSTBXdAn0J3J_hvVNS1ZeZIP6OeJAP
  • 此時,您會得到底下的結果,告訴您的權杖是有問題,並且無法使用這個 Web API
JSON
{
    "Success": false,
    "Message": "權杖解析發生異常 : Illegal base64url string.",
    "Payload": null
}
  • 由於,發出的存取權杖有效期限為一個小時,若你使用一個小時前發出的權杖,來呼叫這個 Web API,例如,使用底下的標頭。
coding
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ2dWxjYW4iLCJleHAiOjE1MTY5Njk0MTUuMCwicm9sZSI6WyJNYW5hZ2VyIiwiUGVvcGxlIl19.8KgvjlRONFIpSnIYD2iTjpY4IWH9vn_ntvI_LwhvBHQ
  • 這個時候,就會得到底下的 權杖已經逾期 的訊息。
JSON
{
    "Success": false,
    "Message": "權杖已經逾期",
    "Payload": null
}
這個練習的完成專案,可以參考資料夾 [04 CallWithToken]

對於已經具備擁有 .NET / C# 開發技能的開發者,可以使用 Xamarin.Forms Toolkit 開發工具,便可以立即開發出可以在 Android / iOS 平台上執行的 App;對於要學習如何使用 Xamarin.Forms & XAML 技能,現在已經推出兩本電子書來幫助大家學這這個開發技術。
這兩本電子書內包含了豐富的逐步開發教學內容與相關觀念、各種練習範例,歡迎各位購買。
Xamarin.Forms 電子書
想要購買 Xamarin.Forms 快速上手 電子書,請點選 這裡

想要購買 XAML in Xamarin.Forms 基礎篇 電子書,請點選 這裡