原文:Push Notifications and ASP.NET Core – Part 2 (Requesting Delivery)
出于阅读考量,对原文部分文本表述有修改,但涉及技术方面不会作修改
翻译:HHaoWang
这是关于Push Notifications
的第二篇文章:
- Part 1 (Push API)
- Part 2 (Requesting Delivery)
- Part 3 (Replacing Messages & Urgency)
- Part 4 (Queueing requesting delivery in background)
- Part 5 (Special Cases)
在上一篇文章中,我重点介绍了一般流程和Push API。在这篇文章中,我将重点关注请求推送消息传递。
简单地说,请求推送消息是通过向订阅端点(endpoint
)发送POST请求来执行的。但魔鬼总是出在细节上,在本例中,它在四个不同的RFC中传递。
准备推送消息的传递请求
我们已经知道应该执行POST请求,并且也知道了请求的URL。如果你阅读过上一篇文章,也就知道我们需要使用VAPID进行身份验证并加密消息负载。但这还不是全部,Web推送协议指定了一个必需的属性:生存时间(Time-To-Live
)。该属性的目的是通知推送服务应该保留消息多长时间(零也是一个合法值,表示允许推送服务在传递该消息后立即删除它)。考虑到这个属性,推送消息可以由以下类表示。
public class PushMessage { private int? _timeToLive; public string Content { get; set; } public int? TimeToLive { get { return _timeToLive; } set { if (value.HasValue && (value.Value < 0)) { throw new ArgumentOutOfRangeException(nameof(TimeToLive), "The TTL must be a non-negative integer"); } _timeToLive = value; } } public PushMessage(string content) { Content = content; } }
活跃时间属性应该通过TTL头来传递,因此需要以下准备请求的初始代码。
public class PushServiceClient { private const string TTL_HEADER_NAME = "TTL"; private const int DEFAULT_TIME_TO_LIVE = 2419200; ... private HttpRequestMessage PreparePushMessageDeliveryRequest(PushSubscription subscription, PushMessage message) { HttpRequestMessage pushMessageDeliveryRequest = new HttpRequestMessage(HttpMethod.Post, subscription.Endpoint) { Headers = { { TTL_HEADER_NAME, (message.TimeToLive ?? DEFAULT_TIME_TO_LIVE).ToString(CultureInfo.InvariantCulture) } } }; return pushMessageDeliveryRequest; } }
如果我们试图发送这个请求,结果会是400或403(取决于推送服务),表明我们没有请求推送信息的授权。这时就需要使用VAPID配合工作了。
身份认证(Authentication)
VAPID规范使用JSON Web Token作为载体。为了完成推送服务侧的认证,应用程序应该使用应用服务器私钥签署令牌,并将其包含在请求中。包含在请求中的JWT的最终形式应该是这样的:
<Base64 encoded JWT header JSON>.<Base64 encoded JWT body JSON>.<Base64 encoded signature>
在C#中表示JWT的头部(header)和主体(body)部分(有些地方也称之为Payload,即载荷,译者注)的最简单方法之一是使用Dictionary<TKey, TValue>。在VAPID中,头部一般是固定的:
private static readonly Dictionary<string, string> _jwtHeader = new Dictionary<string, string> { { "typ", "JWT" }, { "alg", "ES256" } };
JWT的主体部分应该包含以下两个声明(claim):
- Audience (aud) – 推送资源的来源(这会将令牌绑定到特定推送服务)。
- Expiry (exp) – 令牌过期的时间,最长为24小时,但通常使用一半。该值应该是以Unix时间表示的到期时间。
此外,应用程序也可以再包含一个 Subject (sub)
声明,该声明应包含应用程序服务器的联系信息(例如mailto:
或https:URI
)。
签名(signature)部分应该是一个使用ECDSA ES256 算法的JSON Web签名。
现在让我们在代码中来实现这些:
public class VapidAuthentication { private string _subject; private string _publicKey; private string _privateKey; private static readonly DateTime _unixEpoch = new DateTime(1970, 1, 1, 0, 0, 0); private static readonly Dictionary<string, string> _jwtHeader = ...; ... private string GetToken(string audience) { // Audience validation removed for brevity ... Dictionary<string, object> jwtBody = GetJwtBody(audience); return GenerateJwtToken(_jwtHeader, jwtBody); } private Dictionary<string, object> GetJwtBody(string audience) { Dictionary<string, object> jwtBody = new Dictionary<string, object> { { "aud", audience }, { "exp", GetAbsoluteExpiration() } }; if (_subject != null) { jwtBody.Add("sub", _subject); } return jwtBody; } private static long GetAbsoluteExpiration() { TimeSpan unixEpochOffset = DateTime.UtcNow - _unixEpoch; return (long)unixEpochOffset.TotalSeconds + 43200; } private string GenerateJwtToken(Dictionary<string, string> jwtHeader, Dictionary<string, object> jwtBody) { string jwtInput = UrlBase64Converter.ToUrlBase64String(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(jwtHeader))) + "." + UrlBase64Converter.ToUrlBase64String(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(jwtBody))); // Signature generation removed for brevity ... return jwtInput + "." + UrlBase64Converter.ToUrlBase64String(jwtSignature); } }
以上代码并不包含生成签名的部分,因为其较为复杂,阅读起来较为困难,你可以在这里查看具体实现:示例代码。该实现使用了BouncyCastle项目的 ECDsaSigner
类以及一些数组填充例程(你也可以尝试使用微软提供的JwtSecurityTokenHandler快速生成JWT而不是采用上面的实现手动生成,译者注)。生成签名的加密较为耗费计算资源(考虑到可能的订阅数量),因此必须记住,JWT可以按每个Audience进行缓存,并具有与到期声明相对应的Expiry声明。
目前有两种在请求中包含JWT的方式。一种是WebPush认证方案,另一种是VAPID认证方案。VAPID是最终规范中所采纳的认证方案,而Web Push则是草案版本。VAPID方案非常简单,因为它只需要在HTTP请求中添加一个 Authorization
头部即可:
Authorization: vapid t=<JWT>, k=<Base64 encoded Application Server Public Key>
因此,可以像下面的代码片段一样简单地生成具体值:
public class VapidAuthentication { ... public string GetVapidSchemeAuthenticationHeaderValueParameter(string audience) { return String.Format("t={0}, k={1}", GetToken(audience), _publicKey); } ... }
不幸的是,并非所有推送服务都支持最新的规范(在写这篇文章的时候,我在Chrome上使用vapid方案没有成功)。Web Push方案似乎仍然被已经支持vapid的推送服务所支持,所以我将在这里继续使用它。Web Push方案要复杂一些,因为它通过使用两个分离的头部来传输所需的信息。
Authorization: WebPush <JWT> Crypto-Key: p256ecdsa=<Base64 encoded Application Server Public Key>
这意味着这两个值都需要单独公开:
public class VapidAuthentication { public readonly struct WebPushSchemeHeadersValues { public string AuthenticationHeaderValueParameter { get; } public string CryptoKeyHeaderValue { get; } public WebPushSchemeHeadersValues(string authenticationHeaderValueParameter, string cryptoKeyHeaderValue) : this() { AuthenticationHeaderValueParameter = authenticationHeaderValueParameter; CryptoKeyHeaderValue = cryptoKeyHeaderValue; } } ... public WebPushSchemeHeadersValues GetWebPushSchemeHeadersValues(string audience) { return new WebPushSchemeHeadersValues(GetToken(audience), "p256ecdsa=" + _publicKey); } ... }
现在可以将身份验证插入到请求准备代码中了:
public class PushServiceClient { ... private const string WEBPUSH_AUTHENTICATION_SCHEME = "WebPush"; private const string CRYPTO_KEY_HEADER_NAME = "Crypto-Key"; ... private HttpRequestMessage PreparePushMessageDeliveryRequest(PushSubscription subscription, PushMessage message, VapidAuthentication authentication) { // Authentication validation removed for brevity ... HttpRequestMessage pushMessageDeliveryRequest = ...; pushMessageDeliveryRequest = SetAuthentication(pushMessageDeliveryRequest, subscription, authentication); return pushMessageDeliveryRequest; } private static HttpRequestMessage SetAuthentication(HttpRequestMessage pushMessageDeliveryRequest, PushSubscription subscription, VapidAuthentication authentication) { Uri endpointUri = new Uri(subscription.Endpoint); string audience = endpointUri.Scheme + @"://" + endpointUri.Host; VapidAuthentication.WebPushSchemeHeadersValues webPushSchemeHeadersValues = authentication.GetWebPushSchemeHeadersValues(audience); pushMessageDeliveryRequest.Headers.Authorization = new AuthenticationHeaderValue( WEBPUSH_AUTHENTICATION_SCHEME,webPushSchemeHeadersValues.AuthenticationHeaderValueParameter); pushMessageDeliveryRequest.Headers.Add(CRYPTO_KEY_HEADER_NAME, webPushSchemeHeadersValues.CryptoKeyHeaderValue); return pushMessageDeliveryRequest; } }
这是一个可以发送的请求(虽然JWT中并没有有效载荷部分的内容,译者注),因为有效载荷是可选的,但如果能有载荷就更好了。
有效载荷加密
为了保护隐私,推送信息的有效载荷必须进行加密。Web Push 加密规范依赖于HTTP的加密内容编码(Encrypted Content-Encoding),我在过去已经写过这部分内容了。多亏了这一点,我已经有了一个现成的实现,棘手的部分是生成输入密钥材料。
当在客户端创建一个订阅时,客户端会生成一个新的P-256密钥对和一个认证密令(authentication secret)(一个难以猜测的随机值)。来自该密钥对的公钥和认证密令与应用服务器共享。每当应用程序想要发送一个推送信息时,它应该在P-256曲线上生成一个新的EDCH密钥对。该密钥对的公钥应作为 aes128gcm
的密钥材料识别器,而私钥应与客户端公钥一起用于生成EDCH协议(称为共享密令,shared secret)。客户端要能够根据他的私钥和应用公钥生成相同的EDCH协议。为了提高安全性,共享密令应与认证密令通过计算两个HMAC SHA-256散列的方式相结合。首先是共享密令与认证密令的散列,其结果被用来散列信息参数,其定义如下:
"WebPush: info" || 0x00 || Client Public Key || Application Public Key || 0x01
结果被截断为32字节,并用作aes128gcm的密钥材料。使用BouncyCastle可以非常简洁地实现它。
public class PushServiceClient { ... private static readonly byte[] _keyingMaterialInfoParameterPrefix = Encoding.ASCII.GetBytes("WebPush: info"); ... private static byte[] GetKeyingMaterial(PushSubscription subscription, AsymmetricKeyParameter applicationServerPrivateKey, byte[] applicationServerPublicKey) { IBasicAgreement ecdhAgreement = AgreementUtilities.GetBasicAgreement("ECDH"); ecdhAgreement.Init(applicationServerPrivateKey); byte[] userAgentPublicKey = UrlBase64Converter.FromUrlBase64String(subscription.Keys["p256dh"]); byte[] authenticationSecret = UrlBase64Converter.FromUrlBase64String(subscription.Keys["auth"]); byte[] sharedSecret = ecdhAgreement.CalculateAgreement( ECKeyHelper.GetECPublicKeyParameters(userAgentPublicKey)).ToByteArrayUnsigned(); byte[] sharedSecretHash = HmacSha256(authenticationSecret, sharedSecret); byte[] infoParameter = GetKeyingMaterialInfoParameter(userAgentPublicKey, applicationServerPublicKey); byte[] keyingMaterial = HmacSha256(sharedSecretHash, infoParameter); Array.Resize(ref keyingMaterial, 32); return keyingMaterial; } private static byte[] GetKeyingMaterialInfoParameter(byte[] userAgentPublicKey, byte[] applicationServerPublicKey) { // "WebPush: info" || 0x00 || ua_public || as_public || 0x01 byte[] infoParameter = new byte[_keyingMaterialInfoParameterPrefix.Length + userAgentPublicKey.Length + applicationServerPublicKey.Length + 2]; Array.Copy(_keyingMaterialInfoParameterPrefix, infoParameter, _keyingMaterialInfoParameterPrefix.Length); int infoParameterIndex = _keyingMaterialInfoParameterPrefix.Length + 1; Array.Copy(userAgentPublicKey, 0, infoParameter, infoParameterIndex, userAgentPublicKey.Length); infoParameterIndex += userAgentPublicKey.Length; Array.Copy(applicationServerPublicKey, 0, infoParameter, infoParameterIndex, applicationServerPublicKey.Length); infoParameter[infoParameter.Length - 1] = 1; return infoParameter; } private static byte[] HmacSha256(byte[] key, byte[] value) { byte[] hash = null; using (HMACSHA256 hasher = new HMACSHA256(key)) { hash = hasher.ComputeHash(value); } return hash; } }
这能允许向推送消息添加内容。
public class PushServiceClient { ... private HttpRequestMessage PreparePushMessageDeliveryRequest(PushSubscription subscription, PushMessage message, VapidAuthentication authentication) { ... HttpRequestMessage pushMessageDeliveryRequest = ...; pushMessageDeliveryRequest = SetAuthentication(pushMessageDeliveryRequest, subscription, authentication); pushMessageDeliveryRequest = SetContent(pushMessageDeliveryRequest, subscription, message); return pushMessageDeliveryRequest; } ... private static HttpRequestMessage SetContent(HttpRequestMessage pushMessageDeliveryRequest, PushSubscription subscription, PushMessage message) { if (String.IsNullOrEmpty(message.Content)) { pushMessageDeliveryRequest.Content = null; } else { AsymmetricCipherKeyPair applicationServerKeys = ECKeyHelper.GenerateAsymmetricCipherKeyPair(); byte[] applicationServerPublicKey = ((ECPublicKeyParameters)applicationServerKeys.Public).Q.GetEncoded(false); pushMessageDeliveryRequest.Content = new Aes128GcmEncodedContent( new StringContent(message.Content, Encoding.UTF8), GetKeyingMaterial(subscription, applicationServerKeys.Private, applicationServerPublicKey), applicationServerPublicKey, 4096 ); } return pushMessageDeliveryRequest; } ... }
这样就完成了所有的加密工作了! 现在可以发送请求了。
public class PushServiceClient { ... private readonly HttpClient _httpClient = new HttpClient(); ... public async Task RequestPushMessageDeliveryAsync(PushSubscription subscription, PushMessage message, VapidAuthentication authentication) { HttpRequestMessage pushMessageDeliveryRequest = PreparePushMessageDeliveryRequest(subscription, message, authentication); HttpResponseMessage pushMessageDeliveryRequestResponse = await _httpClient.SendAsync(pushMessageDeliveryRequest); // TODO: HandlePushMessageDeliveryRequestResponse(pushMessageDeliveryRequestResponse); } ... }
剩下的最后一件事是处理来自推送服务的响应。
处理响应
我们可能会从推送服务中收到各种错误的响应代码,因为这些代码没有被标准化。规范中唯一公开提到的两个是400和403,但即使是这两个也没有被各种推送服务实现规范一致地使用。我们唯一可以确定的是表示成功的状态代码,即201 Created。在所有其他情况下,能做的最好的事情可能是抛出一个异常。
public class PushServiceClient { private static void HandlePushMessageDeliveryRequestResponse( HttpResponseMessage pushMessageDeliveryRequestResponse) { if (pushMessageDeliveryRequestResponse.StatusCode != HttpStatusCode.Created) { throw new PushServiceClientException(pushMessageDeliveryRequestResponse.ReasonPhrase, pushMessageDeliveryRequestResponse.StatusCode); } } }
还有一个信息可以从成功的响应中检索到——Location标头包含有创建的消息的URI。
这就是用于请求推送消息传递的方法了。希望你可以看一看示例应用程序,它包含了这里描述的所有内容,我计划很快就会在其中推出新的内容(例如JWT缓存)。