.NET Framework4.7.2 制作 Web API 使用 [NSwag] 套件呈现 Swagger UI + JWT Authentication 及跨域处理(CORS)

本文将介绍如何使用 JWT 保持登入状态,配合 Swagger / OpenAPI 呈现。

.NET Framework 4.7.2

开发环境

  • Visual Studio 2019
  • AspNet.Mvc version="5.2.7"
  • AspNet.WebApi version="5.2.7"
  • EntityFramework version="6.1.3"

Web API 使用 JWT Authentication 进行资料验证

参考资料

NuGet 安装套件

  • jose-jwt version="3.2.0"

程序码实作

  1. 建立一个类别 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;
            }
        }
    }
    
  2. 在登入功能的 API 确认登入成功後,生成 JwtToken 并回传给前端

    // GenerateToken() 生成新 JwtToken 用法
    JwtAuthUtil jwtAuthUtil = new JwtAuthUtil();
    string jwtToken = jwtAuthUtil.GenerateToken(userQuery.Id); 
    // 登入成功时,回传登入成功顺便夹带 JwtToken
    return Ok(new { Status = true, JwtToken = jwtToken });
    
  3. 前端将收到的 JWT-Token 字串存入浏览器 localStorage

    https://ithelp.ithome.com.tw/upload/images/20220306/201394877aehPsLye1.jpg

  4. 建立一个类别 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;
            }
        }
    }
    
  5. 使用者用到需登入的 API 时,前端取出浏览器 localStorage 中的 JWT-Token 字串用於请求的 Header 里 Authorization 栏位用 Bearer 规则夹带

  6. 将 [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 ~
    // 处理完请求内容後,顺便送出刷新效期的 JwtToken
    return Ok(new { Status = true, JwtToken = jwtToken });
    
  7. 用於强制登出使用者的方式

    // RevokeToken() 生成失效的 JwtToken 用法
    JwtAuthUtil jwtAuthUtil = new JwtAuthUtil();
    string jwtToken = jwtAuthUtil.RevokeToken();
    // 用於登出使用者时,刷新为失效的 JwtToken
    return Ok(new { Status = true, jwtToken = jwtToken });
    

Web API 使用 [NSwag] 套件呈现 Swagger UI

参考资料

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"

程序码实作

  1. 在专案新增 OWIN 启动类别 Startup.cs,并加入相关设定

    https://ithelp.ithome.com.tw/upload/images/20220305/201394870JiGbKTEHL.jpg

    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();
            }
        }
    }
    
  2. 於专案属性建置内容勾选输出 XML 文件档案, Swagger UI 才会有方法及参数说明

    https://ithelp.ithome.com.tw/upload/images/20220305/20139487ECH4vCNbkV.jpg

  3. 於 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>
    
  4. 於 API 及输入栏位加上 /// 撰写 XML 方法及参数说明

  5. 使用登入 API 取得回传的 JWT token 於 Swagger UI 的 Authorization JWT token 输入验证时,使用 Bearer + 空格 + JWT token 夹带

    https://ithelp.ithome.com.tw/upload/images/20220305/20139487ge4lNo0f8V.jpg

  6. 可於 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 });
     	}
    }
    

    https://ithelp.ithome.com.tw/upload/images/20220310/20139487DbSPitvvSq.jpg

跨域处理(CORS)

由於使用 OWIN 启动会改成由 Startup.cs 类别管理,因此需将放行的请求类型及跨域操作加入。

参考资料

NuGet 安装套件

  • Microsoft.Owin.Cors version="4.2.0"
  • Microsoft.Owin.Security.OAuth version="4.2.0"

程序码实作

  1. 删除原 Web API 2 官方建议的作法,於 NuGet 移除 Microsoft.AspNet.WebApi.Cors

  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);
            }
        }
    }
    
  3. 新增 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"});
            }
        }
    }
    
  4. 於 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
    }
}

https://ithelp.ithome.com.tw/upload/images/20220310/20139487RgGjPa5auE.jpg


<<:  【HTML】【CSS】图片下方的空白

>>:  Python读取MySQL资料库bool值後,判断式的有趣问题

[Day 11] Leetcode 152. Maximum Product Subarray (C++)

前言 先来个之前写过觉得还算值得练习的DP题目~152. Maximum Product Subar...

iOS App开发 OC 第四天, OC 的基础语法 & 编译,链结,执行

从Swift 到 OC 第四天, OC 的基础语法 & 编译,链结,执行 tags: OC ...

15. 做对事是不够的,你还必须要有影响力。

前言 这篇演讲适合所有人听,特别是当你觉得「为什麽我明明在做对的事情,但大家都不接受我的意见」时,...

【31】30天在Colab尝试的30个影像分类训练实验 - 完赛心得

比赛动机 这是我第三次参加铁人赛,每次参赛都刚好隔一年,後来我发现这样的间隔其实很刚好,因为在中间的...

Day 30: 关於 Design Pattern,来点心理测验吧

缘由 现在对模式有个初步的了解,想试着写出「设计模式」的心理测验。 测验开始 问题区 问题 1 你这...