This commit is contained in:
Andras Schmelczer 2026-05-14 20:42:48 +01:00
parent 273d7a83ee
commit 084117cea8
48 changed files with 2283 additions and 890 deletions

View file

@ -9,7 +9,7 @@ use sha2::Sha256;
use tracing::{info, warn};
use crate::checkout_sessions::{
grant_license, mark_checkout_completed, mark_referral_invite_used, verify_checkout_completion,
complete_verified_checkout, reverse_license_for_payment_intent, verify_checkout_completion,
CheckoutCompletion,
};
use crate::state::SharedState;
@ -54,16 +54,52 @@ fn verify_signature(payload: &[u8], sig_header: &str, secret: &str) -> bool {
signed_payload.push(b'.');
signed_payload.extend_from_slice(payload);
signatures.into_iter().any(|sig_hex| {
// Verify every candidate signature without short-circuiting, so the total
// time taken doesn't depend on which (if any) signature matched.
let mut matched = false;
for sig_hex in signatures {
let Ok(sig_bytes) = hex::decode(sig_hex) else {
return false;
continue;
};
let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else {
return false;
continue;
};
mac.update(&signed_payload);
mac.verify_slice(&sig_bytes).is_ok()
})
// verify_slice itself is constant-time.
if mac.verify_slice(&sig_bytes).is_ok() {
matched = true;
}
}
matched
}
fn payment_intent_id_from_object(object: &serde_json::Value) -> Option<&str> {
object["payment_intent"]
.as_str()
.filter(|id| is_safe_stripe_id(id))
}
fn is_safe_stripe_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 128
&& id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}
fn reversal_event_is_actionable(event_type: &str, object: &serde_json::Value) -> bool {
match event_type {
"charge.refunded" => {
object["refunded"].as_bool().unwrap_or(false)
|| object["amount_refunded"].as_u64().unwrap_or(0) > 0
}
"charge.refund.updated" | "refund.created" | "refund.updated" => {
matches!(object["status"].as_str(), Some("succeeded"))
}
"charge.dispute.created" | "charge.dispute.funds_withdrawn" => true,
"charge.dispute.closed" => matches!(object["status"].as_str(), Some("lost")),
_ => false,
}
}
/// Handle Stripe webhook events.
@ -109,40 +145,11 @@ pub async fn post_stripe_webhook(
let session = &event["data"]["object"];
match verify_checkout_completion(&state, session).await {
Ok(CheckoutCompletion::Grant(checkout)) => {
if let Err(err) = mark_referral_invite_used(
&state,
&checkout.referral_invite_id,
&checkout.user_id,
)
.await
{
if let Err(err) = complete_verified_checkout(&state, &checkout).await {
warn!(
user_id = %checkout.user_id,
reservation_id = %checkout.reservation_id,
referral_invite_id = %checkout.referral_invite_id,
"Failed to mark referral invite used after Stripe checkout: {err:?}"
);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
if let Err(err) = grant_license(&state, &checkout.user_id).await {
warn!(
user_id = %checkout.user_id,
reservation_id = %checkout.reservation_id,
"Failed to grant license after Stripe checkout: {err:?}"
);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
if let Err(err) = mark_checkout_completed(
&state,
&checkout.reservation_id,
checkout.paid_amount_pence,
)
.await
{
warn!(
user_id = %checkout.user_id,
reservation_id = %checkout.reservation_id,
"Failed to mark checkout completed after license grant: {err:?}"
"Failed to complete verified Stripe checkout: {err:?}"
);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
@ -163,6 +170,52 @@ pub async fn post_stripe_webhook(
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
}
} else if matches!(
event_type,
"charge.refunded"
| "charge.refund.updated"
| "refund.created"
| "refund.updated"
| "charge.dispute.created"
| "charge.dispute.closed"
| "charge.dispute.funds_withdrawn"
) {
let object = &event["data"]["object"];
let Some(payment_intent_id) = payment_intent_id_from_object(object) else {
warn!(
event_id,
event_type, "Stripe reversal event missing payment intent id"
);
return StatusCode::OK.into_response();
};
if !reversal_event_is_actionable(event_type, object) {
info!(
payment_intent_id,
event_type, "Ignoring non-final Stripe reversal event"
);
return StatusCode::OK.into_response();
}
match reverse_license_for_payment_intent(&state, payment_intent_id, event_type).await {
Ok(Some(user_id)) => {
info!(
user_id,
payment_intent_id, event_type, "Processed Stripe payment reversal event"
);
}
Ok(None) => {
warn!(
payment_intent_id,
event_type, "Stripe reversal event had no matching checkout reservation"
);
}
Err(err) => {
warn!(
payment_intent_id,
event_type, "Failed to process Stripe payment reversal event: {err:?}"
);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
}
}
StatusCode::OK.into_response()