Nous avons passé cinq articles à contrôler minutieusement chaque requête qui quittait notre application. Maintenant, nous sommes sur le point de laisser Internet commencer à lui envoyer des requêtes.
Qu'est-ce qui pourrait bien mal tourner?
Pas mal de choses, en fait. Jusqu'à maintenant, notre application avait le contrôle. C'est elle qui décidait quand appeler Slack. C'est elle qui décidait quoi envoyer. C'est elle qui décidait quel canal et quel fil de discussion devaient recevoir le message.
L'API Events inverse cette relation. Slack décide quand quelque chose s'est produit. Slack nous envoie une requête HTTP. Notre application doit décider si la requête est légitime, si nous l'avons déjà vue et quoi en faire.
C'est un modèle de confiance très différent.
Si quelqu'un peut convaincre notre point de terminaison qu'il est Slack, il peut injecter de fausses réponses de soutien dans les conversations des clients.
- « Votre remboursement a été approuvé. »
- « Veuillez nous envoyer votre mot de passe bancaire. »
- « Nous avons remplacé tout notre service d'assistance par des ratons laveurs. »
Deux de ces cas constitueraient un incident de sécurité grave. Le troisième expliquerait au moins la base de code. Heureusement, Slack s'attend à ce que nous soyons méfiants.
Slack ne se contente pas de "POSTer du JSON"
La version naïve d'un point de terminaison d'API Events ressemble à ceci :
Receive request
Deserialize JSON
Process event
Return successC'est aussi comme ça qu'on construit un point de terminaison qui accepte des instructions de n'importe qui possédant votre URL. Avant de toucher au JSON, nous devons répondre à une question plus importante :
Cette requête vient-elle vraiment de Slack?
Slack signe chaque requête à l'aide du secret de signature associé à votre application. La requête comprend un en-tête X-Slack-Signature et un en-tête X-Slack-Request-Timestamp. Votre application utilise le corps brut de la requête, l'horodatage et sa copie du secret de signature pour calculer de façon indépendante la signature attendue. Si les signatures concordent et que l'horodatage est récent, la requête est authentique.
Sinon, quelqu'un ment.
Le corps brut veut dire le corps brut
Ce détail est suffisamment important pour être agaçant. Slack calcule sa signature à partir des octets exacts qu'il a envoyés. Pas le JSON désérialisé. Pas le JSON que vous avez analysé puis sérialisé à nouveau. Pas le même contenu utile avec des clés réordonnées parce que votre sérialiseur se sentait créatif.
Le corps original de la requête.
Ces deux structures peuvent vouloir dire exactement la même chose :
{
"type": "event_callback",
"event_id": "Ev123"
}{"event_id":"Ev123","type":"event_callback"}Mais aux yeux de Slack, elles ne produisent pas la même signature. Les espaces comptent. L'ordre des clés compte. Chaque octet compte. Nous capturons donc d'abord le corps brut et le laissons intact jusqu'après la vérification de la signature. Ce n'est pas Slack qui complique les choses. C'est la cryptographie qui est extrêmement littérale.
Pourquoi l'horodatage compte
Une signature valide prouve que la requête a été signée à l'aide du secret de signature de notre application Slack. Ce n'est pas tout à fait suffisant. Imaginez que quelqu'un enregistre une requête légitime aujourd'hui et envoie exactement la même requête de nouveau la semaine prochaine. La signature serait toujours valide et le corps n'aurait pas changé.
C'est ce qu'on appelle une attaque par rejeu.
Slack inclut l'horodatage de la requête dans le calcul de la signature. Avant d'accepter la requête, nous rejetons les horodatages qui diffèrent de l'heure actuelle de plus de cinq minutes. Slack recommande précisément cette fenêtre de cinq minutes pour se protéger contre les requêtes rejouées.
Autrement dit :
Valid signature + recent timestamp = probably Slack
Valid signature + timestamp from last Tuesday = get the hell away from meL'horloge de votre serveur compte maintenant. Si elle est nettement fausse, les requêtes Slack valides commenceront à échouer, alors gardez vos systèmes synchronisés. Le temps est déjà assez compliqué sans que votre serveur invente le sien.
Faire évoluer slack.cfc
Dans la partie 5, notre composant connaissait deux valeurs :
property name="bot_token";
property name="default_channel";La vérification des requêtes entrantes en introduit une troisième :
property name="signing_secret";Le jeton du bot authentifie les requêtes que nous envoyons à Slack. Le secret de signature vérifie les requêtes que Slack nous envoie. Ils ne sont pas interchangeables. Ils résolvent les deux côtés opposés de l'intégration.
Mettez à jour le squelette du composant :
component output="false" {
property name="bot_token";
property name="default_channel";
property name="signing_secret";
public slack function init(
required string bot_token,
required string default_channel,
required string signing_secret
) {
variables.bot_token = arguments.bot_token;
variables.default_channel = arguments.default_channel;
variables.signing_secret = arguments.signing_secret;
return this;
}
}C'est pourquoi nous avons créé un composant au lieu de disperser des appels cfhttp isolés dans des modèles aléatoires. Les détails propres à Slack ont un seul endroit. L'authentification sortante vit ici. La vérification entrante vit ici. Le reste de l'application demeure heureusement ignorant de l'un comme de l'autre.
Comparer des secrets sans être trop malin
À la fin du processus de vérification, nous devons comparer la signature envoyée par Slack avec celle que nous avons calculée. Une comparaison normale de chaînes fonctionne sur le plan fonctionnel :
return arguments.expected == arguments.provided;Mais les comparaisons normales de chaînes peuvent s'arrêter dès qu'elles trouvent un caractère qui ne correspond pas. Cela peut théoriquement révéler des informations de temporisation sur la quantité de la valeur qui était correcte.
Quelqu'un est-il susceptible de lancer une attaque sophistiquée par temporisation contre votre point de terminaison de clavardage d'assistance? Probablement pas.
Est-ce qu'une comparaison sécurisée contre les attaques temporelles est difficile à implémenter? Pas du tout.
Le code de sécurité n'est pas l'endroit idéal pour dire : « Probablement correct. » Ajoutez cet utilitaire privé à slack.cfc :
private boolean function secure_compare(
required string expected,
required string provided
) {
if ( len( arguments.expected ) != len( arguments.provided ) ) {
return false;
}
var difference = 0;
for ( var index = 1; index <= len( arguments.expected ); index++ ) {
difference = bitOr(
difference,
bitXor(
asc( mid( arguments.expected, index, 1 ) ),
asc( mid( arguments.provided, index, 1 ) )
)
);
}
return difference == 0;
}La boucle évalue chaque caractère au lieu de s'arrêter à la première différence. Si chaque caractère correspond, difference reste à zéro. Sinon, non. Ce n'est pas glamour. C'est en train de devenir une sorte de thème.
Vérifier la requête Slack
Maintenant, nous pouvons construire la méthode publique de vérification. Slack construit une chaîne de base en utilisant :
v0:{timestamp}:{raw request body}Il signe cette chaîne à l'aide de HMAC SHA-256 et préfixe le condensé hexadécimal obtenu avec v0=. Notre implémentation effectue le même calcul :
public boolean function verify_request(
required string raw_body,
required string timestamp,
required string signature,
numeric max_age_seconds = 300
) {
if (
!len( trim( arguments.timestamp ) )
|| !len( trim( arguments.signature ) )
) {
return false;
}
if ( !isNumeric( arguments.timestamp ) ) {
return false;
}
var current_epoch = createObject(
"java",
"java.time.Instant"
).now().getEpochSecond();
if (
abs( current_epoch - val( arguments.timestamp ) )
> arguments.max_age_seconds
) {
return false;
}
var signature_base = "v0:#arguments.timestamp#:#arguments.raw_body#";
var expected_signature = "v0=" & lCase(
hmac(
signature_base,
variables.signing_secret,
"HmacSHA256",
"UTF-8"
)
);
return secure_compare(
expected = expected_signature,
provided = lCase( arguments.signature )
);
}La méthode renvoie délibérément une valeur booléenne. Sa tâche est de répondre à une seule question : « Cette requête est-elle authentique et récente? » Elle ne parse pas les événements, n'actualise pas les conversations et n'envoie pas de notifications de navigateur.
Elle vérifie Slack. Une fonction. Une responsabilité. Moins d'occasions pour Votre futur vous de découvrir qu'une méthode de sécurité envoie aussi des courriels pour des raisons dont personne ne se souvient.
La première requête est un défi
Lorsque vous entrez une URL de requête de l'API des événements dans Slack, Slack ne fait pas immédiatement confiance au fait que vous la contrôlez. Ce qui est raisonnable. La première requête a une charge utile qui ressemble à ceci :
{
"type": "url_verification",
"challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMhkvFXO7tYXAYM8P"
}Votre point de terminaison vérifie la signature de la requête et renvoie la valeur challenge. C'est tout. Pas de danse OAuth ni d'échange compliqué, ni d'échange cérémoniel de certificats sous une pleine lune.
Slack envoie une valeur. Vous la renvoyez. Slack croit officiellement que vous pouvez recevoir des requêtes HTTP.
Créer le point de terminaison des événements
Nous avons besoin d'une URL publique que Slack peut appeler. Pour l'artefact générique du dépôt, nous utiliserons :
/slack/events.cfm. Une application routée pourrait plutôt acheminer cette logique par l'entremise d'un contrôleur. Le cadre environnant ne change pas les éléments importants. Le point de terminaison doit :
- Capturer le corps brut de la requête.
- Lire les en-têtes de signature de Slack.
- Vérifier la requête.
- Analyser le JSON seulement après la vérification.
- Répondre aux défis de vérification d'URL.
- Stocker les événements valides pour un traitement ultérieur.
- Répondre rapidement.
Voici la première version :
<cfscript>
request_data = getHttpRequestData();
raw_body = isBinary( request_data.content )
? charsetEncode( request_data.content, "UTF-8" )
: request_data.content;
slack_signature = request_data.headers[ "X-Slack-Signature" ] ?: "";
slack_timestamp = request_data.headers[ "X-Slack-Request-Timestamp" ] ?: "";
if (
!application.slack.verify_request(
raw_body = raw_body,
timestamp = slack_timestamp,
signature = slack_signature
)
) {
cfheader(
statuscode = 401,
statustext = "Unauthorized"
);
cfcontent(
type = "application/json",
variable = charsetDecode(
serializeJSON( { "ok" = false } ),
"UTF-8"
)
);
abort;
}
if ( !isJSON( raw_body ) ) {
cfheader(
statuscode = 400,
statustext = "Bad Request"
);
cfcontent(
type = "application/json",
variable = charsetDecode(
serializeJSON( { "ok" = false } ),
"UTF-8"
)
);
abort;
}
payload = deserializeJSON( raw_body );
if (
payload.type == "url_verification"
&& structKeyExists( payload, "challenge" )
) {
cfcontent(
type = "text/plain; charset=utf-8",
variable = charsetDecode(
payload.challenge,
"UTF-8"
)
);
abort;
}
if ( payload.type == "event_callback" ) {
application.slack_event_service.enqueue_event(
payload = payload,
raw_body = raw_body,
retry_number = request_data.headers[ "X-Slack-Retry-Num" ] ?: "",
retry_reason = request_data.headers[ "X-Slack-Retry-Reason" ] ?: ""
);
}
cfcontent(
type = "application/json",
variable = charsetDecode(
serializeJSON( { "ok" = true } ),
"UTF-8"
)
);
</cfscript>Ce point de terminaison est intentionnellement mince. Il vérifie. Il valide. Il stocke. Il accuse réception. Il n'effectue pas le flux de travail réel de la conversation. Cela appartient ailleurs.
Slack est impatient
Slack s'attend à une réponse HTTP réussie dans un délai de trois secondes. Si le point de terminaison prend trop de temps ou renvoie une erreur, Slack considère la livraison comme un échec et la réessaie. Slack recommande explicitement d'accuser réception immédiatement et de traiter les événements de manière asynchrone.
Trois secondes, ça semble généreux jusqu'à ce que votre point de terminaison commence à faire tout ça :
Repérer la conversation
Insérer le message
Résoudre l'utilisateur du soutien
Télécharger une pièce jointe
Publier un événement SSE
Envoyer un courriel
Mettre à jour les analyses
Appeler un modèle d'IA parce qu'apparemment on déteste la latenceNe traitez pas tout l'événement dans la requête webhook. Acceptez-le. Stockez-le. Retournez un succès, puis traitez-le séparément. C'est la version entrante de la leçon sur la mise en file d'attente de la partie 5. Votre client ne devrait pas attendre Slack. Slack ne devrait pas attendre votre logique d'affaires. Personne n'aime attendre.
Créer une boîte de réception d'événements
Il nous faut un endroit durable pour déposer les événements entre la réception et le traitement. Une simple table suffit :
CREATE TABLE slack_event_inbox (
event_id VARCHAR(64) PRIMARY KEY,
event_type VARCHAR(100) NOT NULL,
payload TEXT NOT NULL,
raw_body TEXT NOT NULL,
retry_number INTEGER NULL,
retry_reason VARCHAR(100) NULL,
received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
processed_at TIMESTAMP NULL,
attempt_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT NULL
);Le nom inbox est intentionnel. Ce n'est pas notre table de messages de chat. C'est une boîte de réception d'intégration. Slack a livré quelque chose. Nous en avons accepté la garde. Un worker le traitera plus tard. Si le traitement échoue, la charge utile d'origine reste disponible pour inspection et pour une autre tentative.
C'est bien plus agréable que de consigner « quelque chose s'est mal passé » en espérant que l'observabilité veuille dire fixer intensément un tableau de bord.
Les événements en double sont normaux
Slack retente les livraisons d'événements échouées. La première tentative de reprise peut survenir presque immédiatement. Les reprises suivantes se produisent après des délais croissants. Slack inclut les en-têtes retry-number et retry-reason pour que vous puissiez voir pourquoi l'événement a été renvoyé.
Cela veut dire que votre point de terminaison peut recevoir le même événement plus d'une fois. Ce n'est pas un cas limite. C'est un comportement documenté.
Chaque rappel de l'API Events inclut un event_id qui est unique à l'échelle mondiale pour cet événement. Utilisez-le. Faites de event_id la clé primaire ou une clé unique, et la livraison en double devient inoffensive.
Première livraison
INSERT réussit
Deuxième livraison
INSERT ne fait rien
Troisième livraison
Ne fait toujours rienL'autre option consiste à laisser une réponse Slack devenir trois messages au client parce que votre point de terminaison a été lent une seule fois. Les clients adorent recevoir la même réponse trois fois. Ça donne au système un air nerveux.
Construire slack_event_service.cfc
Le point de terminaison ne devrait pas connaître SQL. Il devrait remettre l'événement à un service dont le rôle est de gérer la file entrante. Créez slack_event_service.cfc :
component output="false" {
public slack_event_service function init() {
return this;
}
public boolean function enqueue_event(
required struct payload,
required string raw_body,
string retry_number = "",
string retry_reason = ""
) {
if ( !structKeyExists( arguments.payload, "event_id" ) ) {
throw(
type = "Slack.MissingEventId",
message = "Slack event callback did not include event_id.",
detail = serializeJSON( arguments.payload )
);
}
var event_type = "unknown";
if (
structKeyExists( arguments.payload, "event" )
&& structKeyExists( arguments.payload.event, "type" )
) {
event_type = arguments.payload.event.type;
}
queryExecute(
"
INSERT INTO slack_event_inbox (
event_id,
event_type,
payload,
raw_body,
retry_number,
retry_reason
)
VALUES (
:event_id,
:event_type,
:payload,
:raw_body,
:retry_number,
:retry_reason
)
ON CONFLICT ( event_id ) DO NOTHING
",
{
event_id = {
value = arguments.payload.event_id,
cfsqltype = "cf_sql_varchar"
},
event_type = {
value = event_type,
cfsqltype = "cf_sql_varchar"
},
payload = {
value = serializeJSON( arguments.payload ),
cfsqltype = "cf_sql_longvarchar"
},
raw_body = {
value = arguments.raw_body,
cfsqltype = "cf_sql_longvarchar"
},
retry_number = {
value = len( trim( arguments.retry_number ) )
? val( arguments.retry_number )
: javacast( "null", "" ),
cfsqltype = "cf_sql_integer",
null = !len( trim( arguments.retry_number ) )
},
retry_reason = {
value = arguments.retry_reason,
cfsqltype = "cf_sql_varchar",
null = !len( trim( arguments.retry_reason ) )
}
},
{
result = "insert_result"
}
);
return insert_result.recordCount > 0;
}
}La syntaxe ON CONFLICT montrée ici est celle de PostgreSQL. Pour SQL Server, MySQL ou Oracle, utilisez l'opération atomique équivalente d'insertion si absent propre à la base de données. Ne la remplacez pas par :
SELECT pour vérifier si cela existe
Puis INSERT si ce n'est pas le casDeux requêtes peuvent passer le SELECT en même temps. Ensuite, les deux essaient d'insérer. Les conditions de compétition sont des bogues qui attendent que la production ait suffisamment de trafic pour devenir intéressantes.
Laissez la base de données imposer l'unicité. Elle est bien meilleure que votre application pour ce travail.
Que signifie la valeur de retour?
enqueue_event() retourne true lorsqu'elle stocke un nouvel événement. Elle retourne false lorsque l'événement existe déjà. Les deux résultats sont des issues réussies pour le point de terminaison HTTP. Un doublon ne veut pas dire que Slack a fait quelque chose de mal; ça veut dire que Slack n'était pas certain que nous avions bien reçu la tentative précédente.
Nous l'avons déjà. Accusez réception de la reprise et passez à autre chose. L'idempotence, c'est souvent juste l'art de répondre à du travail en double avec un haussement d'épaules blasé, comme un membre de la génération X à qui l'on demande comment s'est passée sa journée.
Ne faites pas confiance au jeton obsolète
Les charges utiles d'événements Slack peuvent encore inclure un champ token. Ne l'utilisez pas pour l'authentification des requêtes. Slack identifie ce token comme étant le mécanisme de vérification obsolète et recommande plutôt la vérification par secret de signature. Nous vérifions :
- Le corps brut
- L'horodatage de la requête
- La signature de la requête
- Le secret de signature
Le token à l'intérieur du JSON n'a aucun rôle dans la décision de savoir si la requête est authentique. Les anciens mécanismes d'authentification ont la mauvaise habitude de survivre parce que les supprimer semble risqué. Continuer à s'y fier est généralement plus risqué.
Ce que nous avons construit
À ce stade, le chemin entrant ressemble à ceci :
Slack
│
▼
Public Events Endpoint
│
├── Capture raw body
├── Reject stale timestamp
├── Verify signature
├── Handle challenge
├── Store event once
└── Return HTTP 200
│
▼
Background ProcessingLe dépôt contient maintenant trois éléments importants :
slack.cfc
conversation_service.cfc
slack_event_service.cfcEt un point de terminaison public :
slack/events.cfmPlus important encore, chaque élément a un rôle clair. slack.cfc comprend l'API et les règles de sécurité de Slack. conversation_service.cfc coordonne les conversations et les fils de discussion Slack sortants. slack_event_service.cfc accepte les événements entrants de façon durable et idempotente. Le point de terminaison relie HTTP à ces services sans se transformer en un monument de 900 lignes à la mauvaise maîtrise de soi.
Les plus grandes leçons
Recevoir des événements webhook n'est pas difficile. Les recevoir de manière responsable demande un peu plus de travail.
Vous devez préserver le corps brut. Vérifier la signature. Rejeter les requêtes périmées. Gérer le défi de Slack. Répondre rapidement. Stocker les événements de façon durable. Ignorer les doublons. Sauvegarder assez d'information pour comprendre les échecs plus tard.
Rien de tout cela ne rend la démo plus impressionnante. Tout cela rend la production moins excitante. Et malgré ce que les présentations de conférence peuvent laisser croire, les systèmes de production moins excitants sont généralement préférables.
La prochaine fois
Slack peut maintenant livrer des événements à notre application de façon sécurisée. Nous n'en faisons encore rien d'utile. Cela change dans le prochain article.
Dans la partie 7, nous traiterons les événements de message mis en file d'attente, ignorerons les messages de bot et les activités non pertinentes, associerons thread_ts de Slack à la bonne conversation, enregistrerons la réponse de soutien, et la publierons dans le navigateur à l'aide de Server-Sent Events.
Parce qu'après six articles, il est enfin temps que quelqu'un de l'autre côté dise bonjour.