使用ASP.NET Core实现网页通知推送Web Push Notification(二)

原文:Push Notifications and ASP.NET Core – Part 2 (Requesting Delivery)

出于阅读考量,对原文部分文本表述有修改,但涉及技术方面不会作修改

翻译:HHaoWang

这是关于Push Notifications的第二篇文章:

在上一篇文章中,我重点介绍了一般流程和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缓存)。

留下评论

您的电子邮箱地址不会被公开。 必填项已用*标注

Captcha Code