🏳️🌈♿️👩🎨🌱 En février, je remets 3% de mes ventes à des organismes qui favorisent la diversité, les arts et l'environnement 🏳️🌈♿️👩🎨🌱
Manuel d'alchimie du code
Changement de hCaptcha pour CloudFlare sur ListMonk - Une aventure !
Récemment, un visiteur de mon site web m'a contacté pour me dire que c'était rendu presque impossible de s'inscrire à mon infolettre. Les défis proposés par le logiciel anti-robots hCaptcha sont rendus très difficiles. Il existe une version automatisée qui ne demande aucun défi visuel, mais elle est très dispendieuse.
J'utilise le logiciel libre d'infolettre ListMonk. Par défaut, dans ce logiciel, il n'y a que hCaptcha qui est disponible comme outil de prévention d'exploitation contre les robots spammeurs.
Je souhaitais utiliser une solution libre, mais comme la plupart nécessitent aussi de mettre en place un serveur de validation, j'ai regardé les équivalents gratuits sur le marché. J'ai découvert que CloudFlare offre le service Turnstile gratuitement, et qu'il est beaucoup simple pour les visiteurs. Il suffit de cocher une case lorsque le script ne parvient pas à déterminer automatiquement que le visiteur est humain.
J'ai donc entrepris de modifier le logiciel ListMonk, dont le code est disponible sur GitHub, pour y inclure la fonctionnalité de CloudFlare Turnstile. J'ai fait face à plusieurs défis. ListMonk est développé en Go, un langage que je ne connais pas vraiment. De plus, je suis actuellement sur un ordinateur Mac Studio, avec un processeur Apple M2. Go est un langage compilé, et sur ces processeurs, ce n'est pas possible de compiler du Go avec l'architecture amd64.
J'ai donc fait le développement en compilant localement avec l'architecture de mon processeur, qui est arm64. Cependant, je ne pouvais pas copier mon binaire directement sur mon serveur. De plus, ce n'était pas possible de tester les fonctionnalités de CloudFlare, parce que ça ne fonctionne pas en mode local, il faut être sur un site avec le nom de domaine que nous avons enregistré pour obtenir les clés API.
J'ai donc configuré un GitHub Actions pour compiler le logiciel à distance, sur la plateforme GitHub, et produire le binaire dans les releases. De cet endroit, j'ai pu le télécharger directement sur mon serveur et faire les tests.
Je vais te détailler tout ça !
Enregistrement de CloudFlare Turnstile
J'avais déjà un compte sur CloudFlare, alors l'enregistrement s'est fait en quelques clics. Grosso modo, tu dois avoir un nom de domaine enregistré chez CloudFlare. Tu n'as pas à utiliser leur service de DNS (même s'il est pratique). Tu peux faire un bon bout de chemin avec leurs services gratuits. Donc aucune inquiétude à avoir à moins que tu aies un site web avec un trafic très important.
Voici les caractéristiques de l'offre gratuite de Turnstile :
- Elle Permet d'avoir jusqu'à 10 modules installés sur nos sites web, et jusqu'à 10 domaines pour chaque module.
- Le trafic autorisé est illimité en mode géré.
- CloudFlare va faire le traitement sur ses propres serveurs et identifier les visiteurs avec un cookie.
- Va nécessiter une entrée dans ton gestionnaire de consentement pour les pages avec lesquels tu utilises le module.
- Entre le nom de ton choix
- Ajoute un hôte (nom de domaine)
- Si tu as déjà des domaines chez CloudFlare, tu peux les utiliser ici.
- Sinon, tu peux ajouter d'autres domaines.
- Conserve le choix Managed
- N'active pas le Pre-clearance, sauf si ton domaine utilise la fonctionnalité proxy de CloudFlare.
- Clique sur Create
Tu vas ensuite obtenir une clé de site (qui est publique et visible dans le code de ta page) et une clé secrète, qui est privée et doit être configurée dans le logiciel serveur qui va valider la connexion.
Le site indique maintenant deux liens :
- Client side integration code, que tu mets dans ton formulaire sur ton site web
- Server side integration code, que tu vas utiliser dans le logiciel qui va valider la connexion. Nous allons commencer par le code client.
L'intégration client
L'intégration client consiste à intégrer du HTML dans le code de la page avec la classe cf-turnstile
, ainsi qu'un code javascript qui va activer le module.
Voici le code HTML à intégrer. Remplace yourSitekey
par la clé de site que tu as obtenue précédemment.
<div
class="cf-turnstile"
data-sitekey="yourSitekey"
data-callback="javascriptCallback">
</div>
Le module va vérifier qu'il est sur le bon domaine avant de s'activer. Ce qui fait que ce n'est pas possible de tester le module localement avant de l'avoir mis sur son site.
Si tu es dans un <form>
, tu n'as pas besoin de mettre la variable data-callback
. C'est notre cas ici, nous sommes bien dans un formulaire généré par ListMonk.
<div
class="cf-turnstile"
data-sitekey="yourSitekey">
</div>
Tu dois aussi inclure le script Javascript suivant :
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
L'intégration serveur
Comme nous utilisons ListMonk comme logiciel serveur, et que celui-ci est déjà configuré pour utiliser hCaptcha, nous allons devoir modifier le logiciel. Celui-ci est programmé dans le langage Go.
Modifications au logiciel ListMonk
Les modifications apportées au logiciel visent à généraliser l'utilisation d'un module de CAPTCHA au lieu de reposer seulement sur l'utilisation de hCaptcha.
Pour ce faire, j'ai identifié quatre variables dans le code qui permettent d'utiliser à la fois hCaptcha et Cloudflare Turnstile.
Je propose donc ici un début d'approche de généralisation qui pourra être bonifiée en ajoutant de nouveaux fournisseurs.
Les variables introduites
J'ai ajouté une section security
dans le fichier config.toml
qui contient les variables suivantes :
captcha_url
: Permet de spécifier l'URL du script CAPTCHAcaptcha_verify_url
: Définit l'endpoint du service de vérification du CAPTCHAcaptcha_class
: Spécifie la classe CSS utilisée pour le module CAPTCHA. C'est cette classe qu'on a utilisée dans la section<div>
pour l'intégration du client.captcha_response_field
: Indique le nom du champ de réponse du CAPTCHA dans la réponse du service de validation. Le contenu de ce champ est un dictionnaire qui contient entre autre la variablesuccess
qui indique la validation du jeton généré par le client du côté serveur.
Cette approche permet d'introduire de la flexibilité et d'éviter les valeurs codées en dur dans le code.
Commençons par modifier le modèle de code client généré par ListMonk. Cette information se trouve dans des modèles Vue situés dans /frontend/src/views/Forms.vue
.
Voici la section originale :
if (this.settings['security.enable_captcha']) {
h += '\n'
+ ` <div class="h-captcha" data-sitekey="${this.settings['security.captcha_key']}"></div>\n`
+ ` <${'script'} src="https://js.hcaptcha.com/1/api.js" async defer></${'script'}>\n`;
}
Elle devient maintenant :
if (this.settings['security.enable_captcha']) {
h += '\n'
+ ` <div class="${this.settings['security.captcha_class']}" data-sitekey="${this.settings['security.captcha_key']}"></div>\n`
+ ` <${'script'} src="${this.settings['security.captcha_url']}" async defer></${'script'}>\n`;
}
Tu remarqueras qu'il s'agit d'un modèele générique du code que nous avons modifié précédemment sur le site web.
Nous allons maintenant modifier les fichiers de code Go et ajoutant les variables là oùon trouve déjà des variables de CAPTCHA.
Dans le fichier captcha.go
, nous allons remplacer
type Opt struct {
CaptchaSecret string `json:"captcha_secret"`
}
par
type Opt struct {
CaptchaSecret string `json:"captcha_secret"`
CaptchaResponseField string `json:"captcha_response_field"`
VerifyURL string `json:"captcha_verify_url"`
}
Nous ajoutons ainsi dans la structure de données Opt, le nom du champ de validation dans la réponse du CAPTCHA, ainsi que l'URL de vérification. Le secret représente la clé secrète que nous avons reçue en créant le module. C'est une variable d'environnement qui est déjà définie dans ListMonk.
Le constructeur de structure Captcha
, qui inclus la structure Opt
, n'a aucune façon de gérer la valeur par défaut de l'URL de vérification VerifyURL
. Nous ajoutons donc celle de CloudFlare Turnstile.
On ajoute les lignes suivantes :
verifyURL := o.VerifyURL
if verifyURL == "" {
verifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
}
Le constructeur devient alors
func New(o Opt) *Captcha {
timeout := time.Second * 5
verifyURL := o.VerifyURL
if verifyURL == "" {
verifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
}
return &Captcha{
o: o,
client: &http.Client{
Timeout: timeout,
Transport: &http.Transport{
MaxIdleConnsPerHost: 10,
MaxConnsPerHost: 100,
ResponseHeaderTimeout: timeout,
IdleConnTimeout: timeout,
},
}}
}
Dans la fonction de vérification, au lieu d'utiliser l'URL codée en dur, que nous supprimons tout simplement
const (
rootURL = "https://hcaptcha.com/siteverify"
)
Nous allons utiliser la variable VerifyURL
que nous avons ajoutée à la structure Opt
. Dans la fonction, cette structure est référée en tant que o
, dans la structure c
de type Captcha
, qui a été définie par le constructeur ci-haut.
func (c *Captcha) Verify(token string) (error, bool) {
resp, err := c.client.PostForm(c.o.VerifyURL, url.Values{
"secret": {c.o.CaptchaSecret},
"response": {token},
})
if err != nil {
return err, false
}
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
fmt.Printf("Error closing response body: %v\n", err)
}
}(resp.Body)
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("Error reading response body: %v\n", err)
return err, false
}
var r captchaResp
if err := json.Unmarshal(body, &r); err != nil {
fmt.Printf("Error unmarshalling response JSON: %v\n", err)
return err, true
}
if !r.Success {
fmt.Printf("CAPTCHA verification failed with error codes: %v\n", r.ErrorCodes)
return fmt.Errorf("captcha failed: %s", strings.Join(r.ErrorCodes, ",")), false
}
return nil, true
}
Nous allons maintenant configurer le chemin entre le fichier de configuration config.toml
et l'usage des variables.
Premièrement, dans le fichier init.go
, nous allons ajouter les quatre variables à la structure Security
La structure originale
Security struct {
OIDC struct {
Enabled bool `koanf:"enabled"`
Provider string `koanf:"provider_url"`
ClientID string `koanf:"client_id"`
ClientSecret string `koanf:"client_secret"`
} `koanf:"oidc"`
EnableCaptcha bool `koanf:"enable_captcha"`
CaptchaKey string `koanf:"captcha_key"`
CaptchaSecret string `koanf:"captcha_secret"`
} `koanf:"security"`
devient
Security struct {
OIDC struct {
Enabled bool `koanf:"enabled"`
Provider string `koanf:"provider_url"`
ClientID string `koanf:"client_id"`
ClientSecret string `koanf:"client_secret"`
} `koanf:"oidc"`
EnableCaptcha bool `koanf:"enable_captcha"`
CaptchaKey string `koanf:"captcha_key"`
CaptchaURL string `koanf:"captcha_url"`
VerifyURL string `koanf:"captcha_verify_url"`
CaptchaSecret string `koanf:"captcha_secret"`
CaptchaClass string `koanf:"captcha_class"`
CaptchaResponseField string `koanf:"captcha_response_field"`
} `koanf:"security"`
Plus bas, dans la fonction initCaptcha, nous allons ajouter les deux variables que nous avons ajoutées à la structure Opt
précédemment :
func initCaptcha() *captcha.Captcha {
return captcha.New(captcha.Opt{
CaptchaSecret: ko.String("security.captcha_secret"),
VerifyURL: ko.String("security.captcha_verify_url"),
CaptchaResponseField: ko.String("security.captcha_response_field"),
})
}
Maintenant, dans le programme admin.go
, nous allons ajouter les quatre variables à la structure de données serverConfig
:
type serverConfig struct {
RootURL string `json:"root_url"`
FromEmail string `json:"from_email"`
Messengers []string `json:"messengers"`
Langs []i18nLang `json:"langs"`
Lang string `json:"lang"`
Permissions json.RawMessage `json:"permissions"`
Update *AppUpdate `json:"update"`
NeedsRestart bool `json:"needs_restart"`
HasLegacyUser bool `json:"has_legacy_user"`
Version string `json:"version"`
CaptchaURL string `json:"captcha_url"`
VerifyURL string `json:"captcha_verify_url"`
CaptchaResponseField string `json:"captcha_response_field"`
CaptchaClass string `json:"captcha_class"`
}
Un peu plus bas, dans la fonction handleGetServerConfig
, nous allons aussi ajouter les variables dans l'initialisation de serverConfig
:
out := serverConfig{
RootURL: app.constants.RootURL,
FromEmail: app.constants.FromEmail,
Lang: app.constants.Lang,
Permissions: app.constants.PermissionsRaw,
HasLegacyUser: app.constants.HasLegacyUser,
CaptchaURL: app.constants.Security.CaptchaURL,
VerifyURL: app.constants.Security.VerifyURL,
CaptchaClass: app.constants.Security.CaptchaClass,
CaptchaResponseField: app.constants.Security.CaptchaResponseField,
}
Nous sommes maintenant rendus au dernier fichier modifié. C'est le fichier qui contient la logique du formulaire l'abonnement.
On commence par ajouter les variables dans la structure subFormTpl
:
CaptchaClass
: pour la classe du<div>
CaptchaURL
: l'URL du script Javascript
type subFormTpl struct {
publicTpl
Lists []models.List
CaptchaKey string
CaptchaClass string
CaptchaURL string
}
On définit maintenant les deux variables CaptchaClass
et CaptchaURL
depuis la structure Security que nous avons vue plus haut
if app.constants.Security.EnableCaptcha {
out.CaptchaKey = app.constants.Security.CaptchaKey
out.CaptchaClass = app.constants.Security.CaptchaClass
out.CaptchaURL = app.constants.Security.CaptchaURL
}
On remplace le code de traitement du CAPTCHA suivant :
// Process CAPTCHA.
if app.constants.Security.EnableCaptcha {
err, ok := app.captcha.Verify(c.FormValue("h-captcha-response"))
if err != nil {
app.log.Printf("Captcha request failed: %v", err)
}
if !ok {
return c.Render(http.StatusBadRequest, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.invalidCaptcha")))
}
}
On utilise maintenant nos variables, ce qui donne :
// Process CAPTCHA.
if app.constants.Security.EnableCaptcha {
captchaResponse := c.FormValue(app.constants.Security.CaptchaResponseField)
err, ok := app.captcha.Verify(captchaResponse)
if err != nil {
app.log.Printf("Captcha request failed: %v", err)
}
if !ok {
return c.Render(http.StatusBadRequest, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.invalidCaptcha")))
}
}
Configuration GitHub Actions, tags et Release
J'ai créé une nouvelle tâche pour GitHub Actions, l'outil d'intégration continue de GitHub, qui permet de générer un binaire et de le placer comme artifact dans la section Release. Cette tâche va être déclenchée pour toute étiquette de version qui débute par la lettre v
.
L'application compilée portera simplement le nom listmonk
, ce qui va permettre de la remplacer directement sur le serveur.
Ce fichier est enregistré dans le répertoire .github/workflows/
du projet, et se nomme github-release.yml
. Il n'y a aucune variable d'environnement à configurer, car GITHUB_TOKEN
est une variable globale au dépôt GitHub.
name: Create Release
on:
push:
tags:
- 'v*' # Trigger on tags starting with 'v'
jobs:
build:
name: Build and Create Release
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.21' # Specify the Go version you're using
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y make git gcc
- name: Build project
run: make dist
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: $
with:
tag_name: $
release_name: Release $
draft: false
prerelease: false
- name: Upload Release Asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: $
with:
upload_url: $
asset_path: ./listmonk
asset_name: listmonk
asset_content_type: application/octet-stream
Remplacement du logiciel sur le serveur
Configurer les nouvelles variables d'environnement
Dans le fichier /var/www/listmonk/config.toml
, j'ai ajouté les quatre nouvelles variables pour Cloudflare Turnstile.
[security]
captcha_url = "https://challenges.cloudflare.com/turnstile/v0/api.js"
captcha_verify_url = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
captcha_class = "cf-turnstile"
captcha_response_field = "cf-turnstile-response"
Puis, j'ai modifié la configuration de nginx pour autoriser cloudflare comme source externe de scripts. Il faut appeler le script depuis CloudFlare et non en héberger une copie localement, car il y a souvent des mises à jour
Maintenant, on effectue le remplacement du binaire en déplaçcant la version actuelle, en téléchargeant directement le release que l'on a créé et en ajustant ces permissions. Ça se peut que tes noms d'utilisateur et de groupes soient différent. L'important, c'est de prendre les mêmes que le fichier listmonk_old
.
cp listmonk listmonk_old
rm listmonk
wget https://github.com/franc00018/listmonk/releases/download/v4.1.0-franc00018-2/listmonk
chmod a+x listmonk
chown listmonk:listmonk listmonk
On met maintenant la base de données à jour. C'est important de faire une sauvegarde au préalable, avec un outil comme Adminer, c'est beaucoup plus facile. Les informations pour se connecter à la base de données se trouvent aussi dans le fichier config.toml
.
./listmonk --upgrade
Enfin, on redémarre les services du serveur
systemctl restart listmonk.service
systemctl restart nginx.service
Voilà ! L'application ListMonk a été remplacée et utilise maintenant CloudFlare Turnstile au lieu de hCaptcha pour prévenir les abus par des robots.