From f3b5068aba2f14708fc99024b0b8489ad2fe14f0 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Fri, 1 Feb 2019 17:16:28 -0500 Subject: [PATCH] paypal client and stub out webhook --- src/Billing/BillingSettings.cs | 9 ++ src/Billing/Controllers/PaypalController.cs | 56 +++++++ src/Billing/Startup.cs | 3 + src/Billing/Utilities/PaypalClient.cs | 160 ++++++++++++++++++++ src/Billing/appsettings.Production.json | 5 + src/Billing/appsettings.json | 20 ++- 6 files changed, 246 insertions(+), 7 deletions(-) create mode 100644 src/Billing/Controllers/PaypalController.cs create mode 100644 src/Billing/Utilities/PaypalClient.cs diff --git a/src/Billing/BillingSettings.cs b/src/Billing/BillingSettings.cs index e0b378bdc..851cf1c2e 100644 --- a/src/Billing/BillingSettings.cs +++ b/src/Billing/BillingSettings.cs @@ -6,5 +6,14 @@ public virtual string StripeWebhookKey { get; set; } public virtual string StripeWebhookSecret { get; set; } public virtual string BraintreeWebhookKey { get; set; } + public virtual PaypalSettings Paypal { get; set; } = new PaypalSettings(); + + public class PaypalSettings + { + public virtual bool Production { get; set; } + public virtual string ClientId { get; set; } + public virtual string ClientSecret { get; set; } + public virtual string WebhookId { get; set; } + } } } diff --git a/src/Billing/Controllers/PaypalController.cs b/src/Billing/Controllers/PaypalController.cs new file mode 100644 index 000000000..458ba9fa8 --- /dev/null +++ b/src/Billing/Controllers/PaypalController.cs @@ -0,0 +1,56 @@ +using Bit.Billing.Utilities; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using System.IO; +using System.Text; +using System.Threading.Tasks; + +namespace Bit.Billing.Controllers +{ + [Route("paypal")] + public class PaypalController : Controller + { + private readonly BillingSettings _billingSettings; + private readonly PaypalClient _paypalClient; + + public PaypalController( + IOptions billingSettings, + PaypalClient paypalClient) + { + _billingSettings = billingSettings?.Value; + _paypalClient = paypalClient; + } + + [HttpPost("webhook")] + public async Task PostWebhook([FromQuery] string key) + { + if(HttpContext?.Request == null) + { + return new BadRequestResult(); + } + + string body = null; + using(var reader = new StreamReader(HttpContext.Request.Body, Encoding.UTF8)) + { + body = await reader.ReadToEndAsync(); + } + + if(body == null) + { + return new BadRequestResult(); + } + + var verified = await _paypalClient.VerifyWebhookAsync(body, HttpContext.Request.Headers, + _billingSettings.Paypal.WebhookId); + if(!verified) + { + return new BadRequestResult(); + } + + var webhook = JsonConvert.DeserializeObject(body); + // TODO: process webhook + return new OkResult(); + } + } +} diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index fc5716916..871d507d5 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -39,6 +39,9 @@ namespace Bit.Billing // Repositories services.AddSqlServerRepositories(globalSettings); + // Paypal Client + services.AddSingleton(); + // Context services.AddScoped(); diff --git a/src/Billing/Utilities/PaypalClient.cs b/src/Billing/Utilities/PaypalClient.cs new file mode 100644 index 000000000..b40900d09 --- /dev/null +++ b/src/Billing/Utilities/PaypalClient.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Newtonsoft.Json; + +namespace Bit.Billing.Utilities +{ + public class PaypalClient + { + private readonly HttpClient _httpClient = new HttpClient(); + private readonly string _baseApiUrl; + private readonly string _clientId; + private readonly string _clientSecret; + + private AuthResponse _authResponse; + + public PaypalClient(BillingSettings billingSettings) + { + _baseApiUrl = _baseApiUrl = !billingSettings.Paypal.Production ? "https://api.sandbox.paypal.com/{0}" : + "https://api.paypal.com/{0}"; + _clientId = billingSettings.Paypal.ClientId; + _clientSecret = billingSettings.Paypal.ClientSecret; + } + + public async Task VerifyWebhookAsync(string webhookJson, IHeaderDictionary headers, string webhookId) + { + if(webhookJson == null) + { + throw new ArgumentException("No webhook json."); + } + + if(headers == null) + { + throw new ArgumentException("No headers."); + } + + if(!headers.ContainsKey("PAYPAL-TRANSMISSION-ID")) + { + return false; + } + + await AuthIfNeededAsync(); + + var req = new HttpRequestMessage + { + Method = HttpMethod.Post, + RequestUri = new Uri(string.Format(_baseApiUrl, "v1/notifications/verify-webhook-signature")) + }; + req.Headers.Authorization = new AuthenticationHeaderValue( + _authResponse.TokenType, _authResponse.AccessToken); + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + var verifyRequest = new VerifyWebookRequest + { + AuthAlgo = headers["PAYPAL-AUTH-ALGO"], + CertUrl = headers["PAYPAL-CERT-URL"], + TransmissionId = headers["PAYPAL-TRANSMISSION-ID"], + TransmissionTime = headers["PAYPAL-TRANSMISSION-TIME"], + TransmissionSig = headers["PAYPAL-TRANSMISSION-SIG"], + WebhookId = webhookId + }; + var verifyRequestJson = JsonConvert.SerializeObject(verifyRequest); + verifyRequestJson = verifyRequestJson.Replace("\"__WEBHOOK_BODY__\"", webhookJson); + req.Content = new StringContent(verifyRequestJson, Encoding.UTF8, "application/json"); + + var response = await _httpClient.SendAsync(req); + if(!response.IsSuccessStatusCode) + { + throw new Exception("Failed to verify webhook"); + } + + var responseContent = await response.Content.ReadAsStringAsync(); + var verifyResponse = JsonConvert.DeserializeObject(responseContent); + return verifyResponse.Verified; + } + + private async Task AuthIfNeededAsync() + { + if(_authResponse?.Expired ?? true) + { + var req = new HttpRequestMessage + { + Method = HttpMethod.Post, + RequestUri = new Uri(string.Format(_baseApiUrl, "v1/oauth2/token")) + }; + var authVal = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_clientId}:{_clientSecret}")); + req.Headers.Authorization = new AuthenticationHeaderValue("Basic", authVal); + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + req.Content = new FormUrlEncodedContent(new[] + { + new KeyValuePair("grant_type", "client_credentials") + }); + + var response = await _httpClient.SendAsync(req); + if(!response.IsSuccessStatusCode) + { + throw new Exception("Failed to auth with PayPal"); + } + + var responseContent = await response.Content.ReadAsStringAsync(); + _authResponse = JsonConvert.DeserializeObject(responseContent); + return true; + } + return false; + } + + public class VerifyWebookRequest + { + [JsonProperty("auth_algo")] + public string AuthAlgo { get; set; } + [JsonProperty("cert_url")] + public string CertUrl { get; set; } + [JsonProperty("transmission_id")] + public string TransmissionId { get; set; } + [JsonProperty("transmission_sig")] + public string TransmissionSig { get; set; } + [JsonProperty("transmission_time")] + public string TransmissionTime { get; set; } + [JsonProperty("webhook_event")] + public string WebhookEvent { get; set; } = "__WEBHOOK_BODY__"; + [JsonProperty("webhook_id")] + public string WebhookId { get; set; } + } + + public class VerifyWebookResponse + { + [JsonProperty("verification_status")] + public string VerificationStatus { get; set; } + public bool Verified => VerificationStatus == "SUCCESS"; + } + + public class AuthResponse + { + private DateTime _created; + + public AuthResponse() + { + _created = DateTime.UtcNow; + } + + [JsonProperty("scope")] + public string Scope { get; set; } + [JsonProperty("nonce")] + public string Nonce { get; set; } + [JsonProperty("access_token")] + public string AccessToken { get; set; } + [JsonProperty("token_type")] + public string TokenType { get; set; } + [JsonProperty("app_id")] + public string AppId { get; set; } + [JsonProperty("expires_in")] + public long ExpiresIn { get; set; } + public bool Expired => DateTime.UtcNow > _created.AddSeconds(ExpiresIn - 30); + } + } +} diff --git a/src/Billing/appsettings.Production.json b/src/Billing/appsettings.Production.json index 5ea6892d0..add96fc3b 100644 --- a/src/Billing/appsettings.Production.json +++ b/src/Billing/appsettings.Production.json @@ -15,5 +15,10 @@ "braintree": { "production": true } + }, + "billingSettings": { + "paypal": { + "production": false + } } } diff --git a/src/Billing/appsettings.json b/src/Billing/appsettings.json index 82c91ba59..3f34debda 100644 --- a/src/Billing/appsettings.json +++ b/src/Billing/appsettings.json @@ -45,18 +45,24 @@ "notificationHub": { "connectionString": "SECRET", "hubName": "SECRET" + }, + "braintree": { + "production": false, + "merchantId": "SECRET", + "publicKey": "SECRET", + "privateKey": "SECRET" } }, "billingSettings": { "jobsKey": "SECRET", "stripeWebhookKey": "SECRET", "stripeWebhookSecret": "SECRET", - "braintreeWebhookKey": "SECRET" - }, - "braintree": { - "production": false, - "merchantId": "SECRET", - "publicKey": "SECRET", - "privateKey": "SECRET" + "braintreeWebhookKey": "SECRET", + "paypal": { + "production": false, + "clientId": "SECRET", + "clientSecret": "SECRET", + "webhookId": "SECRET" + } } }