[ASP.NET Web API 2] 實作 JWT 身分驗證並用單元測試驗證

JWT 是一個老牌的套件,從 nuget 上來看他,甚至還比 System.IdentityModel.Tokens.Jwt 還要資深,使用起來也相當的簡單

以下是我的使用過程分享

開發環境

  • VS 2017.15.9.4
  • Install-Package JWT
  • 專案建置步驟請參考 [Web API] 使用 OWIN 進行測試

使用手冊

官網介紹的蠻清楚的,就不再多寫了
https://github.com/jwt-dotnet/jwt

這需要閱讀一下 JWT 的協議,才知道他在做甚麼

產生JWT

JwtManager.cs

這有兩個方法,產生 Token、驗證 Token

https://github.com/yaochangyu/sample.dotblog/blob/master/WebAPI/JWT/jwt-dotnet/Server/JwtManager.cs
 

我把 exp、nbf,這兩個 claim 依賴 Now 屬性,等會要由測試程式注入時間模擬過期

public static string GenerateToken(string userName, int expireMinutes = 20)
{
    var symmetricKey = Convert.FromBase64String(Secret);
    IJwtAlgorithm algorithm = new HMACSHA256Algorithm();
    IJsonSerializer serializer = new JsonNetSerializer();
    IBase64UrlEncoder urlEncoder = new JwtBase64UrlEncoder();
    IJwtEncoder encoder = new JwtEncoder(algorithm, serializer, urlEncoder);
    var now = (DateTimeOffset) Now.Value;
    var expired = now.AddMinutes(expireMinutes).ToUnixTimeSeconds();
    var notBefore = now.ToUnixTimeSeconds();
 
    var payload = new Dictionary<stringobject>
    {
        {"name", userName},
        {"exp", expired},
        {"nbf", notBefore}
    };
 
    var token = encoder.Encode(payload, symmetricKey);
 
    return token;
}

 

驗證Token

這裡用到時間的驗證,我自己實作 IDateTimeProvider,這樣才能注入時間,模擬過期,通過驗證則回傳 ClaimsPrincipal

public static bool TryValidateToken(string token, out ClaimsPrincipal principal)
{
    var symmetricKey = Convert.FromBase64String(Secret);
    principal = null;
    var result = false;
    try
    {
        IJsonSerializer serializer = new JsonNetSerializer();
        IDateTimeProvider provider = new DateTimeProvider {Now = Now};
 
        IJwtValidator validator = new JwtValidator(serializer, provider);
        IBase64UrlEncoder urlEncoder = new JwtBase64UrlEncoder();
        IJwtDecoder decoder = new JwtDecoder(serializer, validator, urlEncoder);
 
        var payload = decoder.DecodeToObject(token, symmetricKey, true);
        List<Claim> claims = new List<Claim>();
        foreach (var item in payload)
        {
            if (item.Value == null)
            {
                continue;
            }
 
            var key = item.Key;
            var value = item.Value.ToString();
            if (key.ToLower() == "name")
            {
                claims.Add(new Claim(ClaimTypes.Name, value));
            }
            else if (key.ToLower() == "role")
            {
                claims.Add(new Claim(ClaimTypes.Role, value));
            }
            else
            {
                claims.Add(new Claim(key, value));
            }
        }
 
        var identity = new ClaimsIdentity(claims, "JWT");
        principal = new ClaimsPrincipal(identity);
        result = true;
    }
    catch (TokenExpiredException)
    {
        Console.WriteLine("Token has expired");
    }
    catch (SignatureVerificationException)
    {
        Console.WriteLine("Token has invalid signature");
    }
    catch (Exception ex)
    {
        Console.WriteLine(ex.Message);
    }
 
    return result;
我不希望驗證失敗原因往前端送,所以真正失敗原因的例外直接吃掉,直接送他一個 401,這裡你得依照你真實的情況自行處理,不要直接抄
catch 這樣寫沒有意義,這裡是用在觀察測試案例的 output

 

AuthorizeAttribute

這裡我用 AuthorizeAttribute 進行驗證

驗證 Token,並拿到 ClaimsPrincipal 塞給 Thread.CurrentPrincipal 和 RequestContext

public class JwtAuthorizeAttribute : AuthorizeAttribute
{
    protected override bool IsAuthorized(HttpActionContext actionContext)
    {
        var authorization = actionContext.Request.Headers.Authorization;
        if (authorization != null && authorization.Scheme == "Bearer")
        {
            var token = authorization.Parameter;
            if (JwtManager.TryValidateToken(token, out var principal))
            {
                Thread.CurrentPrincipal = principal;
                actionContext.Request.GetRequestContext().Principal = principal;
            }
        }
 
        return base.IsAuthorized(actionContext);
    }
}

 

頒發授權 API

public class TokenController : ApiController
{
    // POST api/token
    [AllowAnonymous]
    public IHttpActionResult Post(LoginData loginData)
    {
        if (this.CheckUser(loginData.UserName, loginData.Password))
        {
            var token = JwtManager.GenerateToken(loginData.UserName);
            return new ResponseMessageResult(new HttpResponseMessage
            {
                StatusCode = HttpStatusCode.OK,
                Content = new StringContent(token, Encoding.UTF8)
            });
        }
 
        throw new HttpResponseException(HttpStatusCode.Unauthorized);
    }
 
    public bool CheckUser(string username, string password)
    {
        // should check in the database
        return true;
    }
 
    public class LoginData
    {
        public string UserName { getset}
 
        public string Password { getset}
    }
}

 

保護資源

把剛剛的 AuthorizeAttribute 加進 Filter,套用在整個應用程式

public static class WebApiConfig
{
    public static void Register(HttpConfiguration config)
    {
        // Web API configuration and services
        config.Filters.Add(new JwtAuthorizeAttribute());
 	.....
    }
}

 

這樣 api/Value 就需要授權才能訪問了

public class ValueController : ApiController
{
    public IHttpActionResult Get()
    {
        return new ResponseMessageResult(new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StringContent("value")
        });
    }
}

 

當訪問資源沒有帶入 JWT,就會得到401

[TestMethod]
public void Access_Resource_HttpStatus_Should_Be_Unauthorized()
{
    var queryUrl = "api/value";
 
    var queryResponse = s_client.GetAsync(queryUrl).Result;
    Assert.AreEqual(HttpStatusCode.Unauthorized, queryResponse.StatusCode);
}

 

訪問資源之前先取得 Token,再帶入 Header,便能順利的訪問 api/value

[TestMethod]
public void Access_Resource_Should_Be_Value()
{
    var loginUrl = "api/token";
    var queryUrl = "api/value";
 
    var tokenResponse = s_client.PostAsJsonAsync(loginUrl,
                                                 new LoginData
                                                 {
                                                     UserName = "yao",
                                                     Password = "1234"
                                                 })
                                .Result;
    Assert.AreEqual(HttpStatusCode.OK, tokenResponse.StatusCode);
 
    var token = tokenResponse.Content.ReadAsStringAsync().Result;
    s_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
    var queryResponse = s_client.GetAsync(queryUrl).Result;
    Assert.AreEqual(HttpStatusCode.OK, queryResponse.StatusCode);
    var result = queryResponse.Content.ReadAsStringAsync().Result;
    Assert.AreEqual("value", result);
}

 

Validation 'exp' (Expiration Time)

接下來,我要驗證這個 JWT 套件所頒發的 Token,當過期時會不會驗證失敗,所以我注入了兩次時間 JwtManager.Now,第一次是過期的時間,第二次是目前的時間。

[TestMethod]
public void Token_Expired_Should_Be_Unauthorized()
{
    var loginUrl = "api/token";
    var queryUrl = "api/value";
 
    //注入期望時間
    JwtManager.Now = DateTime.SpecifyKind(new DateTime(200011)DateTimeKind.Utc);
 
    var tokenResponse = s_client.PostAsJsonAsync(loginUrl,
                                                 new LoginData
                                                 {
                                                     UserName = "yao",
                                                     Password = "1234"
                                                 })
                                .Result;
    Assert.AreEqual(HttpStatusCode.OK, tokenResponse.StatusCode);
 
    var token = tokenResponse.Content.ReadAsStringAsync().Result;
    s_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
 
    //模擬時間經過30分鐘
    JwtManager.Now = JwtManager.Now.Value.AddMinutes(30);
    var queryResponse = s_client.GetAsync(queryUrl).Result;
    Assert.AreEqual(HttpStatusCode.Unauthorized, queryResponse.StatusCode);
    JwtManager.Now = null;
}

 

Validation 'nbf' (Not Before)

還有一個跟時間有關係的 claim,這個是在什麼時間之前,該 token 不能使用,同樣的我也注入兩次時間,第一次是設定能使用的時間,第二次是模擬目前時間

[TestMethod]
public void Token_NotBefore_Should_Be_Unauthorized()
{
    var loginUrl = "api/token";
    var queryUrl = "api/value";
 
    //注入Token可以使用的時間
    JwtManager.Now = DateTime.SpecifyKind(new DateTime(200012)DateTimeKind.Utc);
 
    var tokenResponse = s_client.PostAsJsonAsync(loginUrl,
                                                 new LoginData
                                                 {
                                                     UserName = "yao",
                                                     Password = "1234"
                                                 })
                                .Result;
    Assert.AreEqual(HttpStatusCode.OK, tokenResponse.StatusCode);
 
    var token = tokenResponse.Content.ReadAsStringAsync().Result;
    s_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
 
    //注入目前時間
    JwtManager.Now = DateTime.SpecifyKind(new DateTime(200011)DateTimeKind.Utc);
    var queryResponse = s_client.GetAsync(queryUrl).Result;
    Assert.AreEqual(HttpStatusCode.Unauthorized, queryResponse.StatusCode);
    JwtManager.Now = null;
}
這兩個案例算是為了調查 API 所建立的測試,意義也不大

範例位置:

https://github.com/yaochangyu/sample.dotblog/tree/master/WebAPI/JWT/jwt-dotnet

 

若有謬誤,煩請告知,新手發帖請多包涵


Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET

Image result for microsoft+mvp+logo