本文将介绍如何使用 JWT 保持登入状态,配合 Swagger / OpenAPI 呈现。
.NET Framework 4.7.2
开发环境
Visual Studio 2019AspNet.Mvc version="5.2.7"AspNet.WebApi version="5.2.7"EntityFramework version="6.1.3"Web API 使用 JWT Authentication 进行资料验证
参考资料
ASP. NET Web Api 2 透过 JWT 进行资料验证 (@后端实作主要参考)JSON Web Token 入门教程JSON Web Token (JWT) ProfileOAuth 2.0 笔记 (6) Bearer Token 的使用方法[Web] 以 axios 实践前端 refresh token 机制 (@前端配合实作参考)理解OAuth 2.0注册及 JWT-Token 短期保持登入流程 (@前后端协作流程-本文作者绘製,仅供参考)开发者必备知识 - HTTP认证(HTTP Authentication)[笔记] 认识 OAuth 2.0:一次了解各角色、各类型流程的差异认识Cookie、Session、Token与JWTNuGet 安装套件
jose-jwt version="3.2.0"程式码实作
建立一个类别 JwtAuthUtil.cs,负责 token 生成相关功能
using Jose;using System;using System.Collections.Generic;using System.Text;using System.Web.Configuration;using MyWebApiProject.Models;namespace MyWebApiProject.Security{/// <summary> /// JwtToken 生成功能 /// </summary> public class JwtAuthUtil { private readonly ApplicationDbContext db = new ApplicationDbContext(); // DB 连线 /// <summary> /// 生成 JwtToken /// </summary> /// <param name="id">会员id</param> /// <returns>JwtToken</returns> public string GenerateToken(int id) {// 自订字串,验证用,用来加密送出的 key (放在 Web.config 的 appSettings) string secretKey = WebConfigurationManager.AppSettings["TokenKey"]; // 从 appSettings 取出 var user = db.User.Find(id); // 进 DB 取出想要夹带的基本资料// payload 需透过 token 传递的资料 (可夹带常用且不重要的资料) var payload = new Dictionary<string, object> { { "Id", user.Id }, { "Account", user.Account }, { "NickName", user.NickName }, { "Image", user.Image }, { "Exp", DateTime.Now.AddMinutes(30).ToString() } // JwtToken 时效设定 30 分 };// 产生 JwtToken var token = JWT.Encode(payload, Encoding.UTF8.GetBytes(secretKey), JwsAlgorithm.HS512); return token; } /// <summary> /// 生成只刷新效期的 JwtToken /// </summary> /// <returns>JwtToken</returns> public string ExpRefreshToken(Dictionary<string, object> tokenData) { string secretKey = WebConfigurationManager.AppSettings["TokenKey"];// payload 从原本 token 传递的资料沿用,并刷新效期 var payload = new Dictionary<string, object> { { "Id", (int)tokenData["Id"] }, { "Account", tokenData["Account"].ToString() }, { "NickName", tokenData["NickName"].ToString() }, { "Image", tokenData["Image"].ToString() }, { "Exp", DateTime.Now.AddMinutes(30).ToString() } // JwtToken 时效刷新设定 30 分 };//产生刷新时效的 JwtToken var token = JWT.Encode(payload, Encoding.UTF8.GetBytes(secretKey), JwsAlgorithm.HS512); return token; } /// <summary> /// 生成无效 JwtToken /// </summary> /// <returns>JwtToken</returns> public string RevokeToken() { string secretKey = "RevokeToken"; // 故意用不同的 key 生成 var payload = new Dictionary<string, object> { { "Id", 0 }, { "Account", "None" }, { "NickName", "None" }, { "Image", "None" }, { "Exp", DateTime.Now.AddDays(-15).ToString() } // 使 JwtToken 过期 失效 };// 产生失效的 JwtToken var token = JWT.Encode(payload, Encoding.UTF8.GetBytes(secretKey), JwsAlgorithm.HS512); return token; } }}
在登入功能的 API 确认登入成功后,生成 JwtToken 并回传给前端
// GenerateToken() 生成新 JwtToken 用法JwtAuthUtil jwtAuthUtil = new JwtAuthUtil();string jwtToken = jwtAuthUtil.GenerateToken(userQuery.Id); // 登入成功时,回传登入成功顺便夹带 JwtTokenreturn Ok(new { Status = true, JwtToken = jwtToken });
前端
将收到的 JWT-Token 字串存入浏览器 localStorage
建立一个类别 JwtAuthFilter.cs,负责生成 [JwtAuthFilter] 标籤,可放于需登入的 API 上,用来检核 JWT-Token 是否正确
using Jose;using Newtonsoft.Json;using System;using System.Collections.Generic;using System.Net.Http;using System.Text;using System.Web.Configuration;using System.Web.Http;using System.Web.Http.Controllers;using System.Web.Http.Filters;namespace MyWebApiProject.Security{ /// <summary> /// JwtAuthFilter 继承 ActionFilterAttribute 可生成 [JwtAuthFilter] 使用 /// </summary> public class JwtAuthFilter : ActionFilterAttribute { // 加解密的 key,如果不一样会无法成功解密 private static readonly string secretKey = WebConfigurationManager.AppSettings["TokenKey"]; /// <summary> /// 过滤有用标籤 [JwtAuthFilter] 请求的 API 的 JwtToken 状态及内容 /// </summary> /// <param name="actionContext"></param> public override void OnActionExecuting(HttpActionContext actionContext) { // 取出请求内容并排除不需要验证的 API var request = actionContext.Request; if (!WithoutVerifyToken(request.RequestUri.ToString())) { // 有取到 JwtToken 后,判断授权格式不存在且不正确时 if (request.Headers.Authorization == null || request.Headers.Authorization.Scheme != "Bearer") { // 可考虑配合前端专案开发期限,不修改 StatusCode 预设 200,将请求失败搭配 Status: false 供前端判断 string messageJson = JsonConvert.SerializeObject(new { Status = false, Message = "请重新登入" }); // JwtToken 遗失,需导引重新登入 var errorMessage = new HttpResponseMessage() { // StatusCode = System.Net.HttpStatusCode.Unauthorized, // 401 ReasonPhrase = "JwtToken Lost", Content = new StringContent(messageJson, Encoding.UTF8, "application/json") }; throw new HttpResponseException(errorMessage); // Debug 模式会停在此行,点继续执行即可 } else { try { // 有 JwtToken 且授权格式正确时执行,用 try 包住,因为如果有篡改可能解密失败 // 解密后会回传 Json 格式的物件 (即加密前的资料) var jwtObject = GetToken(request.Headers.Authorization.Parameter); // 检查有效期限是否过期,如 JwtToken 过期,需导引重新登入 if (IsTokenExpired(jwtObject["Exp"].ToString())) { string messageJson = JsonConvert.SerializeObject(new { Status = false, Message = "请重新登入" }); // JwtToken 过期,需导引重新登入 var errorMessage = new HttpResponseMessage() { // StatusCode = System.Net.HttpStatusCode.Unauthorized, // 401 ReasonPhrase = "JwtToken Expired", Content = new StringContent(messageJson, Encoding.UTF8, "application/json") }; throw new HttpResponseException(errorMessage); // Debug 模式会停在此行,点继续执行即可 } } catch (Exception) { // 解密失败 string messageJson = JsonConvert.SerializeObject(new { Status = false, Message = "请重新登入" }); // JwtToken 不符,需导引重新登入 var errorMessage = new HttpResponseMessage() { // StatusCode = System.Net.HttpStatusCode.Unauthorized, // 401 ReasonPhrase = "JwtToken NotMatch", Content = new StringContent(messageJson, Encoding.UTF8, "application/json") }; throw new HttpResponseException(errorMessage); // Debug 模式会停在此行,点继续执行即可 } } } base.OnActionExecuting(actionContext); } /// <summary> /// 将 Token 解密取得夹带的资料 /// </summary> /// <param name="token"></param> /// <returns></returns> public static Dictionary<string, object> GetToken(string token) { return JWT.Decode<Dictionary<string, object>>(token, Encoding.UTF8.GetBytes(secretKey), JwsAlgorithm.HS512); } /// <summary> /// 有在 Global 设定一律检查 JwtToken 时才需设定排除,例如 Login 不需要验证因为还没有 token /// </summary> /// <param name="requestUri"></param> /// <returns></returns> public bool WithoutVerifyToken(string requestUri) { //if (requestUri.EndsWith("/login")) return true; return false; } /// <summary> /// 验证 token 时效 /// </summary> /// <param name="dateTime"></param> /// <returns></returns> public bool IsTokenExpired(string dateTime) { return Convert.ToDateTime(dateTime) < DateTime.Now; } }}
使用者用到需登入的 API 时,前端
取出浏览器 localStorage 中的 JWT-Token 字串用于请求的 Header 里 Authorization 栏位用 Bearer 规则夹带
将 [JwtAuthFilter] 标籤,放于需登入的 API 上,检核 JWT-Token 是否正确,并刷新效期
// 取出请求内容,解密 JwtToken 取出资料var userToken = JwtAuthFilter.GetToken(Request.Headers.Authorization.Parameter);// ExpRefreshToken() 生成刷新效期 JwtToken 用法JwtAuthUtil jwtAuthUtil = new JwtAuthUtil();string jwtToken = jwtAuthUtil.ExpRefreshToken(userToken);// Do Something ~// 处理完请求内容后,顺便送出刷新效期的 JwtTokenreturn Ok(new { Status = true, JwtToken = jwtToken });
用于强制登出使用者的方式
// RevokeToken() 生成失效的 JwtToken 用法JwtAuthUtil jwtAuthUtil = new JwtAuthUtil();string jwtToken = jwtAuthUtil.RevokeToken();// 用于登出使用者时,刷新为失效的 JwtTokenreturn Ok(new { Status = true, jwtToken = jwtToken });
Web API 使用 [NSwag] 套件呈现 Swagger UI
参考资料
ASP.NET WebAPI 2 整合 NSwag (@后端实作主要参考)如何在 ASP․NET Core 3 完整设定 NSwag 与 OpenAPI v3 文件ASP.NET WebAPI 2 + NSwag - 实作简单 ApiKey Header + IP 权限管控NuGet 安装套件
NSwag.AspNet.Owin version="13.15.9"NSwag.Annotations version="13.2.5"Microsoft.AspNet.WebApi.Owin version="5.2.7"Microsoft.Owin.Host.SystemWeb version="4.2.0"程式码实作
在专案新增 OWIN 启动类别 Startup.cs,并加入相关设定
using Microsoft.Owin;using NSwag;using NSwag.AspNet.Owin;using NSwag.Generation.Processors.Security;using Owin;using System.Web.Http;[assembly: OwinStartup(typeof(MyWebApiProject.Startup))]namespace MyWebApiProject{ /// <summary> /// OWIN 启动类别 /// </summary> public class Startup { /// <summary> /// 应用程式配置 /// </summary> /// <param name="app"></param> public void Configuration(IAppBuilder app) { // 如需如何设定应用程式的详细资讯,请浏览 https://go.microsoft.com/fwlink/?LinkID=316888 var config = new HttpConfiguration(); // 针对 JSON 资料使用 camel (JSON 回应会改 camel,但 Swagger 提示不会) //config.Formatters.JsonFormatter.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver(); app.UseSwaggerUi3(typeof(Startup).Assembly, settings => { // 针对 WebAPI,指定路由包含 Action 名称 settings.GeneratorSettings.DefaultUrlTemplate = "api/{controller}/{action}/{id?}"; // 加入客製化调整逻辑名称版本等 settings.PostProcess = document => { document.Info.Title = "WebAPI : 专案名称"; }; // 加入 Authorization JWT token 定义 settings.GeneratorSettings.DocumentProcessors.Add(new SecurityDefinitionAppender("Bearer", new OpenApiSecurityScheme() { Type = OpenApiSecuritySchemeType.ApiKey, Name = "Authorization", Description = "Type into the textbox: Bearer {your JWT token}.", In = OpenApiSecurityApiKeyLocation.Header, Scheme = "Bearer" // 不填写会影响 Filter 判断错误 })); // REF: https://github.com/RicoSuter/NSwag/issues/1304 (每支 API 单独呈现认证 UI 图示) settings.GeneratorSettings.OperationProcessors.Add(new OperationSecurityScopeProcessor("Bearer")); }); app.UseWebApi(config); config.MapHttpAttributeRoutes(); config.EnsureInitialized(); } }}
于专案属性的建置内容勾选输出 XML 文件档案, Swagger UI 才会有方法及参数说明
于 Web.config 加入处理器设定,将 URL /swagger/* 导向 NSwag 处理程式
<configuration><system.webServer><handlers><add name="NSwag" path="swagger" verb="*" type="System.Web.Handlers.TransferRequestHandler" preCondition="integratedMode,runtimeVersionv4.0" /></handlers></system.webServer></configuration>
于 API 及输入栏位加上 /// 撰写 XML 方法及参数说明
使用登入 API 取得回传的 JWT token 于 Swagger UI 的 Authorization JWT token 输入验证时,使用 Bearer + 空格 + JWT token 夹带
可于 ApiController 上加入 [OpenApiTag] 描述整个模组功能
[OpenApiTag("Users", Description = "使用者操作功能")]public class UsersController : ApiController{/// <summary> /// 1-5 联络我们功能 (JWT) /// </summary> /// <param name="contactUsVm">留言资料</param> /// <returns></returns>/// <summary> [JwtAuthFilter] // 用于检核 JWT-Token [HttpPost] [SwaggerResponse(typeof(ApiResult))] // 显示回传资料的注解 [Route("api/users/contact-us")] public IHttpActionResult SendContactUsMail(ContactUsVm contactUsVm) { // Do Sometning // 取出请求内容,解密 JwtToken 取出资料 var userToken = JwtAuthFilter.GetToken(Request.Headers.Authorization.Parameter); //单纯刷新效期不新生成,新生成会进资料库 JwtAuthUtil jwtAuthUtil = new JwtAuthUtil(); string jwtToken = jwtAuthUtil.ExpRefreshToken(userToken); // 送出刷新 JwtToken return Ok(new ApiResult { Status = true, JwtToken = jwtToken });}}
跨域处理(CORS)
由于使用 OWIN 启动会改成由 Startup.cs 类别管理,因此需将放行的请求类型及跨域操作加入。
参考资料
API串接常见问题系列文C# Owin初探 概念理解(一)Web API OWIN CORS Handling No Access-Control-Allow-Origin Header (@后端实作主要参考)NuGet 安装套件
Microsoft.Owin.Cors version="4.2.0"Microsoft.Owin.Security.OAuth version="4.2.0"程式码实作
删除原 Web API 2 官方建议的作法,于 NuGet 移除 Microsoft.AspNet.WebApi.Cors
在 ASP.NET Web API 2 中启用跨原始来源要求于 Startup.cs 设定最上方加入启用跨域及验证
using Microsoft.Owin;using Microsoft.Owin.Security.OAuth;using NSwag;using NSwag.AspNet.Owin;using NSwag.Generation.Processors.Security;using Owin;using System.Web.Http;using Thak_tshehWebAPI.Security;[assembly: OwinStartup(typeof(MyWebApiProject.Startup))]namespace MyWebApiProject{ /// <summary> /// OWIN 启动类别 /// </summary> public class Startup { /// <summary> /// 应用程式配置 /// </summary> /// <param name="app"></param> public void Configuration(IAppBuilder app) { // 启用跨域及验证配置 ConfigureAuth(app); var config = new HttpConfiguration(); // 针对 JSON 资料使用 camel (JSON 回应会改 camel,但 Swagger 提示不会) //config.Formatters.JsonFormatter.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver(); app.UseSwaggerUi3(typeof(Startup).Assembly, settings => { // 针对 WebAPI,指定路由包含 Action 名称 settings.GeneratorSettings.DefaultUrlTemplate = "api/{controller}/{action}/{id?}"; // 加入客製化调整逻辑名称版本等 settings.PostProcess = document => { document.Info.Title = "WebAPI : 专案名称"; }; // 加入 Authorization JWT token 定义 settings.GeneratorSettings.DocumentProcessors.Add(new SecurityDefinitionAppender("Bearer", new OpenApiSecurityScheme() { Type = OpenApiSecuritySchemeType.ApiKey, Name = "Authorization", Description = "Type into the textbox: Bearer {your JWT token}.", In = OpenApiSecurityApiKeyLocation.Header, Scheme = "Bearer" // 不填写会影响 Filter 判断错误 })); // REF: https://github.com/RicoSuter/NSwag/issues/1304 (每支 API 单独呈现认证 UI 图示) settings.GeneratorSettings.OperationProcessors.Add(new OperationSecurityScopeProcessor("Bearer")); }); app.UseWebApi(config); config.MapHttpAttributeRoutes(); config.EnsureInitialized(); } /// <summary> /// 启用跨域及验证配置 /// </summary> /// <param name="app"></param> private void ConfigureAuth(IAppBuilder app) { // 建立 OAuth 配置 var oAuthOptions = new OAuthAuthorizationServerOptions { Provider = new AuthorizationServerProvider() }; // 启用 OAuth2 bearer tokens 验证并加入配置 app.UseOAuthAuthorizationServer(oAuthOptions); } }}
新增 AuthorizationServerProvider.cs 类别档,设定跨域 Request Headers 处理逻辑
using Microsoft.Owin;using Microsoft.Owin.Security.OAuth;using System;using System.Configuration;using System.Linq;using System.Threading.Tasks;namespace MyWebApiProject.Security{ /// <summary> /// OAuth 配置并继承 OAuthAuthorizationServerProvider /// </summary> public class AuthorizationServerProvider : OAuthAuthorizationServerProvider { /// <summary> /// 在验证客户端身分前调用,并依客户端请求来源配置 CORS 允许类型设定 /// </summary> /// <param name="context"></param> /// <returns></returns> public override Task MatchEndpoint(OAuthMatchEndpointContext context) { // 依请求来源配置 CORS 允许类型设定 SetCORSPolicy(context.OwinContext); // 如果请求为预检请求则设为完成直接回传 if (context.Request.Method == "OPTIONS") { context.RequestCompleted(); return Task.FromResult(0); } return base.MatchEndpoint(context); } /// <summary> /// 依请求来源配置 CORS 允许类型设定 /// </summary> /// <param name="context"></param> private void SetCORSPolicy(IOwinContext context) { // 取出允许跨域的网址 (放在 Web.config 的 appSettings) string allowedUrls = ConfigurationManager.AppSettings["allowedOrigins"]; // 有填写允许跨域的网址,就分割取出判断请求的来源是否等于允许跨域的网址,并将允许网址加入 Headers if (!String.IsNullOrWhiteSpace(allowedUrls)) { var list = allowedUrls.Split(','); if (list.Length > 0) { string origin = context.Request.Headers.Get("Origin"); var found = list.Where(item => item == origin).Any(); if (found) { context.Response.Headers.Add("Access-Control-Allow-Origin", new string[] { origin }); } } } // 配置允许请求的 Headers 内容 context.Response.Headers.Add("Access-Control-Allow-Headers", new string[] { "Authorization", "Content-Type" }); // 配置允许请求的 Headers 方法 context.Response.Headers.Add("Access-Control-Allow-Methods", new string[] { "OPTIONS", "GET", "POST", "PUT", "DELETE"}); } }}
于 Web.config 加入允许前端跨域请求的网址 (若无需求可不填)
可填测试网域或前端开发时使用的本地端网域,用逗点区隔添加<configuration> <appSettings> <!--Owin CORS--> <add key="allowedOrigins" value="https://www.myfriendproject.com,https://localhost:44444,http://127.0.0.1:5500" /> </appSettings></configuration>
注意事项
複杂型别 ⇒ 会自动转到 Body ⇒ 所以如果要当 GET 用并夹在 uri ⇒ API输入资料型别前才要加 [FromUri] ⇒ Swagger UI 会变成每个值都是个别输入栏位简单型别 ⇒ 会自动转到 Uri ⇒ 所以如果要当 POST 用并藏到 body ⇒ API输入资料型别前才要加 [FromBody] ⇒ Swagger UI 会变成一个可以输入多行的栏位[OpenApiTag("Users", Description = "使用者操作功能")]public class UsersController : ApiController{ /// <summary> /// 1-5 联络我们功能 (JWT) /// </summary> /// <param name="contactUsVm">留言资料</param> /// <returns></returns> /// <summary> [JwtAuthFilter] // 用于检核 JWT-Token [HttpPost] [SwaggerResponse(typeof(ApiResult))] // 显示回传资料的注解 [Route("api/users/contact-us")] public IHttpActionResult SendContactUsMail([FromUri] ContactUsVm contactUsVm) { // Do Sometning }}