1. 程式人生 > >嘗試從零開始構建我的商城 (二) :使用JWT保護我們的資訊保安,完善Swagger配置

嘗試從零開始構建我的商城 (二) :使用JWT保護我們的資訊保安,完善Swagger配置

## 前言 ### GitHub地址 > https://github.com/yingpanwang/MyShop/tree/dev_jwt > 此文對應分支 dev_jwt ### 此文目的  上一篇文章中,我們使用Abp vNext構建了一個可以執行的簡單的API,但是整個站點沒有一個途徑去對我們的API訪問有限制,導致API完全是裸露在外的,如果要執行正常的商業API是完全不可行的,所以接下來我們會通過使用JWT(Json Web Toekn)的方式實現對API的訪問資料限制。 #### JWT簡介 ###### 什麼是JWT 現在API一般是分散式且要求是無狀態的,所以傳統的Session無法使用,JWT其實類似於早期API基於Cookie自定義使用者認證的形式,只是JWT的設計更為緊湊和易於擴充套件開放,使用方式更加便利基於JWT的鑑權機制類似於http協議也是無狀態的,它不需要在服務端去保留使用者的認證資訊或者會話資訊。 ###### JWT的組成 JWT (以下簡稱Token)的組成分為三部分 header,playload,signature 完整的Token長這樣 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoid2FuZ3lpbmdwYW4iLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6IjIiLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9tb2JpbGVwaG9uZSI6IjEyMiIsImV4cCI6MTYwNDI4MzczMSwiaXNzIjoiTXlTaG9wSXNzdWVyIiwiYXVkIjoiTXlTaG9wQXVkaWVuY2UifQ.U-2bEniEz82ECibBzk6C5tuj2JAdqISpbs5VrpA8W9s **header** 包含型別及演算法資訊 ``` JSON { "alg": "HS256", "typ": "JWT" } ``` 通過base64加密後得到了 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 **playload** 包含標準的公開的生命和私有的宣告,不建議定義敏感資訊,因為該資訊是可以被解密的 部分公開的宣告 * iss: jwt簽發者 * aud: 接收jwt的一方 * exp: jwt的過期時間,這個過期時間必須要大於簽發時間 私有的宣告 * http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name : 名稱 * http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier: 標識 * http://schemas.xmlsoap.org/ws/2005/05/identity/claims/mobilephone: 電話 ``` JSON { "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name": "wangyingpan", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier": "2", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/mobilephone": "122", "exp": 1604283731, "iss": "MyShopIssuer", "aud": "MyShopAudience" } ``` 通過base64加密後得到了 eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoid2FuZ3lpbmdwYW4iLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6IjIiLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9tb2JpbGVwaG9uZSI6IjEyMiIsImV4cCI6MTYwNDI4MzczMSwiaXNzIjoiTXlTaG9wSXNzdWVyIiwiYXVkIjoiTXlTaG9wQXVkaWVuY2UifQ **signature** signature 由 三部分資訊組成,分別為base64加密後的**header**,**playload**用"."連線起來,通過宣告的演算法加密使用 服務端**secret** 作為鹽(**salt**) 三部分通過“.”連線起三部分組成了最終的Token ## 程式碼實踐 ### 新增使用者服務 ##### 1.Domain中定義使用者實體User ``` csharp public class User:BaseGuidEntity { [Required] public string Account { get; set; } /// /// 名稱 ///
public string NickName { get; set; } /// /// 真實名稱 /// public string RealName { get; set; } /// /// 年齡 /// public int Age { get; set; } /// /// 手機號 /// public string Tel { get; set; } /// /// 住址 ///
public string Address { get; set; } /// /// 密碼 /// public string Password { get; set; } /// /// 使用者狀態 /// public UserStatusEnum UserStatus { get; set; } } public enum UserStatusEnum { Registered,//已註冊 Incompleted, // 未完善資訊 Completed,//完善資訊 Locked, // 鎖定 Deleted // 刪除 } ``` ##### 2.EntityFrameworkCore 中新增Table **UserCreatingExtension.cs** ``` csharp public static class UserCreatingExtension { public static void ConfigureUserStore(this ModelBuilder builder) { Check.NotNull(builder, nameof(builder)); builder.Entity(option => { option.ToTable("User"); option.ConfigureByConvention(); }); } } ``` **MyShopDbContext.cs** ``` csharp [ConnectionStringName("Default")] public class MyShopDbContext:AbpDbContext { public MyShopDbContext(DbContextOptions options) : base(options) { } protected override void OnModelCreating(ModelBuilder builder) { builder.ConfigureProductStore(); builder.ConfigureOrderStore(); builder.ConfigureOrderItemStore(); builder.ConfigureCategoryStore(); builder.ConfigureBasketAndItemsStore(); // 配置使用者表 builder.ConfigureUserStore(); } public DbSet Products { get; set; } public DbSet Orders { get; set; } public DbSet OrderItems { get; set; } public DbSet Categories { get; set; } public DbSet Basket { get; set; } public DbSet BasketItems { get; set; } //新增使用者表 public DbSet Users { get; set; } } ``` ##### 3.遷移生成User表 首先新增使用者表定義 ``` csharp [ConnectionStringName("Default")] public class DbMigrationsContext : AbpDbContext { public DbMigrationsContext(DbContextOptions options) : base(options) { } protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); builder.ConfigureProductStore(); builder.ConfigureOrderStore(); builder.ConfigureOrderItemStore(); builder.ConfigureCategoryStore(); builder.ConfigureBasketAndItemsStore(); // 使用者配置 builder.ConfigureUserStore(); } } ``` 然後開啟程式包管理控制檯切換為遷移專案**MyShop.EntityFrameworkCore.DbMigration**並輸入 * >Add-Migration "AddUser" * >Update-Database 此時User表就已經生成並對應Migration檔案 ##### 4.Application中構建UserApplication ###### Api中新增配置AppSetting.json ``` csharp "Jwt": { "SecurityKey": "1111111111111111111111111111111", "Issuer": "MyShopIssuer", "Audience": "MyShopAudience", "Expires": 30 // 過期分鐘 } ``` ###### IUserApplicationService ``` csharp namespace MyShop.Application.Contract.User { public interface IUserApplicationService { Task> Register(UserRegisterDto registerInfo, CancellationToken cancellationToken); Task> Login(UserLoginDto loginInfo); } } ``` ###### AutoMapper Profiles中新增對映 ``` csharp namespace MyShop.Application.AutoMapper.Profiles { public class MyShopApplicationProfile:Profile { public MyShopApplicationProfile() { CreateMap().ReverseMap(); CreateMap().ReverseMap(); CreateMap().ReverseMap(); CreateMap().ReverseMap(); CreateMap().ReverseMap(); // 使用者註冊資訊對映 CreateMap() .ForMember(src=>src.UserStatus ,opt=>opt.MapFrom(src=> UserStatusEnum.Registered)) .ForMember(src=>src.Password , opt=>opt.MapFrom(src=> EncryptHelper.MD5Encrypt(src.Password,string.Empty))); } } } ``` ###### UserApplicationService ``` csharp namespace MyShop.Application { /// /// 使用者服務 ///
public class UserApplicationService : ApplicationService, IUserApplicationService { private readonly IConfiguration _configuration; private readonly IRepository _userRepository; /// /// 構造 /// /// 使用者倉儲 /// 配置資訊 public UserApplicationService(IRepository userRepository, IConfiguration configuration) { _userRepository = userRepository; _configuration = configuration; } /// /// 登入 /// /// 登入資訊 /// public async Task> Login(UserLoginDto loginInfo) { if (string.IsNullOrEmpty(loginInfo.Account) || string.IsNullOrEmpty(loginInfo.Password)) return BaseResult.Failed("使用者名稱密碼不能為空!"); var user = await Task.FromResult(_userRepository.FirstOrDefault(p => p.Account == loginInfo.Account)); if (user == null) { return BaseResult.Failed("使用者名稱密碼錯誤!"); } string md5Pwd = EncryptHelper.MD5Encrypt(loginInfo.Password); if (user.Password != md5Pwd) { return BaseResult.Failed("使用者名稱密碼錯誤!"); } var claims = GetClaims(user); var token = GenerateToken(claims); return BaseResult.Success(token); } /// /// 註冊 /// /// 註冊資訊 /// 取消令牌 /// public async Task> Register(UserRegisterDto registerInfo,CancellationToken cancellationToken) { var user = ObjectMapper.Map(registerInfo); var registeredUser = await _userRepository.InsertAsync(user, true, cancellationToken); var claims = GetClaims(user); var token = GenerateToken(claims); return BaseResult.Success(token); } #region Token生成 private IEnumerable GetClaims(User user) { var claims = new[] { new Claim(AbpClaimTypes.UserName,user.NickName), new Claim(AbpClaimTypes.UserId,user.Id.ToString()), new Claim(AbpClaimTypes.PhoneNumber,user.Tel), new Claim(AbpClaimTypes.SurName, user.UserStatus == UserStatusEnum.Completed ?user.RealName:string.Empty) }; return claims; } /// /// 生成token /// /// 宣告 /// private TokenInfo GenerateToken(IEnumerable claims) { // 金鑰 var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["Jwt:SecurityKey"])); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); // 過期時間 int expires = string.IsNullOrEmpty(_configuration["Expires"]) ? 30 : Convert.ToInt32(_configuration["Expires"]); //生成token var token = new JwtSecurityToken( issuer: _configuration["Jwt:Issuer"], audience: _configuration["Jwt:Audience"], claims: claims, expires: DateTime.Now.AddMinutes(expires), signingCredentials: creds); return new TokenInfo() { Expire = expires, Token = new JwtSecurityTokenHandler().WriteToken(token) }; } #endregion } } ``` ### 啟用認證並新增相關Swagger配置 在需要啟動認證的站點模組中新增以下程式碼(MyShopApiModule) ###### MyShopApiModule.cs ``` csharp using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.PlatformAbstractions; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; using MyShop.Admin.Application; using MyShop.Admin.Application.Services; using MyShop.Api.Middleware; using MyShop.Application; using MyShop.Application.Contract.Order; using MyShop.Application.Contract.Product; using MyShop.EntityFrameworkCore; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Threading.Tasks; using Volo.Abp; using Volo.Abp.AspNetCore; using Volo.Abp.AspNetCore.Mvc; using Volo.Abp.AspNetCore.Mvc.Conventions; using Volo.Abp.AspNetCore.Mvc.ExceptionHandling; using Volo.Abp.Autofac; using Volo.Abp.Modularity; namespace MyShop.Api { // 注意是依賴於AspNetCoreMvc 而不是 AspNetCore [DependsOn(typeof(AbpAspNetCoreMvcModule),typeof(AbpAutofacModule))] [DependsOn(typeof(MyShopApplicationModule),typeof(MyShopEntityFrameworkCoreModule),typeof(MyShopAdminApplicationModule))] public class MyShopApiModule :AbpModule { public override void ConfigureServices(ServiceConfigurationContext context) { var service = context.Services; // 配置jwt ConfigureJwt(service); // 配置跨域 ConfigureCors(service); // 配置swagger ConfigureSwagger(service); service.Configure((AbpAspNetCoreMvcOptions options) => { options.ConventionalControllers.Create(typeof(Application.ProductApplicationService).Assembly); options.ConventionalControllers.Create(typeof(Application.OrderApplicationService).Assembly); options.ConventionalControllers.Create(typeof(Application.UserApplicationService).Assembly); options.ConventionalControllers.Create(typeof(Application.BasketApplicationService).Assembly); options.ConventionalControllers.Create(typeof(Admin.Application.Services.ProductApplicationService).Assembly, options => { options.RootPath = "admin"; }); }); } public override void OnApplicationInitialization(ApplicationInitializationContext context) { var env = context.GetEnvironment(); var app = context.GetApplicationBuilder(); if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } // 跨域 app.UseCors("AllowAll"); // swagger app.UseSwagger(); app.UseSwaggerUI(options => { options.SwaggerEndpoint("/swagger/v1/swagger.json", "MyShopApi"); }); app.UseRouting(); //新增jwt驗證 注意:必須先新增認證再新增授權中介軟體,且必須新增在UseRouting 和UseEndpoints之間 app.UseAuthentication(); app.UseAuthorization(); app.UseConfiguredEndpoints(); } #region ServicesConfigure private void ConfigureJwt(IServiceCollection services) { var configuration = services.GetConfiguration(); services .AddAuthentication(options=> { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(options=> { options.RequireHttpsMetadata = false;// 開發環境為false options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters() { ValidateIssuer = true,//是否驗證Issuer ValidateAudience = true,//是否驗證Audience ValidateLifetime = true,//是否驗證失效時間 ClockSkew = TimeSpan.FromSeconds(30), // 偏移時間,所以實際過期時間 = 給定過期時間+偏移時間 ValidateIssuerSigningKey = true,//是否驗證SecurityKey ValidAudience = configuration["Jwt:Audience"],//Audience ValidIssuer = configuration["Jwt:Issuer"],//Issuer,這兩項和前面簽發jwt的設定一致 IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["Jwt:SecurityKey"]))//拿到SecurityKey }; // 事件 options.Events = new JwtBearerEvents() { OnAuthenticationFailed = context => { return Task.CompletedTask; }, OnChallenge = context => { // 驗證失敗 BaseResult result = new BaseResult(ResponseResultCode.Unauthorized,"未授權",null); context.HandleResponse(); context.Response.ContentType = "application/json;utf-8"; context.Response.StatusCode = StatusCodes.Status401Unauthorized; await context.Response.WriteAsync(JsonConvert.SerializeObject(result), Encoding.UTF8); }, OnForbidden = context => { return Task.CompletedTask; }, OnMessageReceived = context => { return Task.CompletedTask; } }; }); } private void ConfigureCors(IServiceCollection services) { services.AddCors(options => { options.AddPolicy("AllowAll", builder => { builder.AllowAnyOrigin() .SetIsOriginAllowedToAllowWildcardSubdomains() .AllowAnyHeader() .AllowAnyMethod(); }); }); } private void ConfigureSwagger(IServiceCollection services) { services.AddSwaggerGen(options => { options.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo() { Title = "MyShopApi", Version = "v0.1" }); options.DocInclusionPredicate((docName, predicate) => true); options.CustomSchemaIds(type => type.FullName); var basePath = PlatformServices.Default.Application.ApplicationBasePath; options.IncludeXmlComments(Path.Combine(basePath, "MyShop.Application.xml")); options.IncludeXmlComments(Path.Combine(basePath, "MyShop.Application.Contract.xml")); #region 新增請求認證 //Bearer 的scheme定義 var securityScheme = new OpenApiSecurityScheme() { Description = "JWT Authorization header using the Bearer scheme. Example: \"Authorization: Bearer {token}\"", Name = "Authorization", //引數新增在頭部 In = ParameterLocation.Header, //使用Authorize頭部 Type = SecuritySchemeType.Http, //內容為以 bearer開頭 Scheme = "Bearer", BearerFormat = "JWT" }; //把所有方法配置為增加bearer頭部資訊 var securityRequirement = new OpenApiSecurityRequirement { { new OpenApiSecurityScheme { Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "MyShopApi" } }, new string[] {} } }; options.AddSecurityDefinition("MyShopApi", securityScheme); options.AddSecurityRequirement(securityRequirement); #endregion }); } #endregion } } ``` ### 統一響應格式 ###### 基礎 BaseResult 定義全體響應型別父類,並提供基礎響應成功及響應失敗結果建立靜態函式 ``` csharp /// /// 基礎響應資訊 /// /// 響應資料型別 public class BaseResult where T:class { /// /// 響應碼 /// public ResponseResultCode Code { get; set; } /// /// 響應訊息 /// public string Message { get; set; } /// /// 響應資料 /// public virtual T Data { get; set; } /// /// 響應成功資訊 /// /// 響應資料 /// public static BaseResult Success(T data,string message = "請求成功") => new BaseResult(ResponseResultCode.Success,message, data); /// /// 響應失敗資訊 /// /// 響應資訊 /// public static BaseResult Failed(string message = "請求失敗!") => new BaseResult (ResponseResultCode.Failed,message,null); /// /// 響應異常資訊 /// /// 響應資訊 /// public static BaseResult Error(string message = "請求失敗!") => new BaseResult(ResponseResultCode.Error, message, null); /// /// 構造響應資訊 /// /// 響應碼 /// 響應訊息 /// 響應資料 public BaseResult(ResponseResultCode code,string message,T data) { this.Code = code; this.Message = message; this.Data = data; } } public enum ResponseResultCode { Success = 200, Failed = 400, Unauthorized = 401, Error = 500 } ``` ###### 列表 ListResult 派生自BaseResult並新增泛型為IEnumerable ``` csharp /// /// 列表響應 /// /// public class ListResult : BaseResult> where T : class { public ListResult(ResponseResultCode code, string message, IEnumerable data) : base(code, message, data) { } } ``` ###### 分頁列表 PagedResult 派生自BaseResult並新增PageData分頁資料型別泛型 ``` csharp public class PagedResult : BaseResult> { public PagedResult(ResponseResultCode code, string message, PageData data) : base(code, message, data) { } /// /// 響應成功資訊 /// /// 資料總條數 /// 分頁列表資訊 /// public static PagedResult Success(int total,IEnumerable list,string message= "請求成功") => new PagedResult (ResponseResultCode.Success,message,new PageData (total,list)); } /// /// 分頁資料 /// /// 資料型別 public class PageData { /// /// 構造 /// /// 資料總條數 /// 資料集合 public PageData(int total,IEnumerable list) { this.Total = total; this.Data = list; } /// /// 資料總條數 /// public int Total { get; set; } /// /// 資料集合 /// public IEnumerable Data { get; set; } } ``` ### 自定義異常 在我們新增自定義異常時需要先將abp vNext 預設提供的全域性異常過濾器移除。 在Module的ConfigureServices中新增移除程式碼 ``` csharp // 移除Abp異常過濾器 Configure(options => { var index = options.Filters.ToList().FindIndex(filter => filter is ServiceFilterAttribute attr && attr.ServiceType.Equals(typeof(AbpExceptionFilter))); if (index > -1) options.Filters.RemoveAt(index); }); ``` 定義MyShop自定義異常中介軟體 ``` csharp /// /// MyShop異常中介軟體 /// public class MyShopExceptionMiddleware { private readonly RequestDelegate _next; public MyShopExceptionMiddleware(RequestDelegate next) { _next = next; } public async Task InvokeAsync(HttpContext context) { try { await _next(context); } catch (Exception ex) { await HandleException(context, ex); } finally { await HandleException(context); } } private async Task HandleException(HttpContext context, Exception ex = null) { BaseResult result = null; ; bool handle = true; if (context.Response.StatusCode == (int)HttpStatusCode.Unauthorized) { result = new BaseResult(ResponseResultCode.Unauthorized, "未授權!", null); } else if (context.Response.StatusCode == (int)HttpStatusCode.InternalServerError) { result = new BaseResult(ResponseResultCode.Error, "服務繁忙!", null); } else { handle = false; } if(handle) await context.Response.WriteAsync(JsonConvert.SerializeObject(result), Encoding.UTF8); } } ``` 為了方便通過**IApplicationBuilder**呼叫這裡我們添加個擴充套件方法用於方便新增我們的自定義異常中介軟體 ``` csharp public static class MiddlewareExtensions { /// /// MyShop異常中介軟體 /// /// /// public static IApplicationBuilder UseMyShopExceptionMiddleware(this IApplicationBuilder app) { app.UseMiddleware(); return app; } } ``` 最後在 Module類中 新增對應的中介軟體 ``` csharp app.UseMyShopExceptionMiddleware(); ``` ## 程式執行 ###### 直接訪問Order列表 由於我們的Order服務帶有[Authrorize]特性 ![](https://img2020.cnblogs.com/blog/920403/202011/920403-20201106100634088-416112452.png) 所以結果直接顯示為 未授權 ![](https://img2020.cnblogs.com/blog/920403/202011/920403-20201106100748024-1781194612.png) ###### 訪問登入介面獲取token 通過註冊介面註冊一個使用者用於登入 使用wyp登入 ![](https://img2020.cnblogs.com/blog/920403/202011/920403-20201106100918955-1170133007.png) ###### 使用token訪問Order列表 將獲取到的token新增到 請求頭中的Authoritarian中 格式為:Bearer {token},這裡我們通過swagger 配置了所以直接可以使用它的小綠鎖 ![](https://img2020.cnblogs.com/blog/920403/202011/920403-20201106101126855-715219311.png) ![](https://img2020.cnblogs.com/blog/920403/202011/920403-20201106101151911-1666361778.png) 隨後訪問之前的Order介面 顯示請求成功 ![](https://img2020.cnblogs.com/blog/920403/202011/920403-20201106101240299-201331757.png) ###### 手動丟擲異常顯示自定義異常響應 訪問一個未實現的介面服務 ![](https://img2020.cnblogs.com/blog/920403/202011/920403-20201106101407155-996849893.png) 正常顯示了我們的全域性異常資訊 ![](https://img2020.cnblogs.com/blog/920403/202011/920403-20201106101445020-2360098