原文: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缓存)。