15. 安全鉴权
15.1 什么是鉴权
鉴权实际上就是一种身份认证。
由用户提供凭据,然后将其与存储在操作系统、数据库、应用或资源中的凭据进行比较。 在授权过程中,如果凭据匹配,则用户身份验证成功,可执行已向其授权的操作。 授权指判断允许用户执行的操作的过程。 也可以将身份验证理解为进入空间(例如服务器、数据库、应用或资源)的一种方式,而授权是用户可以对该空间(服务器、数据库或应用)内的哪些对象执行哪些操作。
15.1.1 常见的鉴权方式
HTTP Basic Authentication
这是 HTTP
协议实现的基本认证方式,我们在浏览网页时,从浏览器正上方弹出的对话框要求我们输入账号密码,正是使用了这种认证方式
Session + Cookie
利用服务器端的 session(会话)和浏览器端的 cookie 来实现前后端的认证,由于 http 请求时是无状态的,服务器正常情况下是不知道当前请求之前有没有来过,这个时候我们如果要记录状态,就需要在服务器端创建一个会话(session),将同一个客户端的请求都维护在各自的会话中,每当请求到达服务器端的时候,先去查一下该客户端有没有在服务器端创建 session,如果有则已经认证成功了,否则就没有认证。
Token
客户端在首次登录以后,服务端再次接收 HTTP
请求的时候,就只认 Token
了,请求只要每次把 Token
带上就行了,服务器端会拦截所有的请求,然后校验 Token
的合法性,合法就放行,不合法就返回 401(鉴权失败)
Token
验证比较灵活,适用于大部分场景。常用的 Token
鉴权方式的解决方案是 JWT
,JWT
是通过对带有相关用户信息的进行加密,加密的方式比较灵活,可以根据需求具体设计。
OAuth
OAuth(开放授权)是一个开放标准,允许用户授权第三方网站访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方网站或分享他们数据的所有内容,为了保护用户数据的安全和隐私,第三方网站访问用户数据前都需要显式的向用户征求授权。我们常见的提供 OAuth 认证服务的厂商有支付宝、QQ 和微信。
OAuth 协议又有 1.0 和 2.0 两个版本。相比较 1.0 版,2.0 版整个授权验证流程更简单更安全,也是目前最主要的用户身份验证和授权方式。
15.2 如何使用
在添加授权服务之前,请先确保 Startup.cs
中 Configure
是否添加了以下两个中间件:
app.UseAuthentication();app.UseAuthorization();
15.2.1 添加 Cookie
身份验证
Cookie
身份验证如果您使用的是 WebAPI
,则该小节可忽略,通常 WebAPI
使用的是 JWT
授权方式,而非 Cookie
。
// Cookies单独身份验证services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, b => { b.LoginPath = "/Home/Login"; });
15.2.2 添加 Jwt
身份验证
安装
Furion.Extras.Authentication.JwtBearer
拓展包在
Startup.cs
中注册AddJwt
服务,注意,必须在.AddControllers()
之前注册!!
// 默认授权机制,需授权的即可(方法)需贴 `[Authorize]` 特性services.AddJwt();// 启用全局授权,这样每个接口都必须授权才能访问,无需贴 `[Authorize]` 特性,推荐!!!!!!!!!❤️// services.AddJwt<JwtHandler>(enableGlobalAuthorize:true);
默认 JwtHandler
代码:
using Furion.Authorization;using Microsoft.AspNetCore.Authorization;using Microsoft.AspNetCore.Http;using System.Threading.Tasks;namespace FurionApi.Web.Core;public class JwtHandler : AppAuthorizeHandler{ public override Task<bool> PipelineAsync(AuthorizationHandlerContext context, DefaultHttpContext httpContext) { // 这里写您的授权判断逻辑,授权通过返回 true,否则返回 false return Task.FromResult(true); }}
- 自定义
Jwt
配置(默认无需配置)
{ "JWTSettings": { "ValidateIssuerSigningKey": true, // 是否验证密钥,bool 类型,默认true "IssuerSigningKey": "你的密钥", // 密钥,string 类型,必须是复杂密钥,长度大于16 "ValidateIssuer": true, // 是否验证签发方,bool 类型,默认true "ValidIssuer": "签发方", // 签发方,string 类型 "ValidateAudience": true, // 是否验证签收方,bool 类型,默认true "ValidAudience": "签收方", // 签收方,string 类型 "ValidateLifetime": true, // 是否验证过期时间,bool 类型,默认true,建议true "ExpiredTime": 20, // 过期时间,long 类型,单位分钟,默认20分钟 "ClockSkew": 5, // 过期时间容错值,long 类型,单位秒,默认 5秒 "Algorithm": "HS256" // 加密算法,string 类型,默认 HS256 }}
Furion
框架为了方便开发,已经自动添加了 Jwt
默认配置。建议每个项目都应该单独配置 IssuerSigningKey
,ValidIssuer
,ValidAudience
这三个。否则同样用了 Furion
框架生成的 Token
可能存在相互访问各自系统的风险。
Algorithm
算法支持列表目前支持的加密算法
HS256
HS384
HS512
PS256
PS384
PS512
RS256
RS384
RS512
ES256
ES256K
ES384
ES512
EdDSA
详情请查阅 SecurityAlgorithms
- ❤️ ❤️ 生成
Token
通常我们需要在登录成功之后生成 JWT
Token 并返回,可通过 JWTEncryption.Encrypt
静态方法生成,如:
Token
的值字典 Dictionary
中的值支持所有基元类型和基元类型组成的值,但应尽可能避免使用 数组
值。
// 生成 tokenvar accessToken = JWTEncryption.Encrypt(new Dictionary<string, object>() { { "UserId", user.Id }, // 存储Id { "Account",user.Account }, // 存储用户名 });
15.2.3 混合身份验证
// JWT 和 Cookies 混合身份验证services.AddJwt(options =>{ options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;}).AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>{ options.LoginPath = "/Home/Login";});
如果启用了混合身份验证后,WebApi
需在控制器/Action 中指定 Scheme
类型为 JwtBearerDefaults.AuthenticationScheme
,如:
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]public class ApiServices : IDynamicApiController{}
如果不设置 Scheme
那么在混合授权的 Swagger
中将默认采用 Cookie
方式,也就是授权失败会将整个 登录页面
内容返回。
15.3 高级自定义授权
Furion
框架提供了非常灵活的高级策略鉴权和授权方式,通过该策略授权方式可以实现任何自定义授权。
15.3.1 AppAuthorizeHandler
Furion
框架提供了 AppAuthorizeHandler
策略授权处理程序提供基类,只需要创建自己的 Handler
继承它即可。如:JwtHandler
:
using Furion.Authorization;using Furion.Core;using Microsoft.AspNetCore.Authorization;using Microsoft.AspNetCore.Http;using Microsoft.IdentityModel.JsonWebTokens;namespace Furion.Web.Core{ /// <summary> /// JWT 授权自定义处理程序 /// </summary> public class JwtHandler : AppAuthorizeHandler { /// <summary> /// 请求管道 /// </summary> /// <param name="context"></param> /// <param name="httpContext"></param> /// <returns></returns> public override Task<bool> PipelineAsync(AuthorizationHandlerContext context, DefaultHttpContext httpContext) { // 此处已经自动验证 Jwt token的有效性了,无需手动验证 // 检查权限,如果方法是异步的就不用 Task.FromResult 包裹,直接使用 async/await 即可 return Task.FromResult(CheckAuthorzie(httpContext)); } /// <summary> /// 检查权限 /// </summary> /// <param name="httpContext"></param> /// <returns></returns> private static bool CheckAuthorzie(DefaultHttpContext httpContext) { // 获取权限特性 var securityDefineAttribute = httpContext.GetMetadata<SecurityDefineAttribute>(); if (securityDefineAttribute == null) return true; return "查询数据库返回是否有权限"; } }}
之后注册 JwtHandler
即可:
services.AddJwt<JwtHandler>();
15.3.2 完全自定义授权
有些时候可能针对不同的平台采用不一样的授权方式,比如合作信任的第三方机构可以免授权,这时候我们只需要重写 HandleAsync
方法即可。如:
using Furion.Authorization;using Furion.Core;using Microsoft.AspNetCore.Authorization;using Microsoft.AspNetCore.Http;using System.Threading.Tasks;namespace Furion.Web.Core{ public class JwtHandler : AppAuthorizeHandler { public override async Task HandleAsync(AuthorizationHandlerContext context) { // 常规授权(可以判断是不是第三方) var isAuthenticated = context.User.Identity.IsAuthenticated; // 第三方授权自定义 if(是第三方){ foreach (var requirement in pendingRequirements) { // 授权成功 context.Succeed(requirement); } } // 授权失败 else context.Fail(); } }}
15.4 授权特性及全局授权
默认情况下,所有的路由都是允许匿名访问的,所以如果需要对某个 Action
或 Controller
设定授权访问,只需要在 Action
或 Controller
贴 [AppAuthorize]
或 [Authorize]
特性即可。
如果需要对特定的 Action
或 Controller
允许匿名访问,则贴 [AllowAnonymous]
即可。
15.4.1 全局授权
services.AddJwt<JwtHandler>(enableGlobalAuthorize:true);
15.4.2 匿名访问
如果需要对特定的 Action
或 Controller
允许匿名访问,则贴 [AllowAnonymous]
即可。
15.5 自动刷新 Token
15.5.1 后端登录部分
当用户登录成功之后,返回 accessToken
字符串,之后通过 JWTEncryption.GenerateRefreshToken()
获取 刷新Token
,并通过响应报文头返回,如:
// tokenvar accessToken = JWTEncryption.Encrypt(new Dictionary<string, object>() { { "UserId", user.Id }, // 存储Id { "Account",user.Account }, // 存储用户名 });// 获取刷新 tokenvar refreshToken = JWTEncryption.GenerateRefreshToken(accessToken, 43200); // 第二个参数是刷新 token 的有效期(分钟),默认三十天// 设置响应报文头httpContextAccessor.HttpContext.Response.Headers["access-token"] = accessToken;httpContextAccessor.HttpContext.Response.Headers["x-access-token"] = refreshToken;
用户登录成功之后把 accessToken
和 refreshToken
一起返回给客户端存储起来。
15.5.2 后端授权 Handler
部分
using Furion.Authorization;using Furion.Core;using Furion.DataEncryption;using Microsoft.AspNetCore.Authorization;using Microsoft.AspNetCore.Http;using Microsoft.Extensions.DependencyInjection;using System.Threading.Tasks;namespace Furion.Web.Core{ /// <summary> /// JWT 授权自定义处理程序 /// </summary> public class JwtHandler : AppAuthorizeHandler { /// <summary> /// 重写 Handler 添加自动刷新收取逻辑 /// </summary> /// <param name="context"></param> /// <returns></returns> public override async Task HandleAsync(AuthorizationHandlerContext context) { // 自动刷新 token if (JWTEncryption.AutoRefreshToken(context, context.GetCurrentHttpContext())) { await AuthorizeHandleAsync(context); } else context.Fail(); // 授权失败 } /// <summary> /// 验证管道,也就是验证核心代码 /// </summary> /// <param name="context"></param> /// <param name="httpContext"></param> /// <returns></returns> public override Task<bool> PipelineAsync(AuthorizationHandlerContext context, DefaultHttpContext httpContext) { // 检查权限,如果方法是异步的就不用 Task.FromResult 包裹,直接使用 async/await 即可 return Task.FromResult(true); } }}
15.5.3 客户端部分
客户端每次请求需将 accessToken
和 refreshToken
放到请求报文头中传送到服务端,格式为:
Authorization: Bearer 你的token
X-Authorization: Bearer 你的刷新token
这里简单贴出使用 axios
自动刷新 token
拦截配置示例:
查看完整代码 - https://gitee.com/dotnetchina/Furion/tree/v4/clients/axios_vue_react
建议使用自动生成 Vue/React/Angular
代理方式:5.6 Vue/React/Angular 接口代理
// token 键定义const accessTokenKey = "access-token";const refreshAccessTokenKey = `x-${accessTokenKey}`;// 清除 tokenconst clearAccessTokens = () => { window.localStorage.removeItem(accessTokenKey); window.localStorage.removeItem(refreshAccessTokenKey);};/** * axios 默认实例 */export const axiosInstance: AxiosInstance = globalAxios;// 这里可以配置 axios 更多选项 =========================================// axios 请求拦截axiosInstance.interceptors.request.use( (conf) => { // 将 token 添加到请求报文头中 conf.headers!["Authorization"] = `Bearer ${window.localStorage.getItem( accessTokenKey )}`; conf.headers!["X-Authorization"] = `Bearer ${window.localStorage.getItem( refreshAccessTokenKey )}`; // 这里编写请求拦截代码 ========================================= return conf; }, (error) => { // 这里编写请求错误代码 return Promise.reject(error); });// axios 响应拦截axiosInstance.interceptors.response.use( (res) => { // 获取状态码和返回数据 var status = res.status; var serve = res.data; // 处理 401 if (status === 401) { clearAccessTokens(); } // 处理未进行规范化处理的 if (status >= 400) { throw new Error(res.statusText || "Request Error."); } // 处理规范化结果错误 if (serve && serve.hasOwnProperty("errors") && serve.errors) { throw new Error(JSON.stringify(serve.errors || "Request Error.")); } // 读取响应报文头 token 信息 var accessToken = res.headers[accessTokenKey]; var refreshAccessToken = res.headers[refreshAccessTokenKey]; // 判断是否是无效 token if (accessToken === "invalid_token") { clearAccessTokens(); } // 判断是否存在刷新 token,如果存在则存储在本地 else if ( refreshAccessToken && accessToken && accessToken !== "invalid_token" ) { window.localStorage.setItem(accessTokenKey, accessToken); window.localStorage.setItem(refreshAccessTokenKey, refreshAccessToken); } // 这里编写响应拦截代码 ========================================= return res; }, (error) => { // 这里编写响应错误代码 return Promise.reject(error); });
在正常开发中,refreshToken
无需每次请求携带,而是 accessToken
即将过期之后携带即可。可以在客户端自行判断 accessToken
是否即将过期。
如果 Token
过期,那么 Furion
将自动根据有效期内的 refreshToken
自动生成新的 AccessToken
,并在 响应报文头 中返回,如:
access-token: 新的token
x-access-token: 新的刷新token
前端需要获取 响应报文头 新的 token 和刷新 token 替换之前在客户处存储旧的 token 和刷新 token。
15.6 获取 Jwt
存储的信息
// 获取 `Jwt` 存储的信息var userId = App.User?.FindFirstValue("键");
注意引入 System.Security.Claims
命名空间
Token
信息说明请确保 .AddJwt
服务已注册且启用了 全局授权
或该接口(方法)贴有 [Authorize]
特性。
15.7 前端解密 JWT
信息
通常在用户登录成功后我们会将 JWT Token
存储到浏览器中,这时候就需要在浏览器端解析 token
里面存储的信息,可以通过调用下面方法实现:
TypeScript
版本
/** * 解密 JWT token 的信息 * @param token jwt token 字符串 * @returns <any>object */function decryptJWT(token: string): any { token = token.replace(/_/g, "/").replace(/-/g, "+"); var json = decodeURIComponent(escape(window.atob(token.split(".")[1]))); return JSON.parse(json);}
JavaScript
版本
/** * 解密 JWT token 的信息 * @param token jwt token 字符串 * @returns <any>object */function decryptJWT(token) { token = token.replace(/_/g, "/").replace(/-/g, "+"); var json = decodeURIComponent(escape(window.atob(token.split(".")[1]))); return JSON.parse(json);}
这样就可以把后端放在 token
里面的信息解析出来了。
可以在解密之后读取 过期时间 exp
来解决请求时是否需要带刷新 Token
,比如即将过期前 5
分钟。
15.8 反馈与建议
给 Furion 提 Issue。
想了解更多 鉴权授权
知识可查阅 ASP.NET Core - 安全和标识 章节。