使用者驗證與授權 - Token Based Authentication:發行 JSON Web Token

昨天已經為先前實作的 ASP.NET Core Web API 加入了會員註冊與登入的功能,但是在登入的使用者認證是屬於 Cookie Based Authentication 因為我們所開發的 Web API 主要是要行動裝置使用,參考一些文章的比較,看來 Token Based Authentication 比較合適,所以今天就來學習讓我們的 ASP.NET Core Web API 可以發行 JSON Web Token 吧!

有關 Token Based Authentication 請先參考這一篇文章,有關 JSON Web Token 請先參考這一篇文章。然後阿源哥哥再以比較口語化的文字說明一下:

Token Based Authentication

如上圖所示,好像大型遊樂園的概念,在票卷發行機構《付錢》,當確認金錢真偽和額度後,便會發行一張票卷(或是磁扣手環之類的),票卷內註明發行日期使用期限,可以使用的遊樂設施等,接著每次要玩遊樂設施時,只要出示該票卷即可,當然設施管理者會先確認票卷真偽、是否為有效期限,以及是否可使用該項設施之後才提供該項設施服務。

相同的道理,伺服器端首先由使用者所提供的帳號密碼確認是否為合法使用者,若是合法使用者就會發行一個含有各項資訊,稱為【Token】的字串給使用者,往後使用者即可帶著該【Token】請求執行各項 REST API 服務,而執行前也會由【Token】中所夾帶的資訊查驗是否該提供該項服務。

JSON Web Token

說明白一點,所謂的【Token】也只是一長串的字串而已,雖然格式可自訂,但是已有機構訂出來標準,該標準稱為 JSON Web Token 該格式請看下圖,再聽說明:

送到使用者端的 Token 為加密過後,如圖左所示的:aaaaa.bbbbb.ccccc. 分隔的三段字串,這三段文字解密後如圖右三個區段分別代表:

  • HEADER
    表頭主要包含了兩項資訊:
    1. 雜湊演算法 
    2. Token 的型式
  • PAYLOAD
    這一段主要是將來在 Web API 所要包含的資訊,例如主題、姓名、帳號、發行單位、權限 ....... 等,主要是看將來應用程式設計需要包含哪些資訊才可讓接收的 Web API 可以繼續往後的工作。
  • SIGNATURE
    這一段是簽章主要是用於防偽用

安裝套件

首先請為 Web API 專案安裝 Microsoft.AspNetCore.Authentication.JwtBearer 套件,以用來實作產生 JSON Web Token(JWT):

在設定檔中加入與 Token 有關的參數

在 appsettings.json 中加入如下的代碼:

{
  ......
  ......
  ......
  
  "Tokens": {
    "Audience": "http://demaewebapi.azurewebsites.net",
    "Issuer": "http://demaewebapi.azurewebsites.net",
    "Key": "keigenisagoodman"
  }
}

為了能在所有的 Controller 中存取寫在 appsettings.json 中的參數,請在 Startup.cs 中加入服務 services.AddSingleton(Configuration);  如下所示:

public void ConfigureServices(IServiceCollection services)
{
    // Add framework services.
    services.AddSingleton(Configuration);


    // 省略



}

實作發行 JSON Web Token

首先在 AccountController 的構建函式中加入可用的服務,如下所示:

public class AccountController : Controller
{
    private UserManager<AppUser> _userManager;
    private SignInManager<AppUser> _signInManager;
    private IPasswordHasher<AppUser> _passwordHasher;
    private IConfigurationRoot _config;

    public AccountController(
        UserManager<AppUser> userManager,
        SignInManager<AppUser> signInManager,
        IPasswordHasher<AppUser> passwordHasher,
        IConfigurationRoot config)
    {
        _userManager = userManager;
        _signInManager = signInManager;
        _passwordHasher = passwordHasher;
        _config = config;
    }

    // 省略

}

然後新增發行 JSON Web Token 的方法,程式碼如下:

[HttpPost("token")]
public async Task<IActionResult> Token([FromBody] LoginModel model)
{
    if (!ModelState.IsValid)
    {
        return BadRequest();
    }

    var user = await _userManager.FindByNameAsync(model.Email);

    if (user == null || _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, model.Password) != PasswordVerificationResult.Success)
    {
        return BadRequest();
    }

    
    var userClaims = await _userManager.GetClaimsAsync(user);
    var claims = new[]
    {
        new Claim(JwtRegisteredClaimNames.Sub, user.UserName),
        new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
        new Claim(JwtRegisteredClaimNames.GivenName, user.NickName),
        new Claim(JwtRegisteredClaimNames.Email, user.Email)
    }.Union(userClaims);

             
    var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Tokens:Key"]));
    var token = new JwtSecurityToken(
      issuer: _config["Tokens:Issuer"],
      audience: _config["Tokens:Audience"],
      claims: claims,
      expires: DateTime.UtcNow.AddMonths(3),              
      signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));

    return Ok(new
    {
        token = new JwtSecurityTokenHandler().WriteToken(token),
        expiration = token.ValidTo
    });
}

上述程式碼分說明,該產生 Token 的 Post 方法,接收使用者傳來的帳號和密碼(以 LoginModel)首先以 ModelState.IsValid 檢查是否有正確填寫,若填寫不正確回傳 BadRequest() ,接著由所傳來的使用者帳號查詢是否有該會員資料,並比對

[HttpPost("token")]
public async Task<IActionResult> Token([FromBody] LoginModel model)
{
    if (!ModelState.IsValid)
    {
        return BadRequest();
    }

    var user = await _userManager.FindByNameAsync(model.Email);

    if (user == null || _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, model.Password) != PasswordVerificationResult.Success)
    {
        return BadRequest();
    }

    
    // 省略



}

 

[HttpPost("token")]
public async Task<IActionResult> Token([FromBody] LoginModel model)
{
    // 省略
    
    var userClaims = await _userManager.GetClaimsAsync(user);
    var claims = new[]
    {
        new Claim(JwtRegisteredClaimNames.Sub, user.UserName),
        new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
        new Claim(JwtRegisteredClaimNames.GivenName, user.NickName),
        new Claim(JwtRegisteredClaimNames.Email, user.Email)
    }.Union(userClaims);

   // 省略          
   
}

 

[HttpPost("token")]
public async Task<IActionResult> Token([FromBody] LoginModel model)
{
    
    // 省略
             
    var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Tokens:Key"]));
    var token = new JwtSecurityToken(
      issuer: _config["Tokens:Issuer"],
      audience: _config["Tokens:Audience"],
      claims: claims,
      expires: DateTime.UtcNow.AddMonths(3),              
      signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));

    // 省略
}

最後將組裝好的 token 以 WriteToken() 方法寫出回傳,順便也把到期日回傳出去:

[HttpPost("token")]
public async Task<IActionResult> Token([FromBody] LoginModel model)
{
    // 省略

    return Ok(new
    {
        token = new JwtSecurityTokenHandler().WriteToken(token),
        expiration = token.ValidTo
    });
}

實作完成,接著就使用 Postman 測試一下吧:

看來有產生了一大長串的 Token 字串,接著再產生的 Token 字串貼到 https://jwt.io/  反解密看看產生的字串是否為我們想要的:

看來解密後的資料確實是我們在程式中所加入的,值得留意的是簽名檔的驗證,把程式中寫在 appsettings.json 的 Tokens.Key 貼入,也確定驗證成功,是我們發行的沒錯。

好吧!今天就學習到這裡。明天再來學習如何判斷《前端》帶來的 Token 確實是我們發行的,且有足夠權限使用我們所開發的 Web API。