【前言】
上一篇我們介紹了什么是JWT,以及如何在asp.net core api項目中集成JWT權限認證。傳送門:https://www.cnblogs.com/7tiny/p/11012035.html
很多博友在留言中提出了疑問:
- 如何結合jwt認證對用戶進行API授權?
- token過期了怎么辦?
- 如何自動刷新token?
- 如何強制token失效?
- 如何應用到集群模式?
那么,便有了本篇。本篇在上一篇的基礎上繼續完善JWT的使用,並陸續回答上面的疑問。當然Demo中沒有體現的也會提供思路供博友參考。
【一、如何結合JWT認證對用戶進行API授權】
場景:我們有多個API接口,我們希望細化地控制哪個用戶可以訪問哪些API(可能是在某個授權界面進行API授權)
還是我們上一篇中的Demo項目:https://github.com/sevenTiny/Demo.Jwt
我們添加了兩個類:PolicyHandler.cs和PolicyRequirement.cs
首先是:PolicyRequirement.cs,這個類文件中定義了一個用戶名和url的對應實體,UserPermission用戶權限承載實體。然后實現了微軟自帶的接口IAuthorizationRequirement,里面構造方法賦值了如果沒有權限將要跳轉的接口和某用戶所有有權限的接口的配置集合,因為只寫了一個接口,這里只配置了一條作為Demo,當然了,在實際應用的時候,所有的這些配置我們都可以寫在數據庫中持久化,需要的時候讀取出來即可。
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using System.Collections.Generic; namespace Demo.Jwt.AuthManagement { /// <summary> /// 權限承載實體 /// </summary> public class PolicyRequirement : IAuthorizationRequirement { /// <summary> /// 用戶權限集合 /// </summary> public List<UserPermission> UserPermissions { get; private set; } /// <summary> /// 無權限action /// </summary> public string DeniedAction { get; set; } /// <summary> /// 構造 /// </summary> public PolicyRequirement() { //沒有權限則跳轉到這個路由 DeniedAction = new PathString("/api/nopermission"); //用戶有權限訪問的路由配置,當然可以從數據庫獲取 UserPermissions = new List<UserPermission> { new UserPermission { Url="/api/value3", UserName="admin"}, }; } } /// <summary> /// 用戶權限承載實體 /// </summary> public class UserPermission { /// <summary> /// 用戶名 /// </summary> public string UserName { get; set; } /// <summary> /// 請求Url /// </summary> public string Url { get; set; } } }
PolicyHandler 這個類繼承了微軟提供的類型AuthorizationHandler<PolicyRequirement>,泛型是我們上一步剛定義的類型。
在這個類里面,我們實現了抽象方法 Task HandleRequirementAsync(AuthorizationHandlerContext context, PolicyRequirement requirement),這個方法里面明確了如何具體地校驗用戶是否有API權限,並且根據校驗結果控制應該跳轉到提示API,還是繼續執行有權限的API。
這里的校驗邏輯比較簡單,Demo級別的,但是提供了校驗的入口,具體業務場景根據需求進行適當替換即可。
using Microsoft.AspNetCore.Authorization; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; namespace Demo.Jwt.AuthManagement { public class PolicyHandler : AuthorizationHandler<PolicyRequirement> { protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, PolicyRequirement requirement) { //賦值用戶權限 var userPermissions = requirement.UserPermissions; //從AuthorizationHandlerContext轉成HttpContext,以便取出表求信息 var httpContext = (context.Resource as Microsoft.AspNetCore.Mvc.Filters.AuthorizationFilterContext).HttpContext; //請求Url var questUrl = httpContext.Request.Path.Value.ToUpperInvariant(); //是否經過驗證 var isAuthenticated = httpContext.User.Identity.IsAuthenticated; if (isAuthenticated) { if (userPermissions.GroupBy(g => g.Url).Any(w => w.Key.ToUpperInvariant() == questUrl)) { //用戶名 var userName = httpContext.User.Claims.SingleOrDefault(s => s.Type == ClaimTypes.NameIdentifier).Value; if (userPermissions.Any(w => w.UserName == userName && w.Url.ToUpperInvariant() == questUrl)) { context.Succeed(requirement); } else { //無權限跳轉到拒絕頁面 httpContext.Response.Redirect(requirement.DeniedAction); } } else { context.Succeed(requirement); } } return Task.CompletedTask; } } }
然后我們改造一下模擬數據的API,添加一個 api/value3 不同的是,這個action我們添加了一個帶有策略名稱的權限特性標簽:[Authorize("Permission")] 通過這個特性標簽制定了這個action 會走我們自定義的策略方法。我們在返回值里面提示了“這個接口只有管理員才能訪問到”,並且返回了登陸用戶的用戶名和角色信息。
[HttpGet] [Route("api/value3")] [Authorize("Permission")] public ActionResult<IEnumerable<string>> Get3() { //這是獲取自定義參數的方法 var auth = HttpContext.AuthenticateAsync().Result.Principal.Claims; var userName = auth.FirstOrDefault(t => t.Type.Equals(ClaimTypes.NameIdentifier))?.Value; var role = auth.FirstOrDefault(t => t.Type.Equals("Role"))?.Value; return new string[] { "這個接口有管理員權限才可以訪問", $"userName={userName}",$"Role={role}" }; }
上文中獲取token的方法我們也微微進行了調整,對不同的登陸用戶返回不同的角色名,讓演示更加直觀一些,因為改動較小,這里不粘貼代碼,有想看詳情的請下載代碼查看。
然后我們改造一下Startup,主要改造的地方是添加了策略模式的配置
services.AddAuthorization(options => { options.AddPolicy("Permission", policy => policy.Requirements.Add(new PolicyRequirement())); })
還有添加了策略模式控制類的依賴注入
//注入授權Handler services.AddSingleton<IAuthorizationHandler, PolicyHandler>();
下面是完整的Startup.cs代碼
using Demo.Jwt.AuthManagement; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; using System; using System.Linq; using System.Text; using System.Threading.Tasks; namespace Demo.Jwt { public class Startup { public Startup(IConfiguration configuration) { Configuration = configuration; } public IConfiguration Configuration { get; } // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { //添加策略鑒權模式 services.AddAuthorization(options => { options.AddPolicy("Permission", policy => policy.Requirements.Add(new PolicyRequirement())); }) .AddAuthentication(s => { //添加JWT Scheme s.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; s.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; s.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) //添加jwt驗證: .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateLifetime = true,//是否驗證失效時間 ClockSkew = TimeSpan.FromSeconds(30), ValidateAudience = true,//是否驗證Audience //ValidAudience = Const.GetValidudience(),//Audience //這里采用動態驗證的方式,在重新登陸時,刷新token,舊token就強制失效了 AudienceValidator = (m, n, z) => { return m != null && m.FirstOrDefault().Equals(Const.ValidAudience); }, ValidateIssuer = true,//是否驗證Issuer ValidIssuer = Const.Domain,//Issuer,這兩項和前面簽發jwt的設置一致 ValidateIssuerSigningKey = true,//是否驗證SecurityKey IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Const.SecurityKey))//拿到SecurityKey }; options.Events = new JwtBearerEvents { OnAuthenticationFailed = context => { //Token expired if (context.Exception.GetType() == typeof(SecurityTokenExpiredException)) { context.Response.Headers.Add("Token-Expired", "true"); } return Task.CompletedTask; } }; }); //注入授權Handler services.AddSingleton<IAuthorizationHandler, PolicyHandler>(); services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IHostingEnvironment env) { ///添加jwt驗證 app.UseAuthentication(); if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseMvc(routes => { routes.MapRoute( name: "default", template: "{controller=Home}/{action=Index}/{id?}"); }); } } }
我們完成了這些工作以后,我們明確我們的目標:
- api/value1 接口我們不登陸就可以直接進行訪問
- api/value2 接口只有登陸用戶可以訪問,不登錄的用戶是沒有權限的
- api/value3 接口只有admin賬號登陸(代碼里寫死的賬號admin,也只為admin配置了權限)才可以訪問,普通用戶是不能訪問的
明確了上面的幾個目標后,下面我們進行測試,依然是運行起來我們的項目:
1.api/value1 接口我們不登陸就可以直接進行訪問
我們沒有登陸便可以訪問到api/value1接口
2.api/value2 接口只有登陸用戶可以訪問,不登錄的用戶是沒有權限的
2.1. 我們先直接訪問api/value2接口
返回了狀態碼:401 無權限
2.2. 那么我們調用登陸接口獲取token
2.3. 成功返回了token,我們拿該token去訪問 api/value2 接口
可以看到,我們成功拿到了數據,足以證明,api/value2 接口是需要登陸權限的
3. 那么,我們用這個token去訪問 api/value3 又會怎樣呢?
返回了403,訪問錯誤。這個403是怎么來的呢?
我們上文說過的PolicyHandler.cs文件中如果校驗接口沒有權限呢,我們會走下面這段邏輯:
//無權限跳轉到拒絕頁面 httpContext.Response.Redirect(requirement.DeniedAction);
requirement.DeniedAction是我們PolicyRequirement.cs文件中配置死的地址:"/api/nopermission"
這個地址返回的就是403 Forbid,當然這里可以根據需要修改返回內容,不再贅述。
4. 我們換一個admin賬號重新登陸,然后訪問 api/value3 接口
4.1 首先我們調用獲取token接口進行token獲取
4.2 我們拿到一個新的token,然后用這個新的token去訪問剛才沒權限的接口
成功地獲取到了結果,說明我們的配置策略生效了,只有admin賬號才有權限獲取到這個接口。
上面就是我們完整的策略模式的實現方案,完整的代碼可以在github地址中進行下載或clone。
【二、Token的使用策略】
1.token過期了怎么辦?
關於token過期這個話題呢,有很多應用場景,對應不同的處理方式。
比如:token過期可以提示用戶重新登陸,常見的有登陸一段時間后要重新登陸校驗密碼;
比如:token過期可以使用其他手段進行“偷偷”刷新,用戶感覺不到,但是token已經是新的了;
2.如何自動刷新token
那么token偷偷刷新有什么實現方式呢?
比如:約定好失效的時間,前端在失效前進行重新調用登陸接口進行獲取;
比如:使用SignalR,保持前后端通訊也可以一定時間輪詢刷新token;
比如:后端執行策略,定時任務刷新token,如果持續請求接口,就可以拿到最新的token進行“續命”,如果長時間不訪問任意接口,那么token也就失效了;
3.如何強制token失效?
什么場景要強制token失效呢?比如我們只允許賬號一個地方登陸一次,異地登陸會將賬號擠下線。這種時候我們就要將舊token失效,僅僅讓新的token生效。
下面我們在Demo中體現如何讓舊token強制失效。
3.1 在我們之前說過的Const.cs類中添加一個靜態變量(不是const,const是只讀的),讓我們在程序中可以直接修改值。當然又是為了模擬,真實場景這個值應該持久化或者存在redis里面,這里我們為了代碼簡潔易懂就不集成太多的組件了。
3.2 稍微修改一下我們的獲取token的action,在密碼驗證成功之后,修改靜態變量的值。
變量值采用賬號密碼加當前時間字符串,以保證每次登陸都是不一樣的值。
//每次登陸動態刷新 Const.ValidAudience = userName + pwd + DateTime.Now.ToString();
然后我們在生成token的時候,讓接收者=我們靜態變量的值,audience: Const.ValidAudience
完整的代碼如下:
[AllowAnonymous] [HttpGet] [Route("api/auth")] public IActionResult Get(string userName, string pwd) { if (CheckAccount(userName, pwd, out string role)) { //每次登陸動態刷新 Const.ValidAudience = userName + pwd + DateTime.Now.ToString(); // push the user’s name into a claim, so we can identify the user later on. //這里可以隨意加入自定義的參數,key可以自己隨便起 var claims = new[] { new Claim(JwtRegisteredClaimNames.Nbf,$"{new DateTimeOffset(DateTime.Now).ToUnixTimeSeconds()}") , new Claim (JwtRegisteredClaimNames.Exp,$"{new DateTimeOffset(DateTime.Now.AddMinutes(30)).ToUnixTimeSeconds()}"), new Claim(ClaimTypes.NameIdentifier, userName), new Claim("Role", role) }; //sign the token using a secret key.This secret will be shared between your API and anything that needs to check that the token is legit. var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Const.SecurityKey)); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); //.NET Core’s JwtSecurityToken class takes on the heavy lifting and actually creates the token. var token = new JwtSecurityToken( //頒發者 issuer: Const.Domain, //接收者 audience: Const.ValidAudience, //過期時間 expires: DateTime.Now.AddMinutes(30), //簽名證書 signingCredentials: creds, //自定義參數 claims: claims ); return Ok(new { token = new JwtSecurityTokenHandler().WriteToken(token) }); } else { return BadRequest(new { message = "username or password is incorrect." }); } }
3.3 然后改造一下StartUp.cs
我們僅僅需要關心改動的地方,也就是AddJwtBearer這個驗證token的方法,我們不用原先的固定值的校驗方式,而提供一個代理方法進行運行時執行校驗
.AddJwtBearer(options => options.TokenValidationParameters = new TokenValidationParameters { ValidateLifetime = true,//是否驗證失效時間 ClockSkew = TimeSpan.FromSeconds(30), ValidateAudience = true,//是否驗證Audience //ValidAudience = Const.GetValidudience(),//Audience //這里采用動態驗證的方式,在重新登陸時,刷新token,舊token就強制失效了 AudienceValidator = (m, n, z) => { return m != null && m.FirstOrDefault().Equals(Const.ValidAudience); }, ValidateIssuer = true,//是否驗證Issuer ValidIssuer = Const.Domain,//Issuer,這兩項和前面簽發jwt的設置一致 ValidateIssuerSigningKey = true,//是否驗證SecurityKey IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Const.SecurityKey))//拿到SecurityKey };
這里邏輯是這樣的:因為重新登陸將原來的變量更改了,所以這里校驗的時候也一並修改成了新的變量值,那么舊的token當然就不匹配了,也就是舊的token被強制失效了。
3.4 我們實際驗證一下
3.4.1 首先我們用admin賬號獲取token
3.4.2 然后用該token訪問有權限的 api/value3 接口
意料之中,我們成功訪問到了值,而且在有效期內訪問多次都是可以訪問成功的。
3.4.3 那么我們用admin賬號重新獲取token
拿到一個新的token
3.4.4 我們不更換token,再用舊的token調用一下 api/value3
返回狀態碼401了,說明沒有權限了
同時headers里面有錯誤描述時接收人參數錯誤,說明一切盡在我們的預期之中。
3.4.5 那么我們使用我們第二次登陸用的新的token進行訪問api/value3
又成功地獲取到了數據,表明我們新的token占有了當前寶座,老國王已經被擠下台了!
4. 如何應用到集群模式
這個問題其實在測試過Demo,然后再結合我們日常應用的話,答案很容易得到。以下幾種參考:
- 我們這個Demo其實相關參數都是從Const.cs常量文件中獲取的,文中也說了,實際應用中應從數據庫或redis中獲取。這些信號都表明了實際應用中很多都是走的配置中心或者是數據庫,這些中間件本就天然支持集群模式,因此部署多套服務和部署一套服務是一樣的,一個接口能通過的驗證,多個接口也同樣能通過驗證。
- 第二種場景在大項目中或者微服務場景中比較常見,那就是微服務網關,我們完全可以將JWT集成在微服務網關上,而不用關心具體的下游服務。只要網關能通過認證就可以訪問到下游的服務節點。
【結尾】
到這里,我們在上一篇中“JWT的簡介以及asp.net core 集成JWT”中遺留的問題已經全部解釋完畢了,當然了,如果有新的問題也非常歡迎各路朋友在評論區留下您寶貴的意見。
上一篇傳送門:https://www.cnblogs.com/7tiny/p/11012035.html
如果想要完整項目源碼的,可以參考地址:https://github.com/sevenTiny/Demo.Jwt