iT邦幫忙

14

[C#][ASP.NET] Web API 開發心得 (7) - 使用 Token 進行 API 授權驗證

最近都在整理過去的文章,好久沒有發文,趁鐵人賽開賽前最後一發。

之前的文章有提到 Cookie-BasedToken-Based 兩種授權驗證方式,另一篇 實作了 Cookie-Based,而今天要介紹的就是第二種 Token-Based 授權驗證。

使用 Token 有那些好處呢?

  1. 跨域: 不受網域限制,可用來串接第三方應用,如 OAuth。
  2. 安全性: 不使用 Cookie 因此不會受到 CSRF 攻擊,不過 Token 並不能防護 XSS 攻擊,還是需要特別注意。[1]
  3. 行動端: 可用於不支援 Cookie 的裝置上,且現在網站和 APP 串接普遍使用 Token 授權。

什麼是 JWT (Json Web Token):

JWT 是網路上常見的 Token 類型,詳細規範可參考 RFC7519
包含三個部分 headerpayloadsignature,並使用 . 串聯起來,結構如下。[2]

header.payload.signature

Header

主要包含兩個資訊,加密演算法Token 的類型,並使用 base64 編碼。

{
  "alg": "HS256",
  "typ": "JWT"
}

Payload

用來存放使用者的基本資料和相關的驗證資訊,並使用 base64 編碼。

{
  "UserId": "A01",
  "UserName": "王小明",
  "exp": "100000000"   //過期時間
}

Signature

確保資料完整性的雜湊簽章,由 Header 和 Payload 經過 base64 編碼後用 . 串接,再使用 HS256 加密後得到。

HMACSHA256(
    base64UrlEncode(Header) + "." + 
    base64UrlEncode(Payload), 
    secret
);

如何使用

使用時放在 Header 內的 Authoriaztion 標頭,並在 Token 前方加上 Bearer 關鍵字。

Authoriaztion: Bearer header.payload.signature

以上為 JWT 簡介,不過可以發現 Token 主體只經過 base64 編碼,並沒有經過加密,因此內含的資訊等於是明碼,並沒有受到保護。

雖然 Token 內不會存放敏感性的資料,但還是希望可以經過加密,隱藏資料結構,因此稍微做了修改。

修改後的 Payload

使用 base64 編碼後,再使用 AES 加密,AES 需要 KEY 和 IV,其中 IV 建議不要每次都相同,詳細原理沒有深入研究,可以參考 這篇

AES(
    base64UrlEncode(Payload), 
    secret,     //密鑰
    iv          //IV
);

修改後的 Header

Header 目前用不到就拿來放 IV 吧,哈哈哈。

iv

修改後的 Signature

HMACSHA256(
    iv + 
    base64UrlEncode(Payload), 
    secret   //密鑰
);

Token 結構

Authoriaztion: iv.payload.signature

接著要討論 Token 的換發流程,JWT 有個缺點,因為所有資訊都寫在 Token 內,雖然驗證時不必經過資料庫,但我們也不能透過資料庫銷毀 Token,只能靠設定過期時間讓它自己過期。

考慮到 Token 通常是給 APP 使用,如果像網頁一樣失效後,需重新輸入帳號密碼登入,一定會被嫌使用者體驗不好,但又不能將過期時間設太長,因為如果 Token 不小心被竊取,該帳號將會長時間處於不安全狀態,竊取者可以用此 Token 做任何事,我們無法讓其失效。

因此有人提出了 Refresh Token 的概念,當 Token 過期後可用 Refresh Token 換取新的,其存活時間可以很長。這樣我們就能將 Token 過期時間縮短,過期後再用 Refresh Token 換取新的就好,同時兼顧安全性和使用者體驗。[3]

到這裡一定有人會問 Refresh Token 如果被竊取呢,因為 Refresh Token 會儲存在資料庫內,所以可以透過刪除的方式銷毀 Refresh Token。那既然都能竊取,換一次偷一次呢,ㄜ...這就不在這篇的討論範圍內了,要記得網路上沒有絕對的安全,只能盡量優化我們的安全機制。

Token 和 Refresh Token 的換發方式:

  1. 使用者輸入帳號密碼,登入後取得 Token 和 Refresh Token。
{
  "access_token":"l0XG52TQx",    //Token
  "refresh_token":"KWI3JOkFA",   //Refresh Token
  "expires_in":3600              //幾秒過期
}
  1. 當 Token 過期後,使用 Refresh Token 要求換發新 Token,
    Server 驗證 Refresh Token 成功後,將舊的 Refresh Token 刪除,並重新核發一組新的,格式同上。

Token-Based 登入流程:

登入流程 Token-BasedCookie-Based 差不多,可以參考 另一篇 文章的 登入流程圖,兩者差在 Cookie-Based 將驗證資訊藉由 Cookie 送到後端,而 Token-Based 則是將 Token 放在 Header 的 Authoriaztion 標頭送到後端。


實作

新增 Token 類別定義回傳的 Token 結構。

public class Token
{
    //Token
    public string access_token { get; set; }
    //Refresh Token
    public string refresh_token { get; set; }
    //幾秒過期
    public int expires_in { get; set; }
}

新增 Payload 類別,這裡稍微修改了 Payload 結構,將使用者資訊和過期時間分開。

public class Payload
{
    //使用者資訊
    public User info { get; set; }
    //過期時間
    public int exp { get; set; }
}

資料會像。

{
  "info": {
      "UserId": "A01",
      "UserName": "王小明" 
  },
  "exp": "100000000"
}

新增 TokenCrypto 處理 AES 加解密產生 HMACSHA256 簽章

public static class TokenCrypto
{
    //產生 HMACSHA256 雜湊
    public static string ComputeHMACSHA256(string data, string key)
    {
        var keyBytes = Encoding.UTF8.GetBytes(key);
        using (var hmacSHA = new HMACSHA256(keyBytes))
        {
            var dataBytes = Encoding.UTF8.GetBytes(data);
            var hash = hmacSHA.ComputeHash(dataBytes, 0, dataBytes.Length);
            return BitConverter.ToString(hash).Replace("-", "").ToUpper();
        }
    }

    //AES 加密
    public static string AESEncrypt(string data, string key, string iv)
    {
        var keyBytes = Encoding.UTF8.GetBytes(key);
        var ivBytes = Encoding.UTF8.GetBytes(iv);
        var dataBytes = Encoding.UTF8.GetBytes(data);
        using (var aes = Aes.Create())
        {
            aes.Key = keyBytes;
            aes.IV = ivBytes;
            aes.Mode = CipherMode.CBC;
            aes.Padding = PaddingMode.PKCS7;
            var encryptor = aes.CreateEncryptor();
            var encrypt = encryptor
                .TransformFinalBlock(dataBytes, 0, dataBytes.Length);
            return Convert.ToBase64String(encrypt);
        }
    }

    //AES 解密
    public static string AESDecrypt(string data, string key, string iv)
    {
        var keyBytes = Encoding.UTF8.GetBytes(key);
        var ivBytes = Encoding.UTF8.GetBytes(iv);
        var dataBytes = Convert.FromBase64String(data);
        using (var aes = Aes.Create())
        {
            aes.Key = keyBytes;
            aes.IV = ivBytes;
            aes.Mode = CipherMode.CBC;
            aes.Padding = PaddingMode.PKCS7;
            var decryptor = aes.CreateDecryptor();
            var decrypt = decryptor
                .TransformFinalBlock(dataBytes, 0, dataBytes.Length);
            return Encoding.UTF8.GetString(decrypt);
        }
    }
}

新增 TokenManager 類別管理產生 Token 和取出使用者資訊的操作。

public class TokenManager
{
    //金鑰,從設定檔或資料庫取得
    public string key = "AAAAAAAAAA-BBBBBBBBBB-CCCCCCCCCC-DDDDDDDDDD-
        EEEEEEEEEE-FFFFFFFFFF-GGGGGGGGGG";
    
    //產生 Token
    public Token Create(User user)
    {
        var exp = 3600;   //過期時間(秒)

        //稍微修改 Payload 將使用者資訊和過期時間分開
        var payload = new Payload
        {
            info = user,
            //Unix 時間戳
            exp = Convert.ToInt32(
                (DateTime.Now.AddSeconds(exp) - 
                 new DateTime(1970, 1, 1)).TotalSeconds)
        };
        
        var json = JsonConvert.SerializeObject(payload);
        var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
        var iv = Guid.NewGuid().ToString().Replace("-", "").Substring(0, 16);

        //使用 AES 加密 Payload
        var encrypt = TokenCrypto
            .AESEncrypt(base64, key.Substring(0, 16), iv);

        //取得簽章
        var signature = TokenCrypto
            .ComputeHMACSHA256(iv + "." + encrypt, key.Substring(0, 64));
        
        return new Token
        {
            //Token 為 iv + encrypt + signature,並用 . 串聯
            access_token = iv + "." + encrypt + "." + signature,
            //Refresh Token 使用 Guid 產生
            refresh_token = Guid.NewGuid().ToString().Replace("-", ""),
            expires_in = exp,
        };
    }

    //取得使用者資訊
    public User GetUser()
    {
        var token = HttpContext.Current.Request.Headers["Authoriaztion"];

        var split = token.Split('.');
        var iv = split[0];
        var encrypt = split[1];
        var signature = split[2];

        //檢查簽章是否正確
        if (signature != TokenCrypto
            .ComputeHMACSHA256(iv + "." + encrypt, key.Substring(0, 64)))
        {
            return null;
        }
        
        //使用 AES 解密 Payload
        var base64 = TokenCrypto
            .AESDecrypt(encrypt, key.Substring(0, 16), iv);
        var json = Encoding.UTF8.GetString(Convert.FromBase64String(base64));
        var payload = JsonConvert.DeserializeObject<Payload>(json);

        //檢查是否過期
        if (payload.exp < Convert.ToInt32(
            (DateTime.Now - new DateTime(1970, 1, 1)).TotalSeconds))
        {
            return null;
        }

        return payload.info;
    }
}

新增 TokenController 測試功能。

[RoutePrefix("api/token")]
public class TokenController : ApiController
{
    private TokenManager _tokenManager;
    public TokenController()
    {
        _tokenManager = new TokenManager();
    }

    //紀錄 Refresh Token,需紀錄在資料庫
    private static Dictionary<string, User> refreshTokens = 
        new Dictionary<string, User>();

    //登入
    [HttpPost]
    [Route("signIn")]
    public Token SignIn(SignInViewModel model)
    {
        //模擬從資料庫取得資料
        if (!(model.UserId == "abc" && model.Password == "123"))
        {
            throw new Exception("登入失敗,帳號或密碼錯誤");
        }
        var user = new User
        {
            Id = 1,
            UserId = "abc",
            UserName = "小明",
            Identity = Identity.User
        };
        //產生 Token
        var token = _tokenManager.Create(user);
        //需存入資料庫
        refreshTokens.Add(token.refresh_token, user);
        return token;
    }

    //換取新 Token
    [HttpPost]
    [Route("refresh")]
    public Token Refresh([FromBody]string refreshToken)
    {
        //檢查 Refresh Token 是否正確
        if (!refreshTokens.ContainsKey(refreshToken))
        {
            throw new Exception("查無此 Refresh Token");
        }
        //需查詢資料庫
        var user = refreshTokens[refreshToken];
        //產生一組新的 Token 和 Refresh Token
        var token = _tokenManager.Create(user);
        //刪除舊的
        refreshTokens.Remove(refreshToken);
        //存入新的
        refreshTokens.Add(token.refresh_token, user);
        return token;
    }

    //測試是否通過驗證
    [HttpPost]
    [Route("isAuthenticated")]
    public bool IsAuthenticated()
    {
        var user = _tokenManager.GetUser();
        if (user == null)
        {
            return false;
        }
        return true;
    }
}

測試

使用 Postman。

1.登入

api/token/signIn

成功回傳 Token。

https://ithelp.ithome.com.tw/upload/images/20180906/20106865gltEAnMxf4.jpg

2.是否通過驗證

api/token/isAuthenticated

回傳 true 表示通過驗證。

https://ithelp.ithome.com.tw/upload/images/20180906/201068650djz3h8ldF.jpg

2.使用 Refresh Token 換取新 Token

api/token/refresh

成功換取新的 Token。

https://ithelp.ithome.com.tw/upload/images/20180906/20106865UWzoAftpiu.jpg

這裡採到一個坑,一開始用 key-value 的方式傳值,但 Web Api 怎麼都收不到,去看了官方 文件 才發現雖然可以用 [FromBody] 綁定參數到 簡單型別 (string、int) 上,

public Token Refresh([FromBody]string refreshToken)

但前端傳到後端的值不能有 key,例如:

=value

接著我就將 key 刪除再送出,結果還是一樣出錯,點右上角的 Code 看看。

https://ithelp.ithome.com.tw/upload/images/20180906/20106865NEBcgVxWqq.jpg

畫面參數正確,但 Web Api 還是收不到,想說用 Fiddler 欄看看好了,最後發現被 Postman 騙了...參數沒有送出阿,畫面上是假的!!!

https://ithelp.ithome.com.tw/upload/images/20180906/20106865fCUp7xto2n.jpg

後來選 raw 才可以。

結語

這篇簡單實作了 Token 驗證和換發的流程,程式的部分如果是正式使用,錯誤訊息還需要再詳細一些,例如驗證簽章、檢查是否過期、等等。

在 Refresh Token 設計上還有一些要注意的地方,例如一位使用者可以同時擁有幾個 Refresh Token 呢,如果只能有一個,那麼一位使用者就只能登入一台裝置,因為另一台裝置持有的 Refresh Token 會在登入時會被覆蓋掉。而如果不限制,隨著登入次數增加 Refresh Token 有可能會無限成長,好像也不是很恰當。

參考了 Google 的做法,Google 會限制單個應用程式授權最多擁有 50 個 Refresh Token,超過則會從舊的開始失效,像上面兩者的折衷辦法。[9]

這篇介紹的 Token 機制,可以用在單一網站或自家的 APP 串接,但如果要像 Google、FB 提供給第三方應用串接,則需實作完整的 OAuth 機制,這篇介紹的還不是完整的 OAuth,算是簡化的版本,完整的下次有機會再分享。

這篇就到這裡摟,感謝大家觀看。 /images/emoticon/emoticon41.gif

參考文章

[1] 講真,別再使用JWT了|洞見
[2] [ASP.NET WebApi]使用JWT進行web api驗證
[3] OAuth 2.0 筆記 (5) 核發與換發 Access Token
[4] 浅谈使用Json Web Token和Cookie的利弊
[5] 使用Asp.Net MVC打造Web Api (14) - 確保傳輸資料的安全
[6] C# DateTime与时间戳转换
[7] .NET Core - AES 加解密
[8] C# base64加密與解密
[9] Using OAuth 2.0 to Access Google APIs


尚未有邦友留言

立即登入留言