基于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的错误

扫描二维码关注公众号,回复: 1024973 查看本文章

在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]:初识认证 [雨夜朦胧]

JwtBearer 认证 [Leo_wlCnBlogs]

ASP.NET Core 中的那些认证中间件及一些重要知识点 [Savorboard](推荐其CAP项目,好用且好玩~~)

aspnet 认证相关的源码

详解ASP.NET Core 处理 404 Not Found


总结:

先明确需求再解决问题

看别人的文章要学习别人解决问题的思路

知其然也知其所以然,要对照问题多看aspnet core的源码。现在代码简单了,只需要调用中间件就能解决大部分问题,但是我们也要根据源码研究它是如何调用如何处理的,这样才能了解"为什么",而不是只知道"怎么做"

多思考,多总结~~

猜你喜欢

转载自www.cnblogs.com/AceZhai/p/9087690.html