一、简介

随着像AngularJs、Backbone、ReastJS等这种MVVC越来越流行,后端开发越来越脱离语言本身的渲染依赖,更甚者把部分业务直接转到前端来做;而后端变得更加的专注于数据处理,让前后端完全分离,这样后端可测试性变得更高、更简单。

针对这些MVVC现在最流行就是后端向前端提供Restful API接口,API应该是无状态,这意味者没有登陆、退出、当前用户会话,更不可能依赖Cookie;因为我们无法保证所有请求都来源于浏览器,有可能是移动端或者其他设备。

那么目前有两种方式来实现Token:

1、Basic Auth

如果按HTTP规范是指每一次请求时需要在Header带上凭证,包括用户名和密码。但一般不会这么做,我们可能会在登陆页面里根据提供的用户名和密码来获取一个Token,然后每一次请求带上Token做为钥匙来访问受限的API。

2、OAuth

相比较Basic Auth更安全,更标准。不过后端的开发难度也相对于高一点,在.NET里面目前有DotNetOpenAuth提供完整的开发资源。

其实不管是Basic Auth或OAuth他们都是为了更安全的获取Token。而JWT(Json Web Token)如果非要分应该算是Basic Auth的一种Token格式之一,其实OAuth 2.0的规范并没有明确规范Token的实现方式,那么如果你把JWT理解为OAuth的一种也不算是错的。

二、什么是JWT

JWT是一种用于认证的Token格式,这个Token可以帮助我们实现在两个系统之间以一种安全的方式传递信息。包含三部分:header、payload、signature。

1、header

用Base-64编码存放Token类型和编码方式。

{
    typ: 'JWT',
    alg: 'HS256' // 采用HMAC SHA-256算法
}
// result:e3R5cDogJ0pXVCcsYWxnOiAnSFMyNTYnfQ==

2、payload

用Base-64编码存放任意信息,比如:用户信息、权限信息等。

{
    name: 'asdf', // 用户名
    role: 'manage', // 权限
    exp: 1443925718155 // 过期时间
}
// result:e25hbWU6ICdhc2RmJyxyb2xlOiAnbWFuYWdlJyxleHA6IDE0NDM5MjU3MTgxNTV9

3、signature

包括header、payload和密钥的混合体,密钥当然是最重要的,要是泄露了那么你的Token也就被破解了。假定密钥为:1234567890,按 HmacSHA256(header+payload, 密钥) 所得到的Token为:b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad。

最后将header、payload、signature合并起来就是Token了。

上面JSON体中的Key键JWT是有一个明确的结构体规范的,可以参考:JWT Claims

三、一个完整的Web API示例

以下我只能尽可能的简化去演示这个过程,如果你对JWT感兴趣可以查看完整的源代码

1、JWT编码和解码代码

代码并无特别之处,只不过将以上JWT三个步骤的具体实现。

/// <summary>
/// 只支持HS256算法
/// </summary>
public class JWT
{
    /// <summary>
    /// Token解码
    /// </summary>
    public T Decode<T>(string token, string key, bool noVerify = false)
    {
        var segments = token.Split('.');
        if (segments.Length != 3) throw new ArgumentException("Token格式不正确");

        var headerSeg = segments[0];
        var payloadSeg = segments[1];
        var signatureSeg = segments[2];

        var header = StringToJson<JWTHeader>(Base64Decrypt(headerSeg));
        var payload = StringToJson<T>(Base64Decrypt(payloadSeg));

        if (!noVerify)
        {
            if (!HmacSignature(headerSeg + "." + payloadSeg, key).Equals(signatureSeg))
                throw new Exception("无效Token");
        }

        return payload;
    }

    /// <summary>
    /// Token编码
    /// </summary>
    public string Encode<T>(T payload, string key, JWTHeader header = null)
    {
        var segments = new List<string>();
        // 1、header
        if (header == null) header = new JWTHeader();
        segments.Add(Base64Encrypt(JsonToString(header)));
        // 2、payload
        segments.Add(Base64Encrypt(JsonToString(payload)));
        // 3、signature
        segments.Add(HmacSignature(String.Join(".", segments), key));

        // 最终以.号拼接做为Token
        return String.Join(".", segments);
    }

    #region helper

    private string Base64Encrypt(string str)
    {
        return Convert.ToBase64String(Encoding.UTF8.GetBytes(str));
    }

    private string Base64Decrypt(string str)
    {
        return Encoding.UTF8.GetString(Convert.FromBase64String(str));
    }

    private string HmacSignature(string secret, string value)
    {
        var secretBytes = Encoding.UTF8.GetBytes(secret);
        var valueBytes = Encoding.UTF8.GetBytes(value);
        string signature;

        using (var hmac = new HMACSHA256(secretBytes))
        {
            var hash = hmac.ComputeHash(valueBytes);
            signature = Convert.ToBase64String(hash);
        }
        return signature;
    }

    private string JsonToString<T>(T obj)
    {
        return Newtonsoft.Json.JsonConvert.SerializeObject(obj);
    }

    private T StringToJson<T>(string value)
    {
        if (String.IsNullOrEmpty(value)) return default(T);
        return Newtonsoft.Json.JsonConvert.DeserializeObject<T>(value);
    }

    #endregion
}

2、获取Token

第一件事自然是通过用户名和密码来获取Token。

[HttpGet, HttpPost, Route("login")]
public TokenResult Login(string username, string password)
{
    // 为了简化不做用户名和密码验证
    // 有效期:7天
    var expire = DateTime.UtcNow.AddDays(7);
    var res = new TokenResult()
    {
        expires = expire
    };
    var payloadObj = new JWTPayload()
    {
        name = username,
        role = "manage",
        exp = expire
    };
    res.token = new JWT().Encode<JWTPayload>(payloadObj, PassportConfig.SecretKey);

    return res;
}

当我们请求 passport/login?username=asdf&password=1 后,会返回一个JSON结果:

{
token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiMSIsInJvbGUiOiJtYW5hZ2UiLCJleHAiOjE0NDQ1NDQxMzU0MTl9.zUbqWY/vBerzM3BjzSFnFxEbWlcW1g92pNsmStt5xXc=",
expires: 1444544135419
}

token 表示于下如果我们需要访问受限API时,只需要带上这个Token即可。

expires 表示过期时间。

3、验证Token

Web API我们可以通过OWIN做一个中间件,每一次请求时需要先通过中间件来验证Token是否有效。但这里我没打算这么写,写一个OWin的成本高于一个属性。所以这里我采用 AuthorizationFilterAttribute 属性。

public override void OnAuthorization(HttpActionContext actionContext)
{
    if (actionContext == null) throw new Exception("actionContext");

    // 过滤带有AllowAnonymous
    if (SkipAuthorization(actionContext)) return;

    // 分别从Query、Body、Header获取Token字符串
    const string key = "access_token";
    string token = "";
    // header
    if (actionContext.Request.Headers.Contains(key))
        token = actionContext.Request.Headers.GetValues(key).First();
    // query
    // body

    if (String.IsNullOrEmpty(token))
        actionContext.Response = actionContext.ControllerContext.Request.CreateErrorResponse(HttpStatusCode.Forbidden, "请先登录。");

    try
    {
        var payload = new JWT().Decode<JWTPayload>(token, PassportConfig.SecretKey);
        // 检查token是否已经过期
        if (payload.exp < DateTime.UtcNow)
            actionContext.Response = actionContext.ControllerContext.Request.CreateErrorResponse(HttpStatusCode.Forbidden, "Token已经过期。");
    }
    catch (Exception ex)
    {
        actionContext.Response = actionContext.ControllerContext.Request.CreateErrorResponse(HttpStatusCode.Forbidden, ex.Message);
    }
}

四、总结

JWT这种JSON Token格式相比较我们自建的安全认证更简单,而安全上面也能够得到保障。首先对于公司产品而言,同一个Token能否在不同业务线上能够快速、有效的认证非常重要;其次本身Token已经附带着用户信息,虽然他几乎接近明文存放在Token中间,但这和安全并不冲突。