ASP.NET Core 使用 JWT 搭建分佈式無狀態身份驗證系統

升級到 Asp.Net Core 2.0 (2017/08/29 更新)git

爲何使用 Jwt

最近,移動開發的勁頭愈來愈足,學校搞的各類比賽都須要用手機 APP 來撐場面,因此,做爲寫後端的,頗有必要改進一下以往的基於 Session 的身份認證方式了,理由以下:算法

  1. 移動端常常要保持長時間(1 到 2 星期)在線,可是 Session 卻很差在服務端保存這麼久,雖然能夠持久化到數據庫,可是仍是挺費資源
  2. 移動端每每不是使用的網頁技術,因此藏在 Cookie 裏面的 SessionId 不是很方便的傳遞給服務端
  3. 服務端暴露給客戶端的接口每每是 RESTful 風格的,這是一種無狀態的 API 風格,因此身份認證的方式最好也是無狀態的纔好

因此我選擇了使用 Jwt (Json Web Token) 這個技術。Jwt 是一種無狀態的分佈式的身份驗證方式,與 Session 相反,Jwt 將用戶信息存放在 Token 的 payload 字段保存在客戶端,經過 RSA 加密的方式,保證數據不會被篡改,驗證數據有效性。
下面是一個使用 Jwt 的系統的身份驗證流程:數據庫

image_1bfoostkp1rc3q5gn7o1nqs10c99.png-59.6kB

能夠看出,用戶的信息保存在 Token 中,而 Token 分佈在用戶的設備中,因此服務端再也不須要在內存中保存用戶信息了
身份認證的 Token 傳遞時以一種至關簡單的格式保存在 header 中,方便客戶端對其進行操做json

Jwt 簡介

Jwt 形式的 token 通常分爲 3 個部分,分別是 Header,Payload,Signature,這三個部分使用 . 分隔。其中前兩部分使用 Base64 編碼,未經加密處理,第三個部分使用 RSA 加密。
因此一個 Jwt 看起來大概是這個樣子:後端

header.payload.signature

下面是一個真實的 Jwt:api

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1bmlxdWVfbmFtZSI6InplZWtvIiwicm9sZSI6IiIsIm5hbWVpZCI6MSwianRpIjoiNjNjN2Q3OWY2N2VhMDhjYjRiYzNjMmNkOTJiY2JkNTgiLCJuYmYiOjE0OTQ0MDMwMjQsImV4cCI6MTQ5NTAwNzgyMywiaWF0IjoxNDk0NDAzMDI0LCJpc3MiOiJUZXN0SXNzdWVyIiwiYXVkIjoiVGVzdEF1ZGllbmNlIn0.V7Mfi3FGOTLYV0O5DmOWju7LkDJwZNO6HZN19CHb3ekYxcoVbP51YjYAr0fUHc3RPIp3gxITzziHY-07xZ2swCaV0K-hiF5IbwpDuvyxsnlgaRxS94wKDGKSJkArC82KukCtm7IuFBxnNr6kxe7tGcebVhqtaqgnxEUg5lKtDtVI85kd17YtzBp9Vxnc3Ie0r-6KPgUa2HacCf2Pc3hkvY7tZdWZ6ininZlZ-EbcyZI2KTx-vOqdK63MS2JYSw7W2qwf89tsRsORwbB2P4dOBBFK8YSXJpeyGeJWFEMjAMkiH3AeMmW2w_H7r_6Pn-jh5gozzBei4JoHTU6RVDUg1A

Header 部分通常用來記錄加密算法跟 Token 類型
舉個例子:安全

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

Payload

Payload 存放的是一些不敏感的用戶數據,由於這一部分僅僅只是使用 Base64 加密,因此不該該用來保存用戶的密碼之類的信息。服務器

一個例子:app

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

Signature

這一部分是 Jwt 最重要的部分,使用 header 中記錄的算法進行了加密,加密方式以下:asp.net

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

因此這個部分能夠用來保證用戶信息不被篡改,起到驗證用戶身份的做用

在開發過程當中,能夠訪問 https://jwt.io 來調試 Token
固然,爲了更快的訪問速度,還可使用 這個網站

在 ASP.NET Core 中使用 Jwt

由於 Jwt 自己的特色,因此用來簽發 Token 的服務器能夠跟應用服務器不是同一臺,這樣就能夠搞微服務之類的東西(反正我不懂。。。)
所以,在這篇博客中,將會建立兩個 Web 應用:

  • JwtIssuer // 用來簽發 Token
  • JwtAudience // 應用服務器,提供 API 的

image_1bfoq5a6k16231f5k291fba1f0fm.png-2.6kB

首先來搭建咱們的 Token 簽發服務器吧!

簽發第一個 Token

因爲要使用到 RSA 加密,因此先建立一個輔助類來幫助簡化調用:

RSAUtils.cs

using System.IO;
using System.Security.Cryptography;
using Newtonsoft.Json;

namespace JwtUtils
{
    public static class RSAUtils
    {
        /// <summary>
        /// 從本地文件中讀取用來簽發 Token 的 RSA Key
        /// </summary>
        /// <param name="filePath">存放密鑰的文件夾路徑</param>
        /// <param name="withPrivate"></param>
        /// <param name="keyParameters"></param>
        /// <returns></returns>
        public static bool TryGetKeyParameters(string filePath, bool withPrivate, out RSAParameters keyParameters)
        {
            string filename = withPrivate ? "key.json" : "key.public.json";
            keyParameters = default(RSAParameters);
            if (Directory.Exists(filePath) == false) return false;
            keyParameters = JsonConvert.DeserializeObject<RSAParameters>(File.ReadAllText(Path.Combine(filePath, filename)));
            return true;
        }

        /// <summary>
        /// 生成並保存 RSA 公鑰與私鑰
        /// </summary>
        /// <param name="filePath">存放密鑰的文件夾路徑</param>
        /// <returns></returns>
        public static RSAParameters GenerateAndSaveKey(string filePath)
        {
            RSAParameters publicKeys, privateKeys;
            using (var rsa = new RSACryptoServiceProvider(2048))
            {
                try
                {
                    privateKeys = rsa.ExportParameters(true);
                    publicKeys = rsa.ExportParameters(false);
                }
                finally
                {
                    rsa.PersistKeyInCsp = false;
                }
            }
            File.WriteAllText(Path.Combine(filePath, "key.json"), JsonConvert.SerializeObject(privateKeys));
            File.WriteAllText(Path.Combine(filePath, "key.public.json"), JsonConvert.SerializeObject(publicKeys));
            return privateKeys;
        }
    }
}

這個工具類可以幫助咱們生成 RSA 密鑰,並把生成的私鑰跟公鑰保存在兩個文件中,還能從文件中讀取密鑰。

而後定義一個數據類,用來幫助咱們在應用的各個地方獲取加密相關的信息:

JWTTokenOptions.cs

using Microsoft.IdentityModel.Tokens;

namespace JwtUtils
{
    public class JWTTokenOptions
    {
        public string Audience { get; set; }
        public RsaSecurityKey Key { get; set; }
        public SigningCredentials Credentials { get; set; }
        public string Issuer { get; set; }
    }
}

接下來在 Startup.cs 中配置 Jwt 的加密選項:

public void ConfigureServices(IServiceCollection services)
        {
            // 省略了其餘的東西
            
            // 從文件讀取密鑰
            string keyDir = PlatformServices.Default.Application.ApplicationBasePath;
            if (RSAUtils.TryGetKeyParameters(keyDir, true, out RSAParameters keyParams) == false)
            {
                keyParams = RSAUtils.GenerateAndSaveKey(keyDir);
            }
            _tokenOptions.Key = new RsaSecurityKey(keyParams);
            _tokenOptions.Issuer = "TestIssuer"; // 簽發者名稱
            _tokenOptions.Credentials = new SigningCredentials(_tokenOptions.Key, SecurityAlgorithms.RsaSha256Signature);
            // 添加到 IoC 容器
            services.AddSingleton(_tokenOptions);
            
            services.AddMvc();
        }

接下來建立一個控制器,用來提供簽發 Token 的 API

TokenController.cs

namespace JwtIssuer.Controllers
{
    [Route("api/[controller]")]
    public class TokenController : Controller
    {
        private readonly JWTTokenOptions _tokenOptions;
        private readonly AuthDbContext _dbContext;

        public TokenController(JWTTokenOptions tokenOptions, AuthDbContext dbContext)
        {
            _tokenOptions = tokenOptions;
            _dbContext = dbContext;
        }

        /// <summary>
        /// 生成一個新的 Token
        /// </summary>
        /// <param name="user">用戶信息實體</param>
        /// <param name="expire">token 過時時間</param>
        /// <param name="audience">Token 接收者</param>
        /// <returns></returns>
        private string CreateToken(User user, DateTime expire, string audience)
        {
            var handler = new JwtSecurityTokenHandler();
            string jti = audience + user.Username + expire.GetMilliseconds();
            jti = jti.GetMd5(); // Jwt 的一個參數,用來標識 Token
            var claims = new[]
            {
                new Claim(ClaimTypes.Role, user.Role ?? string.Empty), // 添加角色信息
                new Claim(ClaimTypes.NameIdentifier, user.Id.ToString(), // 用戶 Id ClaimValueTypes.Integer32),
                new Claim("jti",jti,ClaimValueTypes.String) // jti,用來標識 token
            };
            ClaimsIdentity identity = new ClaimsIdentity(new GenericIdentity(user.Username, "TokenAuth"), claims);
            var token = handler.CreateEncodedJwt(new SecurityTokenDescriptor
            {
                Issuer = "TestIssuer", // 指定 Token 簽發者,也就是這個簽發服務器的名稱
                Audience = audience, // 指定 Token 接收者
                SigningCredentials = _tokenOptions.Credentials,
                Subject = identity,
                Expires = expire
            });
            return token;
        }

        /// <summary>
        /// 用戶登陸
        /// </summary>
        /// <param name="user">用戶登陸信息</param>
        /// <param name="audience">要訪問的網站</param>
        /// <returns></returns>
        [HttpPost("{audience}")]
        public IActionResult Post([FromBody]User user, string audience)
        {
            DateTime expire = DateTime.Now.AddDays(7);

            // 在這裏來驗證用戶的用戶名、密碼
            var result = _dbContext.Users.First(u => u.Username == user.Username && u.Password == user.Password);
            if (result == null)
            {
                return Json(new { Error = "用戶名或密碼錯誤" });
            }
            return Json(new { Token = CreateToken(result, expire, audience) });
        }
    }
}

如今,訪問這個 API(http://localhost:port/api/token/TestAudience) 就能夠獲取用戶的 Token 了

image_1bfovtq6k11cg2baslp1p0k1nkk34.png-85.4kB

在應用服務器驗證 Token

在 Startup.cs 中註冊 Jwt 相關的服務:

public void ConfigureServices(IServiceCollection services)
        {
            // 省略了其餘的內容
            
             // 從文件讀取密鑰
            string keyDir = PlatformServices.Default.Application.ApplicationBasePath;
            if (RSAUtils.TryGetKeyParameters(keyDir, false, out RSAParameters keyparams) == false)
            {
                _tokenOptions.Key = default(RsaSecurityKey);
            }
            else
            {
                _tokenOptions.Key = new RsaSecurityKey(keyparams);
            }
            _tokenOptions.Issuer = "TestIssuer"; // 設置簽發者
            _tokenOptions.Audience = "TestAudience"; // 設置簽收者,也就是這個應用服務器的名稱
            _tokenOptions.Credentials = new SigningCredentials(_tokenOptions.Key, SecurityAlgorithms.RsaSha256Signature);

            services.AddAuthorization(auth =>
            {
                auth.AddPolicy("Bearer", new AuthorizationPolicyBuilder()
                    .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme)
                    .RequireAuthenticatedUser()
                    .Build());
            });
            
            // Add framework services.
            services.AddMvc();
        }

而後在 Startup.cs 添加 Jwt 認證中間件:

public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
        {
            // 省略了其餘的內容

            app.UseJwtBearerAuthentication(new JwtBearerOptions
            {
                TokenValidationParameters = new TokenValidationParameters
                {
                    IssuerSigningKey = _tokenOptions.Key,
                    ValidAudience = _tokenOptions.Audience, // 設置接收者必須是 TestAudience
                    ValidIssuer = _tokenOptions.Issuer, // 設置簽發者必須是 TestIssuer
                    ValidateLifetime = true
                }
            });

        }

接着隨便建立一個 API 控制器

namespace JwtAudience.Controllers
{
    [Route("api/[controller]")]
    public class ValuesController : Controller
    {
        // GET api/values
        [HttpGet]
        [Authorize]
        public IEnumerable<string> Get()
        {
            return new string[] { "value1", "value2" };
        }
        
    }
}

首先編譯一下應用服務器,可是不要急着運行。由於應用服務器驗證 Token 是須要公鑰的,因此如今去以前的簽發服務器的 build 目錄
image_1bfov9v1jngmv0p128d1mh81kn41g.png-46.4kB

能夠看到生成了兩個json文件,將其中的 key.public.json 拷貝到應用服務器的對應的目錄下面,而後運行應用服務器。

若是咱們直接訪問應用服務器的 API,就會被擋在外面:

image_1bfovfiiqmcsofb1d0e14r2kd51t.png-47.6kB

因此如今去把以前拿到的 token 複製出來,而後給這個請求加個請求頭——Authorization
值是 Bearer 你的Token

image_1bfovjhie6fu1erfca510t51f3j2a.png-46.4kB
這樣,基本的身份驗證就完成了~

有興趣的話還能夠把這個 Token 放在前面提到的用來調試 Jwt 網站上,個人 Token 的解析結果是:

image_1bfovmv6l12gi11c7rvu1duvo8f2n.png-39.8kB

這裏面的 iss 指的就是簽發者,aud 指的是接收者,對於咱們的應用服務器來講,這兩個參數錯了任意一個都將沒法經過驗證(這裏就不演示了,等會兒會有測試代碼~)

真的足夠安全?

至此,咱們已經把 Jwt 的身份認證基本實現了,可是仔細想一想,卻發現存在一個很嚴重的問題————用戶的 Token 在過時時間以內根本沒法手動設置失效,隨之而來的還有重放攻擊等等問題!

Jwt官方也沒有提供很好的應對方法,如今就只有一條路能夠走,就是把失效的 Token 加入黑名單。只要可以讓 Token 失效,以後應對這些安全問題就只是策略上的選擇。

在 Jwt 的官方說明中,jti 這個參數就是用來標識 Token 的。因此,讓一個 Token 失效只須要把這個 Token 中的 jti 加入應用服務器的數據庫的黑名單就行了。

得益於微軟對 Identity 良好的設計,咱們能夠很容易的拓展默認的 Jwt 認證規則

首先建立一個 ValidJtiRequirement 類

public class ValidJtiRequirement : IAuthorizationRequirement
{
}

嗯,他的結構就是這麼簡單。。。

而後建立一個用來驗證這個 Requirement 的 ValidJtiHandler

public class ValidJtiHandler : AuthorizationHandler<ValidJtiRequirement>
{
    private readonly AudienceDbContext _dbContext;

    public ValidJtiHandler(AudienceDbContext dbContext)
    {
        _dbContext = dbContext;
    }
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, ValidJtiRequirement requirement)
    {
        // 檢查 Jti 是否存在
        var jti = context.User.FindFirst("jti")?.Value;
        if (jti == null)
        {
            context.Fail(); // 顯式的聲明驗證失敗
            return Task.CompletedTask;
        }

        // 檢查 jti 是否在黑名單
        var tokenExists = _dbContext.BlackRecords.Any(r => r.Jti == jti);
        if (tokenExists)
        {
            context.Fail();
        }
        else
        {
            context.Succeed(requirement); // 顯式的聲明驗證成功
        }
        return Task.CompletedTask;

    }
}

最後,稍微的修改一下注冊服務時的代碼

services.AddAuthorization(auth =>
{
    auth.AddPolicy("Bearer", new AuthorizationPolicyBuilder()
        .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme)
        .RequireAuthenticatedUser()
        .AddRequirements(new ValidJtiRequirement()) // 添加上面的驗證要求
        .Build());
});
// 註冊驗證要求的處理器,可經過這種方式對同一種要求添加多種驗證
services.AddSingleton<IAuthorizationHandler, ValidJtiHandler>();

最後再來提供一個使 Token 失效的 API

namespace JwtAudience.Controllers
{
    [Route("api/[controller]")]
    public class TokenController : Controller
    {
        private readonly AudienceDbContext _dbContext;

        public TokenController(AudienceDbContext dbContext)
        {
            _dbContext = dbContext;
        }

        [HttpGet]
        public IActionResult Get() => Json(_dbContext.BlackRecords);

        /// <summary>
        /// 使用戶的 Token 失效
        /// </summary>
        /// <returns></returns>
        [Authorize("Bearer")]
        [HttpDelete]
        public IActionResult Delete()
        {
            // 從 payload 中提取 jti 字段
            var jti = User.FindFirst("jti")?.Value;
            var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
            if (jti == null)
            {
                HttpContext.Response.StatusCode = 400;
                return Json(new { Result = false });
            }
            // 把這個 jti 加入數據庫
            _dbContext.BlackRecords.Add(new BlackRecord { Jti = jti, UserId = userId });
            _dbContext.SaveChanges();
            return Json(new {Result = true});
        }
    }
}

這裏須要注意的是,由於拓展了默認的驗證策略,因此須要在 Authorize 這個特性欽定使用 Bearer 策略:

[Authorize("Bearer")]

可是這樣就容易在編碼的時候出現拼寫錯誤,因此來建立一個繼承自這個特性的BearerAuthorize類。

namespace JwtAudience
{
    /// <summary>
    /// Jwt 驗證
    /// </summary>
    public class BearerAuthorizeAttribute : AuthorizeAttribute
    {
        public BearerAuthorizeAttribute() : base("Bearer") { }
    }
}

如今咱們就可使用[BearerAuthorize]來替代[Authorize]

至此,使 token 失效的能力就具有了。

而後附帶一份測試代碼,用來檢驗認證過程是否符合咱們的預期:
https://coding.net/u/zeeko/p/JwtApplication/git/blob/master/Test/Test.cs


升級到 Asp.Net Core 2.0 (2017/08/29)

花了一天時間來把項目升級到 2.0,並非由於 API 變化很大,而是以前的 bug 有些多,修起來有些慢。

首先要升級 Program.cs 裏面的 Main 函數:

public class Program
{
    public static void Main(string[] args)
    {
        BuildWebHost(args).Run();
    }

    public static IWebHost BuildWebHost(string[] args) =>
        WebHost.CreateDefaultBuilder(args)
            .UseStartup<Startup>()
            .Build();
}

看起來更簡短了一些。

接下來升級認證配置,按照官方的說明,全部的 app.UseXxxAuthentication 方法都變成了 service.AddAuthentication(XxxSchema).AddXxx(),因此改動不是很大:

JwtIssuer/Startup.cs/ConfigureServices

services.AddAuthentication().AddJwtBearer(jwtOptions =>
{
    jwtOptions.TokenValidationParameters = new TokenValidationParameters
    {
        IssuerSigningKey = _tokenOptions.Key,
        ValidAudience = _tokenOptions.Audience,
        ValidIssuer = _tokenOptions.Issuer,
        ValidateLifetime = true
    };
});

JwtAudience/Startup.cs/ConfigureServices

services.AddAuthentication().AddJwtBearer(jwtOptions =>
{
    jwtOptions.TokenValidationParameters = new TokenValidationParameters
    {
        IssuerSigningKey = _tokenOptions.Key,
        ValidAudience = _tokenOptions.Audience,
        ValidIssuer = _tokenOptions.Issuer,
        ValidateLifetime = true
    };
});

至此,須要升級的地方就修改好了,可是到目前爲止仍是沒法運行,由於有些在 1.0 裏面沒有嚴格檢驗的地方開始報錯了。

第一個地方是 ValidJtiHandler,以前在註冊的時候,生命週期選的是單例並無報錯,可是由於這個類依賴了一個生命週期是 Scoped 的對象—— AudienceDbContext,這會引起一個異常,解決方法是把 ValidJtiHandler 也改爲 Scoped:

services.AddScoped<IAuthorizationHandler, ValidJtiHandler>();

第二個地方是 RSAParameters 在 2.x 裏面,它的私鑰屬性不能被 Json.Net 序列化,解決方法也很簡單,加一個對應的相似 DTO 的類:

class RsaParameterStorage
{
    public byte[] D { get; set; }
    public byte[] DP { get; set; }
    public byte[] DQ { get; set; }
    public byte[] Exponent { get; set; }
    public byte[] InverseQ { get; set; }
    public byte[] Modulus { get; set; }
    public byte[] P { get; set; }
    public byte[] Q { get; set; }
}

而後在導出私鑰前將 RSAParameters 映射成一個 RsaParameterStorage對象,而後使用 Json.Net 來序列化,映射使用的是我本身寫的一個 Mapper(因此升級項目只花了幾十分鐘,調教 Mapper 花了一天),代碼更改以下:

// 轉換成 json 字符串
static string ToJsonString(this RSAParameters parameters)
{
    var content = parameters.Map().To<RsaParameterStorage>();
    return JsonConvert.SerializeObject(content);
}

// 從文件中讀取
keyParameters = JsonConvert.DeserializeObject<RsaParameterStorage>(File.ReadAllText(filePath)).Map().To<RSAParameters>();
相關文章
相關標籤/搜索