返回
Featured image of post 浅议非典型 Web 应用场景下的身份认证

浅议非典型 Web 应用场景下的身份认证

据我所知,软件行业,向来是充满着鄙视链的,人们时常会因为语言、框架、范式、架构等等问题而争执不休。不必说 PHP 到底是不是世界上最好的语言,不必说原生与 Web 到底哪一个真正代表着未来,更不必说前端与后端到底哪一个更有技术含量,单单一个 C++ 的版本,1998 与 2011 之间仿佛隔了一个世纪。我真傻,我单知道人们会因为 GCC 和 VC++ 而分庭抗礼多年,却不知道人们还会因为大括号换行、Tab 还是空格、CRLF 还是 CR……诸如此类的问题而永不休战。也许,正如 王垠 前辈所说,编程这个领域总是充满着某种 宗教原旨 的意味。回想起刚毕业那会儿,因为没有 Web 开发的经验而被人轻视,当年流行的 SSH 全家桶,对我鼓捣 Windows 桌面开发这件事情,投来无限鄙夷的目光,仿佛 Windows 是一种原罪。可时间久了以后,我渐渐意识到,对工程派而言,一切都是工具;而对于学术派而言,一切都是包容。这个世界并不是只有 Web,对吧?所以,这篇博客我想聊聊非典型 Web 应用场景下的身份认证。

楔子

在讨论非典型 Web 应用场景前,我们不妨来回想一下,一个典型的 Web 应用是什么样子?打开浏览器、输入一个 URL、按下回车、输入用户名和密码、点击登录……,在这个过程中,Cookie/Session用来维持整个会话的状态。直到后来,前后端分离的大潮流下,无状态的服务开始流行,人们开始使用一个令牌(Token)来标识身份信息,无论是催生了 Web 2.0 的 OAuth 2.0 协议,还是在微服务里更为流行的 JWT(JSON Web Token),其实,都在隐隐约约说明一件事情,那就是在后 Web 时代,特别是微信兴起以后,人们在线与离线的边界越来越模糊,疫情期间居家办公的这段时间,我最怕听到 Teams 会议邀请的声音,因为无论你是否在线,它都会不停地催促你,彻底模糊生活与工作的边界。那么,屏幕前聪明的你,你告诉我,什么是典型的 Web 应用?也许,我同样无法回答这个问题,可或许,下面这几种方式,即 gRPC、SignalR 和 Kafka,可以称之为:非典型的 Web 应用。

gRPC

相信经常阅读我博客的朋友,都知道这样一件事情,那就是,过去这半年多的时间,我一直在探索,如何去构建一个以 gRPC 为核心的微服务架构。想了解这方面内容的朋友,不妨抽空看看我前面写过的博客。从整体上来说,我们对于 gRPC 的使用上,基本可以分为对内和对外两个方面。对内,不同的服务间通过 gRPC 客户端互相通信,我们称之为:直连;对外,不同的服务通过 Envoy 代理为 JSON API 供前端/客户端消费,我们称之为:代理。一个简单的微服务示意图,如下图所示:

gRPC 微服务中的内与外
gRPC 微服务中的内与外

目前,这个方案最大的问题,不同的服务间通过 gRPC 客户端直连的时候,无法提供身份认证信息,因为如果是单纯的读,即从某一个服务查询数据,其实是可以接受这种“裸奔”的状态,可一旦涉及到了写,这种方案就显得不大严谨。譬如现在的做法,如果从 HttpContext 里提取不到用户信息,就默认当前用户是 Sys,表示这是一个系统级别的操作。那么,如何解决这个问题呢?我们一起来看一下:

1var channel = GrpcChannel.ForAddress("https://localhost:5001");
2var client = new Greeter.GreeterClient(channel);
3
4var metadata = new Metadata();
5metadata.Add("Authorization", $"Bearer {token}");
6
7var reply = client.SayHello(new HelloRequest(), metadata);

可以注意到,这里的关键是构造一个Metadata,并在其中传入Authorization头部。当然,这一切的前提是你遵循并且沿用了 ASP.NET Core 身份验证,这里以最常见的 JWT 认证为例:

 1services.AddAuthentication(x =>
 2{
 3   x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
 4   x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
 5} ).AddJwtBearer(x =>
 6{
 7   x.RequireHttpsMetadata = false;
 8   x.SaveToken = true;
 9   x.TokenValidationParameters = new TokenValidationParameters
10  {
11     ValidateIssuerSigningKey = true,
12     IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.Secret)),
13     ValidIssuer = jwtOptions.Issuer,
14     ValidAudience = jwtOptions.Audience,
15     ValidateIssuer = true,
16     ValidateAudience = true,
17   };
18});

通常情况下,这里的IssuerSigningKey由一个证书文件来提供,例如最常用的是X509SecurityKey类。为了方便演示,这里采用一组字符串进行签名。不管采用哪一种方式,我们都应该保证它与生成令牌时的参数一致。例如,下面是一个典型的生成 JWT 令牌的代码片段:

 1var claims = new[]
 2{
 3   new Claim(ClaimTypes.Name, userInfo.UserName),
 4   new Claim(ClaimTypes.Role, userInfo.UserRole)
 5};
 6
 7var signKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.Value.Secret));
 8var credentials = new SigningCredentials(signKey, SecurityAlgorithms.HmacSha256);
 9var jwtToken = new JwtSecurityToken(
10   _jwtOptions.Value.Issuer,
11   _jwtOptions.Value.Audience, 
12   claims, 
13   expires: DateTime.Now.AddMinutes(_jwtOptions.Value.AccessExpiration), 
14   signingCredentials: credentials
15);
16
17token = new JwtSecurityTokenHandler().WriteToken(jwtToken);

除了以上两点,请确保你的正确地配置了认证和授权两个中间件,注意它们的顺序和位置:

 1public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
 2{
 3    // ...
 4    app.UseRouting();
 5    // 注意顺序
 6    app.UseAuthentication();
 7    app.UseAuthorization();
 8    app.UseEndpoints(endpoints =>
 9   {
10       endpoints.MapGrpcService<GreeterService>();
11   });
12}

此时,我们就可以在 gRPC 中通过IHttpContextAccessor获取当前用户信息:

1public override Task<HelloReply> SayHello(HelloRequest request, ServerCallContext context)
2{
3    var userName = _httpContextAccessor.HttpContext.User?.Identity.Name;
4    return Task.FromResult(new HelloReply { Message = $"Hello {userName}"});
5}

当然,考虑到我们使用 gRPC 客户端工厂的场景更多一点,我们更希望这个令牌可以在一开始就准备好,而不是每调用一个方法都需要传一次令牌。此时,我们可以使用下面的做法:

 1services.AddGrpcClient<Greeter.GreeterClient>(o =>
 2{
 3    o.Address = new Uri("https://localhost:5001");
 4}).ConfigureChannel(o =>
 5{
 6    var credentials = CallCredentials.FromInterceptor((context, metadata) =>
 7    {
 8      if (!string.IsNullOrEmpty(_token)
 9        metadata.Add("Authorization", $"Bearer {_token}");
10      return Task.CompletedTask;
11    });
12
13    o.Credentials = ChannelCredentials.Create(new SslCredentials(), credentials);
14});

至此,关于 gRPC 的身份认证问题终于得到了解决,无论是对内的直连还是对外的代理,我们都可以获得用户的身份信息。需要说明的是,默认情况下,gRPC 允许调用方在不携带令牌的情况下调用接口,所以,我们这里的认证方案更像是一种君子协定。如果希望做更严格的限制,可以考虑在具体的服务上添加 [Authorize]特性,就像我们在控制器上使用该特性一样。

SignalR

SignalR 是由微软提供的一面向实时 Web 应用的、开源的库,你可以认为,它是集 WebSockets、Server-Sent 事件 和 长轮询于一身的综合性的库。当你需要从服务端实时地推送消息到特定的客户端(组)的时候,SignalR 将会是一个不错的选择。譬如可视化的仪表盘或者监视系统,需要接收数据的变更通知以实时地刷新视图;即时通讯(IM)、社交网络、邮件、游戏、协作等方面地应用都需要及时地发出通知。ASP.NET Core 版本的 SIgnalR 在自动处理连接管理方面做出来不小的改善,可很多时候,SIgnalR 里面的 ConnectionId 对我们而言是没有意义的,我们更想知道连接到 Hub 的用户是谁,这样就催生出来 SignalR 身份认证的需求场景,下面,我们就来看看对应的 解决方案

 1// 方式一、AccessTokenProvider 
 2var hubConnection = new HubConnectionBuilder()
 3  .WithUrl("http://localhost:5000/echohub",options =>
 4  {
 5    options.AccessTokenProvider = () => Task.FromResult("<Your Token>");
 6  })
 7  .WithAutomaticReconnect()
 8  .Build();
 9
10
11// 方式二、直接追加查询参数
12var hubConnection = new HubConnectionBuilder()
13  .WithUrl("http://localhost:5000/echohub?access_token=<Your Token>")
14  .WithAutomaticReconnect()
15  .Build();

首先,SignalR 的身份认证,整体上依然遵循 ASP.NET Core 里的这套认证/授权流程,所以,我们可以继续沿用 gRPC 这部分的代码。考虑到 SignalR 首次发起的是一个 GET 请求,通常的做法是在查询参数中追加令牌参数。当然,现在官方提供了AccessTokenProvider这个属性,允许你构造一个委托来提供令牌。接下来,为了让这个令牌更符合一般的场景,譬如,按照约定它应该出现在 HTTP 请求头的 Authorization 字段上,我们有下面两种方式来对它进行处理:

 1// 方式一、设置 JwtBearer 的 Events 属性
 2services.AddAuthentication(x =>
 3{
 4  //...
 5})
 6.AddJwtBearer(x =>
 7{
 8  //...
 9  x.Events = new JwtBearerEvents()
10  {
11    OnMessageReceived = context =>
12    {
13      var accessToken = context.Request.Query["access_token"];
14      var path = context.HttpContext.Request.Path;
15      if (!string.IsNullOrEmpty(accessToken) && (path.StartsWithSegments("/echohub")))
16        context.Token = accessToken;
17      return Task.CompletedTask;
18    }
19  };
20});
21
22// 方式二,编写中间件,注意顺序
23app.Use((context, next) =>
24{
25  var accessToken = context.Request.Query["access_token"].ToString();
26  var path = context.Request.Path;
27  if (!string.IsNullOrEmpty(accessToken) && (path.StartsWithSegments("/echohub")))
28    context.Request.Headers.Add("Authorization", new StringValues($"Bearer {accessToken}"));
29
30    return next.Invoke();
31});
32
33app.UseAuthentication();
34app.UseAuthorization();

可以注意到,不管是哪一种方式,核心目的都是为了让令牌能在 ASP.NET Core 的请求管道中出现在它期望出现的地方,这是什么地方呢?我想,应该是为执行认证/授权中间件以前,所以,为什么我说这两个中间件的顺序非常重要,原因正在于此,一旦我们做了这一点,剩下的事情就交给微软,我们只需要通过 HttpContextUser 属性获取用户信息即可。

1public Task Echo(string message)
2{
3  var userName = Context.User?.Identity?.Name;
4  Clients.Client(Context.ConnectionId).SendAsync("OnEcho", $"{userName}:{message}");
5  return Task.CompletedTask;
6}

对于 SignalR 而言,同一个用户会对应多个 ConnectionId,当然,我并不需要过度地去关注这个东西,除非我们真的要分清每一个 ConnectionId 具体代表什么。类似地,SignalR 一样可以用 [Authorized] 特性来限制 Hub 是否可以在未认证的情况下使用,甚至它可以配合不同的 Policy 来做更细致的划分:

 1public class UserRoleRequirement : 
 2  AuthorizationHandler<UserRoleRequirement, HubInvocationContext>, IAuthorizationRequirement
 3{
 4  protected override Task HandleRequirementAsync(
 5    AuthorizationHandlerContext context, UserRoleRequirement requirement, HubInvocationContext resource)
 6  {
 7    var userRole = context.User.Claims.FirstOrDefault(x => x.Type == ClaimTypes.Role)?.Value;
 8    if (userRole == "Admin" && resource.HubMethodName == "Echo")
 9      context.Succeed(requirement);
10
11    return Task.CompletedTask;
12  }
13}

这里,我们定义了一个UserRoleRequirement类,其作用是仅仅允许Admin角色访问Echo()方法。此时,为了让这个策略生效,我们还需要将其注册到容器中,如下面的代码片段所示:

1services
2  .AddAuthorization(options =>
3  {
4    options.AddPolicy("UserRoleRestricted", policy =>
5    {
6      policy.Requirements.Add(new UserRoleRequirement());
7    });
8  });

现在,我们可以在集线器(Hub)上控制 Echo() 方法访问权限,考虑到 gRPCSignalR 都可以使用这套身份认证方案,所以,这个做法同样适用于 gRPC,如果你希望实现方法级的权限控制:

 1[Authorize]
 2public class EchoHub : Hub
 3{
 4  [Authorize("UserRoleRestricted")]
 5  public Task Echo(string message)
 6  {
 7    var userName = Context.User?.Identity?.Name;
 8    Clients.Client(Context.ConnectionId).SendAsync("OnEcho", $"{userName}:{message}");
 9    return Task.CompletedTask;
10  }
11}

Kafka

无独有偶,除了 SignalR ,我们还用到了消息中间件 Kafka,事实上,SignalR 会从 Kafka 拉取消息,并将其发布到订阅了该 Topic 的客户端上,所以,Kafka 在整个系统中,其实扮演着非常重要的角色。如果你只是需要让 Kafka 充当一个消息中介者,那么,你完全不需要考虑 Kafka 的身份认证问题。可一旦你考虑用 Kafka 来做具体的业务,这个问题就会立刻凸显出来。譬如,当我们用 Kafka 来实现一个分布式事务的时候,我们采用了 SAGA 模式,即让主事务负责事务的协调,每个子事务在收到主事务的消息后,执行相应的操作并回复主事务一条消息,再由主事务来决定整个事务应该提交还是回滚。如图所示,下面是一个针对 SAGA 模式的简单示意图:

分布式事务 SAGA 模式示意图
分布式事务 SAGA 模式示意图

关于这个模式的细节,感兴趣的朋友可以从 这里 获取。这里我想说的是,当我们尝试用 Kafka 来做具体的业务的时候,我们其实是无法获得对应的用户信息的,因为此时此刻,基于 ASP.NET Core 的管道式的洋葱模型,对我们而言是暂时失效的,所以,我一直在说的非典型 Web 应用,其实可以指脱离了洋葱模型、脱离了授权/认证流程的这类场景。和 gRPC 类似,当我们需要用户信息,而又无法获得用户信息的时候,该怎么办呢?答案是在 Kafka 的消息中传递一个令牌(Token),下面是一个简单的实现思路:

 1var producerConfig = new ProducerConfig { BootstrapServers = "192.168.50.162:9092" };
 2using (var p = new ProducerBuilder<Null, string>(producerConfig).Build())
 3{
 4    var token = "<Your Token>";
 5    var topic = <Your Topic>";
 6    var document = new { Id = "001", Name = "张三", Address = "北京市朝阳区", Event = "喝水未遂" };
 7    var message = new Message<Null, string> { Value = JsonConvert.SerializeObject(document) };
 8    // 在 Kafka 消息头里增加 Authorization 字段
 9    message.Headers = new Headers();
10    message.Headers.Add("Authorization", Encoding.UTF8.GetBytes($"Bearer {token}"));
11    var result = await p.ProduceAsync(topic, message);
12}

显然,这是非常朴素的一个想法,发送 Kafka 消息的时候,在 Kafka 的消息头里增加 Authorization字段,完美借鉴HTTP协议里的做法。那么,相对应地,消费 Kafka 消息的时候需要做一点调整,因为 Kafka 完全独立于 ASP.NET Core 的请求管道,所以,校验令牌的工作此时需要我们来独立完成。不过,请放心,这一切不会特别难,因为JwtSecurityTokenHandler这个类我们已经在前面用过一次:

 1var consumerConfig = new ConsumerConfig { BootstrapServers = "127.0.0.1:9092" };
 2using (var c = new ConsumerBuilder<Null, string>(consumerConfig).Build())
 3{
 4    c.Subscribe("<Your Topic");
 5
 6    var cts = new CancellationTokenSource();
 7    var jwtHandler = new JwtSecurityTokenHandler();
 8    var tokenParameters = new TokenValidationParameters()
 9    {
10      ValidateIssuerSigningKey = true,
11      IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("<Your Secret>")),
12      ValidIssuer = "<Your Issuer>",
13      ValidAudience = "<Your Audience>",
14      ValidateIssuer = true,
15      ValidateAudience = true,
16    };
17
18    while (true)
19    {
20        var userName = string.Empty;
21        var consumeResult = c.Consume(cts.Token);
22        var headers = consumeResult.Message.Headers;
23        if (headers != null && headers.TryGetLastBytes("Authorization", out byte[] values))
24        {
25            // 校验令牌
26            var jwtToken = Encoding.UTF8.GetString(values).Replace("Bearer", "").Trim();
27            var claimsPrincipal = jwtHandler.ValidateToken(jwtToken, tokenParameters, out SecurityToken securityToken);
28            userName = claimsPrincipal.Identity.Name;
29            
30            // 处理消息
31            // ......
32        }
33    }
34}

不过,我个人感觉,这样会增加消息消费方的工作,更好的做法是采用 CallContext 或者 AsyncLocal 来做统一的处理,这样,消息订阅方只需要关心消息怎么处理即可,考虑到 Kafka 属于 Pull 模式的消息队列,这种思路不见得在性能上有多少提升,更多的是一种简化啦,算是少写一点重复代码:

 1public static void Subscribe<TKey, TValue>(
 2  this IConsumer<TKey, TValue> consumer, string topic, 
 3  CancellationToken cancellationToken, Action<TValue> callback)
 4{
 5  consumer.Subscribe(topic);
 6
 7  while (true)
 8  {
 9    try
10    {
11      var consumeResult = consumer.Consume(cancellationToken);
12      if (consumeResult != null)
13      {
14        var headers = consumeResult.Message.Headers;
15        if (headers != null && headers.TryGetLastBytes("Authorization", out byte[] values))
16        {
17          var jwtToken = Encoding.UTF8.GetString(values).Replace("Bearer", "").Trim();
18          var userInfo = new JwtTokenResloverService().ValidateToken(jwtToken);
19          UserContext.SetUserInfo(userInfo);
20        }
21
22        if (callback != null)
23            callback(consumeResult.Message.Value);
24      }
25    }
26    catch (ConsumeException e)
27    {
28      // ...
29    }
30  }
31}

其中,UserContext内部利用AsyncLocal在不同线程间共享用户信息,这可以让我们在任意位置访问用户信息,因为该扩展方法中会调用一次SetUserInfo()方法,所以,只要在 Kafka 的消息头上维护了Authorization字段,就可以从中解析出常用的用户信息,譬如用户名、用户角色等等。当然,按照 JWT规范,我们最好还是不要在载荷(Payload)中存放敏感信息,如果需要更详细的信息,比如部门、权限等等,建议通过调接口或者查数据的库方式来实现。下面是UserContext的实现细节:

1static class UserContext
2{
3  private static AsyncLocal<UserInfo> _localUserInfo = new AsyncLocal<UserInfo>();
4  public static void SetUserInfo(UserInfo userInfo) => _localUserInfo.Value = userInfo;
5  public static UserInfo GetUserInfo() => _localUserInfo.Value;
6}

非常的简单,对不对? 作为 CallContext 的继任者,AsyncLocal就是这样的优秀!也许,大家会好奇JwtTokenResloverService是什么?其实,它还是JwtSecurityTokenHandler那一堆东西,下面的代码片段展示了如何从ClaimsPrincipal中提取用户名和角色名称,再次说明,不要在这里放敏感信息:

1var claimsPrincipal = _jwtHandler.ValidateToken(token, tokenParameters, out SecurityToken securityToken);
2if (claimsPrincipal != null)
3{
4  var userInfo = new UserInfo();
5  userInfo.UserName = claimsPrincipal.Identity.Name;
6  userInfo.UserRole = claimsPrincipal.Claims.FirstOrDefault(x => x.Type == ClaimTypes.Role)?.Value;
7  return userInfo;
8}

现在,我们一开始的例子,可以简化成下面这样:

 1var consumerConfig = new ConsumerConfig { BootstrapServers = "127.0.0.1:9092" };
 2using (var c = new ConsumerBuilder<Null, string>(consumerConfig).Build())
 3{
 4  var cts = new CancellationTokenSource();
 5  c.Subscribe("<Your Topic>", cts.Token, message =>
 6 {
 7    // 获取当前用户信息
 8    var userInfo = UserContext.GetUserInfo();
 9    // 处理消息
10  });
11}

Kafka 的故事,讲述到这里本该结尾,可世界上哪里会有完美的方案呢?如果考虑到 Kafka 发生消息堆积的可能性,一旦消息没有被及时处理,那么,这个放在消息头上的令牌可能会出现过期的情况。这种事情就和你使用缓存一样,如果不考虑缓存的击穿、穿透、雪崩三大灾难,那你对缓存的认知简直是肤浅。同样的道理,你要考虑令牌的过期、刷新、撤销,整个认知网络才算是真正建立起来,这些就交给我聪明的读者啦,或者以后有机会单独写一写这些话题。

本文小结

2021年的最后一天,西安疫情可谓是涛声依旧,居家隔离、远程办公、买不到菜,注定要为这段时光写下注脚。也许,我们都习惯了饭来张口的外卖生活,可在这一刻,当一切线下互动被迫中止的时候,我们终于发现这些“典型”的生活场景变得不再“典型”;当人们开始在微信群里用接龙的方式来寻求生活物资的时候,微信这个社交工具的缺点就被不断地放大;当人们逐渐回归到一种“以物易物”的状态时,我不得不感慨这种从骨子里与生俱来的生存本能;当人们习以为常的“典型”被打破,其本身是否就是一种舒适圈?无论技术还是生活,你是否有做好随时面对“非典型”场景的准备?我想,在与新冠病毒长期对峙的后疫情时代,这是每一个人都应该去思考的问题,这篇博客断断续续地写了好几天,大概 2021 终究还是要这般潦草的过去罢。因为,我是一个长期悲观主义者,我确信这个世界依旧遵循熵增定律。如果你打算反驳,我会说:你说得对。

Built with Hugo
Theme Stack designed by Jimmy