基于aspnet core 2.0 为app开发接口 (一. Authorize篇 )
最近要为app端开发接口,为了安全考虑要为部分接口添加Authorize验证,因此选用了JWT技术。具体做法是:用户登录,注册时发放token,由app端保存,在调用服务器端接口时,携带token,服务器端验证token是否合法,验证通过则正常响应,验证失败则返回401信息。其他问题如保证某个账号当前只能在一个手机登陆,自定义401信息方便app端处理,自定义404等友好信息。
园子里有很多介绍Authorize的文章,我在开发过程中也参考了一些园友的文章,参考的作者和链接我会在文章中放出来,如有忘记提到的引用文章,还请回复,作者会及时修改。
接下来我们逐步实现上面提到的功能,step by step~
在api项目添加nuget引用
Microsoft.AspNetCore.Authentication.JwtBearer
Microsoft.IdentityModel.Tokens
用户登录,注册时发放token
[AllowAnonymous] [HttpPost] [Route("login")] public async Task<IActionResult> Login() { string userName = Request.Form["UserName"].ToString(); string password = Request.Form["Password"].ToString(); bool pass = await _userService.CheckPassword(userName, pasWord); if (pass) { //发放token return Ok(CreateToken(userName)); } else { return BadRequest("登录名或密码不正确"); } }
发放token的地方可以根据业务在token中添加一些信息
private string CreateToken(string userName) { var claims = new[] { //可以添加一些需要的信息 new Claim(ClaimTypes.Name, userName), }; //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(_configuration["SecurityKey"])); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); /** Claims 部分包含了一些跟这个 token 有关的重要信息。 JWT 标准规定了一些字段,下面节选一些字段: iss: The issuer of the token,token 是给谁的 sub: The subject of the token,token 主题 exp: Expiration Time。 token 过期时间,Unix 时间戳格式 iat: Issued At。 token 创建时间, Unix 时间戳格式 jti: JWT ID。针对当前 token 的唯一标识 除了规定的字段外,可以包含其他任何 JSON 兼容的字段。 * */ var token = new JwtSecurityToken( issuer: "ace.com", audience: "ace.com", claims: claims, expires: DateTime.Now.AddMinutes(30), signingCredentials: creds); return new JwtSecurityTokenHandler().WriteToken(token); }
在Startup中配置服务端验证的代码
public void ConfigureServices(IServiceCollection services) { //... services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true,//是否验证Issuer ValidateAudience = true,//是否验证Audience ValidateLifetime = true,//是否验证失效时间 ValidateIssuerSigningKey = true,//是否验证SecurityKey ValidAudience = "ace.com",//Audience ValidIssuer = "ace.com",//Issuer,这两项和前面签发jwt的设置一致 IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["SecurityKey"]))//拿到SecurityKey }; }); } public void Configure(IApplicationBuilder app, IHostingEnvironment env) { //... app.UseAuthentication(); }
现在已经配置好了token的发放和验证,只需要在需要验证的controller或action添加Authorize标记即可
[Route("api/[controller]")] public class ValuesController : Controller { // GET api/values [HttpGet] [Authorize] public string Get() { return "api project~~"; } }
现在直接访问这个接口会报401的错误
在Headers中添加token后可正常调用接口
接口提供给app等前端使用时,为了统一接口样式,方便前台操作处理,可能会自定义401信息,以达到下图的效果
这样处理response的效果是:服务端只给app端返回200的状态码,根据业务需求在response的body中返回不同的code,app端只根据response的body中的code去处理。
解决方思路是服务器端配置jwt验证时添加一个响应事件,拦截自身的响应处理。现在想的就是在哪里拦截,如何拦截的问题。
组件Microsoft.AspNetCore.Authentication.JwtBearer验证处理的代码都在JwtBearerHandler的HandleAuthenticateAsync方法中,查看源码可发现JwtBearerEvents提供了几个event来供开发者自定义一些处理。
JwtBearerEvents的源码如下所示:
/// <summary> /// Specifies events which the <see cref="JwtBearerHandler"/> invokes to enable developer control over the authentication process. /// </summary> public class JwtBearerEvents { /// <summary> /// Invoked if exceptions are thrown during request processing. The exceptions will be re-thrown after this event unless suppressed. /// </summary> public Func<AuthenticationFailedContext, Task> OnAuthenticationFailed { get; set; } = context => Task.CompletedTask; /// <summary> /// Invoked when a protocol message is first received. /// </summary> public Func<MessageReceivedContext, Task> OnMessageReceived { get; set; } = context => Task.CompletedTask; /// <summary> /// Invoked after the security token has passed validation and a ClaimsIdentity has been generated. /// </summary> public Func<TokenValidatedContext, Task> OnTokenValidated { get; set; } = context => Task.CompletedTask; /// <summary> /// Invoked before a challenge is sent back to the caller. /// </summary> public Func<JwtBearerChallengeContext, Task> OnChallenge { get; set; } = context => Task.CompletedTask; public virtual Task AuthenticationFailed(AuthenticationFailedContext context) => OnAuthenticationFailed(context); public virtual Task MessageReceived(MessageReceivedContext context) => OnMessageReceived(context); public virtual Task TokenValidated(TokenValidatedContext context) => OnTokenValidated(context); public virtual Task Challenge(JwtBearerChallengeContext context) => OnChallenge(context); }
验证的逻辑在HandleAuthenticateAsync方法中,验证完成后的处理在HandleChallengeAsync方法中,若是验证通过不会调用这个方法。其部分源码如下所示:
protected override async Task HandleChallengeAsync(AuthenticationProperties properties) { var authResult = await HandleAuthenticateOnceSafeAsync(); var eventContext = new JwtBearerChallengeContext(Context, Scheme, Options, properties) { AuthenticateFailure = authResult?.Failure }; // Avoid returning error=invalid_token if the error is not caused by an authentication failure (e.g missing token). if (Options.IncludeErrorDetails && eventContext.AuthenticateFailure != null) { eventContext.Error = "invalid_token"; eventContext.ErrorDescription = CreateErrorDescription(eventContext.AuthenticateFailure); } await Events.Challenge(eventContext); if (eventContext.Handled) { return; } Response.StatusCode = 401; //..... }
所以我们可以选择在Event.Challenge(eventContext)时拦截其处理,直接响应我们的自定义内容。我们可以在刚才配置JwtBearer的option中添加OnChallenge事件。代码如下所示
public void ConfigureServices(IServiceCollection services) { //...其他设置 services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.Events = new JwtBearerEvents { OnChallenge = context => { //if (context.AuthenticateFailure != null) //{ context.Response.StatusCode = 200; byte[] body = Encoding.UTF8.GetBytes(Newtonsoft.Json.JsonConvert.SerializeObject(new ApiResponse { code = 401, data = null, msg = "登录失效,请重新登陆" })); context.Response.ContentType = "application/json"; context.Response.Body.Write(body, 0, body.Length); context.HandleResponse(); //} return Task.CompletedTask; } }; //...其他设置 }); }
在文章开始我们提到了保证app一个账号当前只在一个手机登陆的问题,这个问题可以有两种解决思路。
第一种思路:用户每次登陆时发送机器唯一识别码,服务器端发现本次登陆机器和上次登陆机器不同时,给上个机器推送消息,让上个机器的app端下线,跳转到登录页面。
第二种思路:用户每次登陆时发送机器唯一识别码,服务器端将该识别码放在token中,app每次请求服务端的数据都需要验证token,在验证token时验证是否是当前登陆的机器,若不是,返回401信息,使app端跳转至登录页面。
我选择了第二种方式,因为第一种需要第三方的推送组件,过分的依赖别人是很危险的,^_^所以我们选择相信自己,相信服务器端。
JwtBearerHandler的HandleAuthenticateAsync方法代码如下:
/// <summary> /// Searches the 'Authorization' header for a 'Bearer' token. If the 'Bearer' token is found, it is validated using <see cref="TokenValidationParameters"/> set in the options. /// </summary> /// <returns></returns> protected override async Task<AuthenticateResult> HandleAuthenticateAsync() { string token = null; try { // Give application opportunity to find from a different location, adjust, or reject token var messageReceivedContext = new MessageReceivedContext(Context, Scheme, Options); // event can set the token await Events.MessageReceived(messageReceivedContext); if (messageReceivedContext.Result != null) { return messageReceivedContext.Result; } //... }
我们可以发现Events.MessageReceived事件在处理的最开始时调用,所以我们在此入手。先检查该机器是不是当前登陆的机器,若是当前登陆的机器,再走下面的验证流程。若不是当前登陆的机器,直接将token置空(或其他方法)。因为即便不是当前的机器,该token还是可以使用的(token只要不过期就可以使用),置空token能保证下面的验证逻辑将其拦截。
操作方法仍然是配置JwtBearer的JwtBearerOptions,代码如下所示
public void ConfigureServices(IServiceCollection services) { services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.Events = new JwtBearerEvents { OnMessageReceived = context => { if (!StringValues.IsNullOrEmpty(context.Request.Headers["Authorization"])) { try { //todo 验证多app登陆 var startLength = "Bearer ".Length; var tokenStr = context.Request.Headers["Authorization"].ToString(); var token = new JwtSecurityTokenHandler().ReadJwtToken(tokenStr.Substring(startLength, tokenStr.Length - startLength)); string userName = token.Claims.ToList().First(o => o.Type == System.Security.Claims.ClaimTypes.Name).Value.ToString(); string clientId = token.Claims.ToList().First(o => o.Type == "ClientId").Value.ToString(); var clientService = container.GetRequiredService<IClientService>(); if (!clientService.CheckLogin(userName, clientId))//验证逻辑根据业务实现 context.Request.Headers["Authorization"] = string.Empty; } catch (Exception ex) { context.Request.Headers["Authorization"] = string.Empty; } } return Task.CompletedTask; } }; }); }
当然在生成token的地方也要在token的Claims中添加机器唯一识别码。
到这里为止,我们已经基本解决了文章开头提到的问题。
文中我们说到为了方便app处理响应,统一response的格式,状态码都为200。但是有些默认的如404等状态码还是会造成如下的效果
我们可以修改其全全局设置,在Startup的Configure方法中添加代码
app.UseStatusCodePagesWithRedirects("/error/{0}");
我们可以看到这个方法的注释如下
// // 摘要: // Adds a StatusCodePages middleware to the pipeline. Specifies that responses should // be handled by redirecting with the given location URL template. This may include // a '{0}' placeholder for the status code. URLs starting with '~' will have PathBase // prepended, where any other URL will be used as is. // // 参数: // app: // // locationFormat:
我们可以添加一个controller,来处理不同的状态码,代码如下所示
public class ErrorController : Controller { [Route("error/404")] public IActionResult Error404() { return Ok(new ApiResponse { code = 404, msg = "请求出错", data = null }); } [Route("error/{code:int}")] public IActionResult Error(int code) { return Ok(new ApiResponse { code = code, data = null, msg = "请求出错" }); } }
这时再调用不存在的接口时,是如下效果
现在就达到了我们想要的格式。
过程中参考的博客如下:
ASP.NET Core 认证与授权[1]:初识认证 [雨夜朦胧]
ASP.NET Core 中的那些认证中间件及一些重要知识点 [Savorboard](推荐其CAP项目,好用且好玩~~)
详解ASP.NET Core 处理 404 Not Found
总结:
先明确需求再解决问题
看别人的文章要学习别人解决问题的思路
知其然也知其所以然,要对照问题多看aspnet core的源码。现在代码简单了,只需要调用中间件就能解决大部分问题,但是我们也要根据源码研究它是如何调用如何处理的,这样才能了解"为什么",而不是只知道"怎么做"
多思考,多总结~~