据我所知,软件行业,向来是充满着鄙视链的,人们时常会因为语言、框架、范式、架构等等问题而争执不休。不必说 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 应用。
var claims = new[] { new Claim(ClaimTypes.Name, userInfo.UserName), new Claim(ClaimTypes.Role, userInfo.UserRole) };
var signKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.Value.Secret)); var credentials = new SigningCredentials(signKey, SecurityAlgorithms.HmacSha256); var jwtToken = new JwtSecurityToken( _jwtOptions.Value.Issuer, _jwtOptions.Value.Audience, claims, expires: DateTime.Now.AddMinutes(_jwtOptions.Value.AccessExpiration), signingCredentials: credentials );
token = new JwtSecurityTokenHandler().WriteToken(jwtToken);
// 方式二,编写中间件,注意顺序 app.Use((context, next) => { var accessToken = context.Request.Query["access_token"].ToString(); var path = context.Request.Path; if (!string.IsNullOrEmpty(accessToken) && (path.StartsWithSegments("/echohub"))) context.Request.Headers.Add("Authorization", new StringValues($"Bearer {accessToken}"));
return next.Invoke(); });
app.UseAuthentication(); app.UseAuthorization();
可以注意到,不管是哪一种方式,核心目的都是为了让令牌能在 ASP.NET Core 的请求管道中出现在它期望出现的地方,这是什么地方呢?我想,应该是为执行认证/授权中间件以前,所以,为什么我说这两个中间件的顺序非常重要,原因正在于此,一旦我们做了这一点,剩下的事情就交给微软,我们只需要通过 HttpContext 的 User 属性获取用户信息即可。
1 2 3 4 5 6
public Task Echo(string message) { var userName = Context.User?.Identity?.Name; Clients.Client(Context.ConnectionId).SendAsync("OnEcho", $"{userName}:{message}"); return Task.CompletedTask; }
关于这个模式的细节,感兴趣的朋友可以从 这里 获取。这里我想说的是,当我们尝试用 Kafka 来做具体的业务的时候,我们其实是无法获得对应的用户信息的,因为此时此刻,基于 ASP.NET Core 的管道式的洋葱模型,对我们而言是暂时失效的,所以,我一直在说的非典型 Web 应用,其实可以指脱离了洋葱模型、脱离了授权/认证流程的这类场景。和 gRPC 类似,当我们需要用户信息,而又无法获得用户信息的时候,该怎么办呢?答案是在 Kafka 的消息中传递一个令牌(Token),下面是一个简单的实现思路:
1 2 3 4 5 6 7 8 9 10 11 12
var producerConfig = new ProducerConfig { BootstrapServers = "192.168.50.162:9092" }; using (var p = new ProducerBuilder<Null, string>(producerConfig).Build()) { var token = "<Your Token>"; var topic = “<Your Topic>"; var document = new { Id = "001", Name = "张三", Address = "北京市朝阳区", Event = "喝水未遂" }; var message = new Message<Null, string> { Value = JsonConvert.SerializeObject(document) }; // 在 Kafka 消息头里增加 Authorization 字段 message.Headers = new Headers(); message.Headers.Add("Authorization", Encoding.UTF8.GetBytes($"Bearer {token}")); var result = await p.ProduceAsync(topic, message); }
var consumerConfig = new ConsumerConfig { BootstrapServers = "127.0.0.1:9092" }; using (var c = new ConsumerBuilder<Null, string>(consumerConfig).Build()) { c.Subscribe("<Your Topic");
var cts = new CancellationTokenSource(); var jwtHandler = new JwtSecurityTokenHandler(); var tokenParameters = new TokenValidationParameters() { ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("<Your Secret>")), ValidIssuer = "<Your Issuer>", ValidAudience = "<Your Audience>", ValidateIssuer = true, ValidateAudience = true, };
while (true) { var userName = string.Empty; var consumeResult = c.Consume(cts.Token); var headers = consumeResult.Message.Headers; if (headers != null && headers.TryGetLastBytes("Authorization", outbyte[] values)) { // 校验令牌 var jwtToken = Encoding.UTF8.GetString(values).Replace("Bearer", "").Trim(); var claimsPrincipal = jwtHandler.ValidateToken(jwtToken, tokenParameters, out SecurityToken securityToken); userName = claimsPrincipal.Identity.Name; // 处理消息 // ...... } } }
while (true) { try { var consumeResult = consumer.Consume(cancellationToken); if (consumeResult != null) { var headers = consumeResult.Message.Headers; if (headers != null && headers.TryGetLastBytes("Authorization", outbyte[] values)) { var jwtToken = Encoding.UTF8.GetString(values).Replace("Bearer", "").Trim(); var userInfo = new JwtTokenResloverService().ValidateToken(jwtToken); UserContext.SetUserInfo(userInfo); }
var claimsPrincipal = _jwtHandler.ValidateToken(token, tokenParameters, out SecurityToken securityToken); if (claimsPrincipal != null) { var userInfo = new UserInfo(); userInfo.UserName = claimsPrincipal.Identity.Name; userInfo.UserRole = claimsPrincipal.Claims.FirstOrDefault(x => x.Type == ClaimTypes.Role)?.Value; return userInfo; }
现在,我们一开始的例子,可以简化成下面这样:
1 2 3 4 5 6 7 8 9 10 11
var consumerConfig = new ConsumerConfig { BootstrapServers = "127.0.0.1:9092" }; using (var c = new ConsumerBuilder<Null, string>(consumerConfig).Build()) { var cts = new CancellationTokenSource(); c.Subscribe("<Your Topic>", cts.Token, message => { // 获取当前用户信息 var userInfo = UserContext.GetUserInfo(); // 处理消息 }); }