最新消息:XAMPP默认安装之后是很不安全的,我们只需要点击左方菜单的 "安全"选项,按照向导操作即可完成安全设置。

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

XAMPP下载 admin 1283浏览 0评论

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

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

Cookie vs Token:

使用 Token 有那些好處呢?

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

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

aaaaa.bbbbb.ccccc
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 aaaaa.bbbbb.ccccc


以上為 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 的換發方式:

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

登入流程 Token-Based 和 Cookie-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。

QQ截图20180907165305

2.是否通過驗證

api/token/isAuthenticated

回傳 true 表示通過驗證。

QQ截图20180907165322

2.使用 Refresh Token 換取新 Token

api/token/refresh

成功換取新的 Token。

QQ截图20180907165354

這裡採到一個坑,一開始用 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 騙了…參數沒有送出阿,畫面上是假的!!!

 

QQ截图20180907165410

結語

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

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

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

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

這篇就到這裡摟,感謝大家觀看。

转载请注明:XAMPP中文组官网 » [C#][ASP.NET] Web API 開發心得 (7) – 使用 Token 進行 API 授權驗證

您必须 登录 才能发表评论!