ASP.NET Core 使用 JWT 搭建分散式無狀態身份驗證系統(轉載)
原文地址:https://www.cnblogs.com/JacZhu/p/6837676.html
為什麼使用 Jwt
最近,移動開發的勁頭越來越足,學校搞的各種比賽都需要用手機 APP 來撐場面,所以,作為寫後端的,很有必要改進一下以往的基於 Session 的身份認證方式了,理由如下:
- 移動端經常要保持長時間(1 到 2 星期)線上,但是 Session 卻不好在服務端儲存這麼久,雖然可以持久化到資料庫,但是還是挺費資源
- 移動端往往不是使用的網頁技術,所以藏在 Cookie 裡面的 SessionId 不是很方便的傳遞給服務端
- 服務端暴露給客戶端的介面往往是 RESTful 風格的,這是一種無狀態的 API 風格,所以身份認證的方式最好也是無狀態的才好
所以我選擇了使用 Jwt (Json Web Token) 這個技術。Jwt 是一種無狀態的分散式的身份驗證方式,與 Session 相反,Jwt 將使用者資訊存放在 Token 的 payload 欄位儲存在客戶端,通過 RSA 加密的方式,保證資料不會被篡改,驗證資料有效性。
下面是一個使用 Jwt 的系統的身份驗證流程:
可以看出,使用者的資訊儲存在 Token 中,而 Token 分佈在使用者的裝置中,所以服務端不再需要在記憶體中儲存使用者資訊了
身份認證的 Token 傳遞時以一種相當簡單的格式儲存在 header 中,方便客戶端對其進行操作
Jwt 簡介
Jwt 形式的 token 一般分為 3 個部分,分別是 Header,Payload,Signature,這三個部分使用 .
所以一個 Jwt 看起來大概是這個樣子:
header.payload.signature
下面是一個真實的 Jwt:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1bmlxdWVfbmFtZSI6InplZWtvIiwicm9sZSI6IiIsIm5hbWVpZCI6MSwianRpIjoiNjNjN2Q3OWY2N2VhMDhjYjRiYzNjMmNkOTJiY2JkNTgiLCJuYmYiOjE0OTQ0MDMwMjQsImV4cCI6MTQ5NTAwNzgyMywiaWF0IjoxNDk0NDAzMDI0LCJpc3MiOiJUZXN0SXNzdWVyIiwiYXVkIjoiVGVzdEF1ZGllbmNlIn0 .V7Mfi3FGOTLYV0O5DmOWju7LkDJwZNO6HZN19CHb3ekYxcoVbP51YjYAr0fUHc3RPIp3gxITzziHY-07xZ2swCaV0K-hiF5IbwpDuvyxsnlgaRxS94wKDGKSJkArC82KukCtm7IuFBxnNr6kxe7tGcebVhqtaqgnxEUg5lKtDtVI85kd17YtzBp9Vxnc3Ie0r-6KPgUa2HacCf2Pc3hkvY7tZdWZ6ininZlZ-EbcyZI2KTx-vOqdK63MS2JYSw7W2qwf89tsRsORwbB2P4dOBBFK8YSXJpeyGeJWFEMjAMkiH3AeMmW2w_H7r_6Pn-jh5gozzBei4JoHTU6RVDUg1A
Header
Header 部分一般用來記錄加密演算法跟 Token 型別
舉個例子:
{
"alg": "HS256",
"typ": "JWT"
}
Payload
Payload 存放的是一些不敏感的使用者資料,因為這一部分僅僅只是使用 Base64 加密,所以不應該用來儲存使用者的密碼之類的資訊。
一個例子:
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
Signature
這一部分是 Jwt 最重要的部分,使用 header 中記錄的演算法進行了加密,加密方式如下:
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
所以這個部分可以用來保證使用者資訊不被篡改,起到驗證使用者身份的作用
在開發過程中,可以訪問 https://jwt.io 來除錯 Token
當然,為了更快的訪問速度,還可以使用 這個網站
在 ASP.NET Core 中使用 Jwt
因為 Jwt 本身的特點,所以用來簽發 Token 的伺服器可以跟應用伺服器不是同一臺,這樣就可以搞微服務之類的東西(反正我不懂。。。)
因此,在這篇部落格中,將會建立兩個 Web 應用:
- JwtIssuer // 用來簽發 Token
- JwtAudience // 應用伺服器,提供 API 的
首先來搭建我們的 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) });
}
}
}
在應用伺服器驗證 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 目錄
可以看到生成了兩個json檔案,將其中的 key.public.json 拷貝到應用伺服器的對應的目錄下面,然後執行應用伺服器。
如果我們直接訪問應用伺服器的 API,就會被擋在外面:
所以現在去把之前拿到的 token 複製出來,然後給這個請求加個請求頭——Authorization
值是 Bearer 你的Token
這樣,基本的身份驗證就完成了~
有興趣的話還可以把這個 Token 放在前面提到的用來除錯 Jwt 網站上,我的 Token 的解析結果是:
這裡面的 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 失效的能力就具備了。
升級到 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