fix(ci,backend): securise le jeton du scan DAST, seme des donnees et refait ses garde-fous
This commit is contained in:
+151
-74
@@ -1,12 +1,14 @@
|
||||
name: DAST
|
||||
|
||||
# Scan dynamique OWASP ZAP de l'API (issue #41). Il attaque une API qui tourne : le job démarre
|
||||
# la base et le backend sur le runner, crée un compte `lecteur` jetable (scripts/dast-token.sh),
|
||||
# puis lance ZAP sur le contrat OpenAPI avec le jeton de ce compte.
|
||||
# la base et le backend sur le runner, sème un site et quelques relevés (sans ça le scan ne
|
||||
# frappe que des gestionnaires d'erreur), crée un compte `lecteur` jetable
|
||||
# (scripts/dast-token.sh), puis lance ZAP sur le contrat OpenAPI avec le jeton de ce compte.
|
||||
#
|
||||
# Non bloquant pour l'instant : le volume d'alertes d'un premier passage est inconnu. Le rapport
|
||||
# est publié en artefact et dans le résumé du job. Le fixer en seuil viendra une fois les alertes
|
||||
# triées.
|
||||
# Non bloquant pour l'instant sur les alertes (`continue-on-error` sur la seule étape du scan) :
|
||||
# le volume d'un premier passage trié est inconnu. Deux étapes suivantes, elles, bloquent si le
|
||||
# scan n'a rien testé (import du contrat, absence de toute réponse de succès) : un job vert doit
|
||||
# vouloir dire qu'un scan a eu lieu.
|
||||
#
|
||||
# Piège : ce scan tape la configuration par défaut du backend (`APP_ENV=local`, pas de TLS, pas
|
||||
# de reverse proxy). Il ne dit rien des en-têtes ni du TLS posés par le proxy en production, et
|
||||
@@ -34,7 +36,10 @@ jobs:
|
||||
zap:
|
||||
name: Scan OWASP ZAP de l'API
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
# Généreux face aux ~2 minutes observées de bout en bout : le vrai plafond est
|
||||
# `scanner.maxScanDurationInMins` (étape Scan ZAP), sous le TTL du jeton. Une annulation par
|
||||
# ce timeout-ci n'exécute pas les étapes `always()` : mieux vaut ne jamais l'atteindre.
|
||||
timeout-minutes: 30
|
||||
|
||||
# Même image que docker-compose.yml : la première migration refuse de s'appliquer sans
|
||||
# l'extension TimescaleDB (cf. backend.yml).
|
||||
@@ -60,24 +65,27 @@ jobs:
|
||||
APP_SECRET_KEY: secret-de-scan-assez-long-pour-le-validateur
|
||||
APP_ENV: local
|
||||
# Le jeton du lecteur doit survivre à toute la durée du scan (15 minutes par défaut).
|
||||
# 3600 est le plafond accepté par la configuration.
|
||||
# 3600 est le plafond accepté par la configuration ; `scanner.maxScanDurationInMins`
|
||||
# (étape Scan ZAP) reste très en dessous, marge comprise pour les étapes qui l'entourent.
|
||||
APP_ACCESS_TOKEN_TTL_SECONDS: "3600"
|
||||
PGPASSWORD: change_me
|
||||
|
||||
steps:
|
||||
- name: Récupère le dépôt
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Installe uv
|
||||
# Épinglé sur le commit du tag v5 (règle Sonar githubactions:S7637).
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
# Épinglé sur le commit du tag v7 (règle Sonar githubactions:S7637 : dépendance tierce,
|
||||
# contrairement à actions/checkout ou actions/upload-artifact, premières parties).
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: apps/backend/uv.lock
|
||||
# `prune-cache` vaut `true` par défaut sur ce commit : l'étape de post-job « Pruning
|
||||
# cache » est restée bloquée 5 minutes avant d'échouer (exit code 2), sans lien avec le
|
||||
# scan lui-même (les 16 étapes précédentes passaient). Le prune n'est qu'une optimisation
|
||||
# de taille de cache, pas une garantie : le désactiver ici retire le blocage.
|
||||
# `prune-cache` vaut `true` par défaut (encore sur ce commit) : l'étape de post-job
|
||||
# « Pruning cache » est restée bloquée 5 minutes avant d'échouer (exit code 2) sur un
|
||||
# run où les 16 étapes précédentes passaient, sans lien avec le scan. Le prune n'est
|
||||
# qu'une optimisation de taille de cache entre deux runs, pas une garantie : le
|
||||
# désactiver retire le blocage sans rien changer au comportement du job.
|
||||
prune-cache: false
|
||||
|
||||
- name: Installe l'interpréteur déclaré par .python-version
|
||||
@@ -99,6 +107,24 @@ jobs:
|
||||
run: uv run --frozen --no-sync --no-build alembic upgrade head
|
||||
working-directory: apps/backend
|
||||
|
||||
# Sans données, `GET /sites` rend `[]`, chaque `/{site_id}` rend 404 et le scan actif ne
|
||||
# frappe que des gestionnaires d'erreur plutôt que la logique métier. `db/seeds/` est vide
|
||||
# (pas encore d'outillage de jeu de données pour la CI) : un site et deux relevés à la main,
|
||||
# juste assez pour que les routes de lecture aient quelque chose à rendre.
|
||||
- name: Insère un site et des relevés minimaux pour le scan
|
||||
run: |
|
||||
psql -h localhost -p 5433 -U enervision -d enervision_dast <<'SQL'
|
||||
INSERT INTO site (site_id, site_name, site_type, location, capacity_kw, status)
|
||||
VALUES ('dast-site', 'Site du scan DAST', 'bureau', 'CI', 50, 'actif')
|
||||
ON CONFLICT (site_id) DO NOTHING;
|
||||
|
||||
INSERT INTO reading (site_id, timestamp, source, consumption_kw, consumption_kwh, is_working_hours, data_quality, raw_data)
|
||||
VALUES
|
||||
('dast-site', now() - interval '2 hours', 'api_current', 12.5, 12.5, true, 'good', '{}'),
|
||||
('dast-site', now() - interval '1 hour', 'api_current', 13.0, 13.0, true, 'good', '{}')
|
||||
ON CONFLICT DO NOTHING;
|
||||
SQL
|
||||
|
||||
- name: Démarre l'API
|
||||
run: |
|
||||
nohup uv run --frozen --no-sync --no-build uvicorn app.main:create_app --factory \
|
||||
@@ -120,108 +146,157 @@ jobs:
|
||||
echo "jeton=$jeton" >> "$GITHUB_OUTPUT"
|
||||
working-directory: apps/backend
|
||||
|
||||
# `--network host` : ZAP atteint l'API sur le localhost du runner. Le dossier de sortie
|
||||
# appartient à l'utilisateur du conteneur (uid 1000), sans droits d'écriture pour les autres
|
||||
# (règle Sonar S2612 : pas de `chmod 777`).
|
||||
# Étape distincte du scan lui-même, et sans `continue-on-error` : un `curl` qui échoue ici
|
||||
# (API tombée juste après la sonde de readiness, par exemple) doit rester un échec visible,
|
||||
# pas se travestir en « ZAP n'a importé aucune URL » à l'étape de garde suivante.
|
||||
- name: Prépare le contrat pour ZAP
|
||||
run: |
|
||||
mkdir -p zap-out zap-logs
|
||||
curl -fsS http://localhost:8000/openapi.json -o zap-out/openapi.json
|
||||
# Le dossier passe à l'uid 1000 (utilisateur du conteneur ZAP) : le runner n'y écrit
|
||||
# plus après ce chown, d'où `zap-logs/` (uid du runner) pour les journaux ci-dessous.
|
||||
# Pas de `chmod 777` (règle Sonar S2612).
|
||||
sudo chown -R 1000:1000 zap-out
|
||||
|
||||
# `--network host` : ZAP atteint l'API sur le localhost du runner.
|
||||
#
|
||||
# Piège : la clé du nom d'en-tête est `matchstr`, pas `matchstring`. ZAP accepte n'importe quelle
|
||||
# clé `-config` sans erreur ; avec la mauvaise, il ajoutait à TOUTES les requêtes un en-tête au
|
||||
# nom vide (`: Bearer <jeton>`), que uvicorn refuse par un 400 (« Invalid HTTP request
|
||||
# received »), y compris sur les routes publiques.
|
||||
# Piège vécu : la clé du nom d'en-tête est `matchstr`, pas `matchstring`. ZAP accepte
|
||||
# n'importe quelle clé `-config` sans erreur ; avec la mauvaise, il ajoutait à TOUTES les
|
||||
# requêtes un en-tête au nom vide (`: Bearer <jeton>`), qu'uvicorn refuse par un 400
|
||||
# (« Invalid HTTP request received »), y compris sur les routes publiques.
|
||||
#
|
||||
# Le jeton ne passe ni par `${{ }}` dans ce script (il finirait en clair dans le fichier de
|
||||
# commande que GitHub écrit sur le disque du runner pour toute la durée de l'étape), ni par
|
||||
# l'argv de `docker run` (visible par `ps aux` et par `docker inspect zap` tant que le
|
||||
# conteneur existe) : il est écrit dans un fichier de configuration ZAP séparé, monté en
|
||||
# lecture seule hors de `/zap/wrk` pour ne jamais atterrir dans l'artefact publié.
|
||||
#
|
||||
# Les routes d'authentification qui changent l'état du compte du scan sont exclues : un
|
||||
# scan actif y déclencherait la limitation de débit du login, la réinitialisation de mots de
|
||||
# passe et la fermeture des sessions, sans rien apprendre de plus.
|
||||
#
|
||||
# Le conteneur n'est pas jetable (`--rm`) : son journal interne (`zap.log`) est copié en
|
||||
# sortie, c'est lui qui dit pourquoi un import OpenAPI a échoué.
|
||||
# `scanner.maxScanDurationInMins`/`maxRuleDurationInMins` bornent le scan actif, que `-T` ne
|
||||
# couvre pas (il ne borne que le démarrage et le scan passif) : sans ça, une règle qui
|
||||
# traîne peut dépasser le TTL du jeton (401 muets en fin de scan) ou le timeout du job (qui
|
||||
# annule sans exécuter les étapes `always()`, rapport et journaux perdus).
|
||||
- name: Scan ZAP
|
||||
id: zap
|
||||
continue-on-error: true
|
||||
env:
|
||||
JETON: ${{ steps.jeton.outputs.jeton }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
mkdir -p zap-out zap-logs
|
||||
# Le contrat est chargé depuis un fichier : `importUrl` a répondu 400 au premier passage
|
||||
# et ZAP est alors reparti explorer la racine (2 URL, que des 404).
|
||||
curl -fsS http://localhost:8000/openapi.json -o zap-out/openapi.json
|
||||
# Les noms des schémas de sécurité du contrat sont accentués (`Jeton d'accès`...) : ZAP
|
||||
# les analyse mal. Seule la copie donnée à ZAP est renommée, le contrat versionné reste
|
||||
# tel quel.
|
||||
python3 - <<'PY'
|
||||
import json, pathlib
|
||||
chemin = pathlib.Path("zap-out/openapi.json")
|
||||
contrat = chemin.read_text(encoding="utf-8")
|
||||
for ancien, nouveau in (("Jeton d'accès", "JetonAcces"), ("Cookie de rafraîchissement", "CookieRafraichissement")):
|
||||
contrat = contrat.replace(ancien, nouveau)
|
||||
json.loads(contrat)
|
||||
chemin.write_text(contrat, encoding="utf-8")
|
||||
PY
|
||||
sudo chown -R 1000:1000 zap-out
|
||||
docker run --name zap --network host -v "$PWD/zap-out:/zap/wrk:rw" \
|
||||
printf 'replacer.full_list(0).description=auth\nreplacer.full_list(0).enabled=true\nreplacer.full_list(0).matchtype=REQ_HEADER\nreplacer.full_list(0).matchstr=Authorization\nreplacer.full_list(0).regex=false\nreplacer.full_list(0).replacement=Bearer %s\n' "$JETON" > "$RUNNER_TEMP/zap-auth.conf"
|
||||
chmod 600 "$RUNNER_TEMP/zap-auth.conf"
|
||||
docker run --name zap --network host \
|
||||
-v "$PWD/zap-out:/zap/wrk:rw" \
|
||||
-v "$RUNNER_TEMP/zap-auth.conf:/zap/auth.conf:ro" \
|
||||
ghcr.io/zaproxy/zaproxy:stable zap-api-scan.py \
|
||||
-t /zap/wrk/openapi.json -f openapi -O http://localhost:8000 \
|
||||
-T 30 -d \
|
||||
-T 10 \
|
||||
-r zap-report.html -J zap-report.json -w zap-report.md \
|
||||
-z "-config replacer.full_list(0).description=auth \
|
||||
-config replacer.full_list(0).enabled=true \
|
||||
-config replacer.full_list(0).matchtype=REQ_HEADER \
|
||||
-config replacer.full_list(0).matchstr=Authorization \
|
||||
-config replacer.full_list(0).regex=false \
|
||||
-config replacer.full_list(0).replacement='Bearer ${{ steps.jeton.outputs.jeton }}' \
|
||||
-z "-configfile /zap/auth.conf \
|
||||
-config globalexcludeurl.url_list.url(0).description=auth-etat \
|
||||
-config globalexcludeurl.url_list.url(0).enabled=true \
|
||||
-config globalexcludeurl.url_list.url(0).regex='.*/api/v1/auth/(login|password|logout-all|forgot-password|reset-password).*'" \
|
||||
-config globalexcludeurl.url_list.url(0).regex='.*/api/v1/auth/(login|password|logout-all|forgot-password|reset-password).*' \
|
||||
-config scanner.maxScanDurationInMins=15 \
|
||||
-config scanner.maxRuleDurationInMins=5" \
|
||||
2>&1 | tee "$RUNNER_TEMP/zap-stdout.log"
|
||||
|
||||
- name: Récupère les journaux de ZAP
|
||||
if: always()
|
||||
run: |
|
||||
mkdir -p zap-logs
|
||||
cp "$RUNNER_TEMP/zap-stdout.log" zap-logs/zap-stdout.log || true
|
||||
docker cp zap:/home/zap/.ZAP/zap.log zap-logs/zap.log || true
|
||||
cp "$RUNNER_TEMP/api.log" zap-logs/api.log || true
|
||||
# ZAP journalise la valeur de chaque `-config`/`-configfile` chargé, y compris le jeton,
|
||||
# à un niveau visible sans `-d` : les copies publiées en artefact sont donc caviardées,
|
||||
# même si `::add-mask::` (posé à la création du jeton) protège déjà le journal du job.
|
||||
masque() { sed -E 's/(Bearer )[A-Za-z0-9._-]+/\1[MASQUE]/Ig'; }
|
||||
[ -f "$RUNNER_TEMP/zap-stdout.log" ] && masque < "$RUNNER_TEMP/zap-stdout.log" > zap-logs/zap-stdout.log
|
||||
docker cp zap:/home/zap/.ZAP/zap.log "$RUNNER_TEMP/zap-internal.log" 2>/dev/null || true
|
||||
[ -f "$RUNNER_TEMP/zap-internal.log" ] && masque < "$RUNNER_TEMP/zap-internal.log" > zap-logs/zap.log
|
||||
[ -f "$RUNNER_TEMP/api.log" ] && masque < "$RUNNER_TEMP/api.log" > zap-logs/api.log
|
||||
rm -f "$RUNNER_TEMP/zap-auth.conf"
|
||||
docker rm -f zap >/dev/null 2>&1 || true
|
||||
|
||||
# `continue-on-error` sur le scan ne doit pas faire passer pour vert un scan qui n'a rien
|
||||
# testé : ZAP « réussit » aussi quand l'import du contrat n'a chargé que quelques URL, et
|
||||
# ne teste alors que des 404 (constaté au premier passage : 2 URL importées sur 26
|
||||
# opérations). Les alertes restent non bloquantes, ce garde-fou-là bloque.
|
||||
# testé. Constaté une première fois : 2 URL importées sur 26 opérations, ZAP n'avait envoyé
|
||||
# que des requêtes vouées au 404. Le seuil est dérivé du contrat plutôt que d'un nombre fixe
|
||||
# : un contrat qui grossit ne doit pas rendre la garde plus permissive qu'elle ne l'était.
|
||||
- name: Vérifie que le contrat a bien été importé
|
||||
run: |
|
||||
attendu="$(python3 -c "
|
||||
import json
|
||||
d = json.load(open('zap-out/openapi.json'))
|
||||
methodes = ('get', 'post', 'put', 'patch', 'delete', 'head', 'options')
|
||||
print(sum(1 for chemin in d['paths'].values() for m in chemin if m in methodes))
|
||||
")"
|
||||
minimum=$((attendu * 80 / 100))
|
||||
importees="$(sed -n 's/.*Number of Imported URLs: \([0-9]*\).*/\1/p' "$RUNNER_TEMP/zap-stdout.log" | tail -1)"
|
||||
minimum=10
|
||||
echo "URL importées depuis le contrat OpenAPI : ${importees:-aucune}"
|
||||
echo "URL importées depuis le contrat OpenAPI : ${importees:-aucune} (contrat : $attendu opérations, minimum accepté : $minimum)"
|
||||
if [ "${importees:-0}" -lt "$minimum" ]; then
|
||||
echo "::error::ZAP n'a importé que ${importees:-0} URL du contrat OpenAPI (minimum attendu : $minimum). Le scan n'a pas testé l'API, voir zap-logs/zap.log dans l'artefact zap-report."
|
||||
echo "::error::ZAP n'a importé que ${importees:-0} URL sur $attendu opérations du contrat OpenAPI (minimum attendu : $minimum, soit 80%). Le scan n'a pas testé l'API, voir zap-logs/zap.log dans l'artefact zap-report."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Deuxième garde-fou : le contrat peut être importé et ZAP n'obtenir que des 4xx (constaté au
|
||||
# deuxième passage : 81 endpoints, 100 % de 400, job vert). Un scan dont toutes les réponses
|
||||
# sont des erreurs client n'a rien testé de l'API.
|
||||
- name: Vérifie que l'API a répondu autre chose que des erreurs client
|
||||
# Deuxième garde-fou : le contrat peut être importé et ZAP n'obtenir que des erreurs
|
||||
# (constaté : base sans données, toutes les routes de site répondaient 404).
|
||||
#
|
||||
# Piège de conception, trouvé en répétant ce job en local avant de l'écrire ici : borner le
|
||||
# pourcentage de 4xx ne marche pas. Un scan actif fuzze délibérément un grand nombre
|
||||
# d'entrées invalides (identifiants inventés, méthodes non supportées...), donc même un scan
|
||||
# sain, contre l'API seedée juste au-dessus, reste à 98% de 4xx avec seulement 1% de 2xx :
|
||||
# c'est la forme normale d'un scan actif, pas un signe d'échec. Le signal qui distingue
|
||||
# vraiment un scan cassé (0% de 2xx, `insight.code.2xx` absent du rapport dans le premier
|
||||
# incident) d'un scan sain (2xx non nul, aussi faible soit-il) est donc l'absence de succès,
|
||||
# pas la part d'échecs. Dérivé de `zap-report.json` (champ structuré `insights[]`) plutôt
|
||||
# que du texte libre du rapport Markdown, qui aurait le même défaut de conception en plus
|
||||
# d'être fragile au format.
|
||||
- name: Vérifie que le scan a obtenu au moins une réponse de succès
|
||||
run: |
|
||||
if [ ! -f zap-out/zap-report.md ]; then
|
||||
echo "::error::Aucun rapport ZAP produit : le scan n'a rien testé."
|
||||
exit 1
|
||||
fi
|
||||
if grep -q "status code 4xx | 100 %" zap-out/zap-report.md; then
|
||||
echo "::error::100 % des réponses sont des erreurs client (4xx) : le scan n'a rien testé de l'API. Voir zap-logs/api.log et zap-logs/zap.log dans l'artefact zap-report."
|
||||
exit 1
|
||||
fi
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
rapport = json.load(open("zap-out/zap-report.json"))
|
||||
except FileNotFoundError:
|
||||
print("::error::Aucun rapport ZAP produit : le scan n'a rien testé.")
|
||||
sys.exit(1)
|
||||
|
||||
pourcentage_2xx = 0.0
|
||||
for insight in rapport.get("insights", []):
|
||||
if insight.get("key") == "insight.code.2xx":
|
||||
pourcentage_2xx = float(insight.get("statistic", 0))
|
||||
break
|
||||
|
||||
print(f"Pourcentage de réponses 2xx : {pourcentage_2xx}%")
|
||||
if pourcentage_2xx <= 0:
|
||||
print(
|
||||
"::error::Aucune réponse 2xx (succès) reçue : le scan n'a atteint aucune route "
|
||||
"réelle de l'API. Voir zap-logs/api.log et zap-logs/zap.log dans l'artefact "
|
||||
"zap-report."
|
||||
)
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
# Uniquement la synthèse (jusqu'à « Alert Detail » exclu) : `$GITHUB_STEP_SUMMARY` est
|
||||
# limité à 1 Mio, et cette étape tourne sous `always()` - son échec ferait échouer le job
|
||||
# après le passage des deux garde-fous, pour une simple raison de mise en forme. Le rapport
|
||||
# complet reste dans l'artefact `zap-report`.
|
||||
- name: Publie le résumé
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f zap-out/zap-report.md ]; then
|
||||
cat zap-out/zap-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
awk '/^## Alert Detail/{exit} {print}' zap-out/zap-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Rapport complet (HTML/JSON/Markdown) dans l'artefact \`zap-report\`." >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "Aucun rapport ZAP produit, voir le journal du job." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Publie les rapports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: zap-report
|
||||
path: |
|
||||
@@ -229,8 +304,10 @@ jobs:
|
||||
zap-logs/
|
||||
if-no-files-found: warn
|
||||
|
||||
# Un scan sans compte authentifié ne testerait que les routes publiques : mieux vaut le
|
||||
# dire que le laisser passer pour vert.
|
||||
# Diagnostic de dernier recours : les journaux de l'API sont déjà dans l'artefact
|
||||
# (zap-logs/api.log) via l'étape « Récupère les journaux de ZAP » (always()), mais les
|
||||
# afficher directement dans le journal du job évite d'avoir à le télécharger pour un échec
|
||||
# évident (l'API n'a jamais démarré, par exemple).
|
||||
- name: Journal de l'API en cas d'échec
|
||||
if: failure() || steps.zap.outcome == 'failure'
|
||||
run: cat "$RUNNER_TEMP/api.log" || true
|
||||
|
||||
@@ -48,7 +48,9 @@ SettingsDep = Annotated[Settings, Depends(get_settings)]
|
||||
|
||||
CODE_CHANGEMENT_REQUIS = "password_change_required"
|
||||
|
||||
_porteur = HTTPBearer(auto_error=False, scheme_name="Jeton d'accès")
|
||||
# Nom ASCII : un outillage tiers (ZAP, cf. .github/workflows/dast.yml) peut mal analyser un nom
|
||||
# de schéma accentué dans le contrat OpenAPI. Piège vécu, pas anticipé.
|
||||
_porteur = HTTPBearer(auto_error=False, scheme_name="JetonAcces")
|
||||
CredentialsDep = Annotated[HTTPAuthorizationCredentials | None, Depends(_porteur)]
|
||||
|
||||
|
||||
|
||||
@@ -94,7 +94,9 @@ TAGS: Final[list[dict[str, Any]]] = [
|
||||
|
||||
cookie_de_rafraichissement = APIKeyCookie(
|
||||
name=REFRESH_COOKIE_DEFAUT,
|
||||
scheme_name="Cookie de rafraîchissement",
|
||||
# Nom ASCII : un outillage tiers (ZAP, cf. .github/workflows/dast.yml) peut mal analyser un
|
||||
# nom de schéma accentué dans le contrat OpenAPI. Piège vécu, pas anticipé.
|
||||
scheme_name="CookieRafraichissement",
|
||||
description=(
|
||||
"Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le "
|
||||
"préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers "
|
||||
|
||||
+22
-22
@@ -213,7 +213,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Cookie de rafraîchissement": []
|
||||
"CookieRafraichissement": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -252,7 +252,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Cookie de rafraîchissement": []
|
||||
"CookieRafraichissement": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -301,7 +301,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -347,7 +347,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -423,7 +423,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -673,7 +673,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -757,7 +757,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -771,7 +771,7 @@
|
||||
"operationId": "update_user_api_v1_users__user_id__patch",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -889,7 +889,7 @@
|
||||
"operationId": "reset_password_api_v1_users__user_id__password_reset_post",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -1023,7 +1023,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1037,7 +1037,7 @@
|
||||
"operationId": "get_site_api_v1_sites__site_id__get",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -1124,7 +1124,7 @@
|
||||
"operationId": "get_current_api_v1_sites__site_id__current_get",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -1211,7 +1211,7 @@
|
||||
"operationId": "list_alerts_api_v1_alerts_get",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -1361,7 +1361,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1375,7 +1375,7 @@
|
||||
"operationId": "get_recommendation_api_v1_recommendations__recommendation_id__get",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -1462,7 +1462,7 @@
|
||||
"operationId": "generate_recommendations_api_v1_recommendations_generate_post",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -1588,7 +1588,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1602,7 +1602,7 @@
|
||||
"operationId": "list_readings_api_v1_readings_get",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -1799,7 +1799,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1855,7 +1855,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3225,13 +3225,13 @@
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
"Cookie de rafraîchissement": {
|
||||
"CookieRafraichissement": {
|
||||
"type": "apiKey",
|
||||
"description": "Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers `/api/v1/auth`.",
|
||||
"in": "cookie",
|
||||
"name": "ev_refresh"
|
||||
},
|
||||
"Jeton d'accès": {
|
||||
"JetonAcces": {
|
||||
"type": "http",
|
||||
"scheme": "bearer"
|
||||
}
|
||||
|
||||
@@ -104,8 +104,8 @@ def test_the_rate_limit_documents_the_delay_header(schema: dict[str, Any]) -> No
|
||||
def test_the_refresh_cookie_appears_in_the_security_schemes(schema: dict[str, Any]) -> None:
|
||||
schemes = schema["components"]["securitySchemes"]
|
||||
|
||||
assert schemes["Cookie de rafraîchissement"]["in"] == "cookie"
|
||||
assert schemes["Cookie de rafraîchissement"]["name"] == "ev_refresh"
|
||||
assert schemes["CookieRafraichissement"]["in"] == "cookie"
|
||||
assert schemes["CookieRafraichissement"]["name"] == "ev_refresh"
|
||||
|
||||
|
||||
def test_each_tag_used_by_a_route_is_described(schema: dict[str, Any]) -> None:
|
||||
|
||||
@@ -331,8 +331,10 @@ pas prise :
|
||||
| `license_info` | Aucune licence n'est choisie |
|
||||
| `contact` | Aucun canal de support n'existe |
|
||||
|
||||
Deux schémas de sécurité sont déclarés : `Jeton d'accès` pour le porteur JWT, et
|
||||
`Cookie de rafraîchissement` pour `/auth/refresh` et `/auth/logout`. **Le second est purement
|
||||
Deux schémas de sécurité sont déclarés : `JetonAcces` pour le porteur JWT, et
|
||||
`CookieRafraichissement` pour `/auth/refresh` et `/auth/logout`, des noms ASCII délibérés (issue
|
||||
#41 : un outillage tiers comme ZAP peut mal analyser un nom de schéma accentué dans le contrat).
|
||||
**Le second est purement
|
||||
documentaire** : son `auto_error=False` garantit qu'il ne décide d'aucun refus. Le passer à vrai
|
||||
ferait répondre 403 avant d'atteindre `lit_le_cookie()`, et `/auth/refresh` cesserait de rendre le
|
||||
401 sur lequel le frontend déclenche sa déconnexion.
|
||||
|
||||
@@ -57,13 +57,21 @@ flowchart TB
|
||||
push --> fd
|
||||
push --> mv & ms
|
||||
push --> av & ab
|
||||
push --> sb1 & sb2 --> sscan
|
||||
push --> sb1 & sb2 & sb3 --> sscan
|
||||
|
||||
subgraph cd["Déploiement · deploy.yml"]
|
||||
dep["deploy<br/>runner eni-g3, environnement rec ou prod"]
|
||||
end
|
||||
|
||||
push -->|"push sur dev ou main"| dep
|
||||
|
||||
planifie["chaque lundi 3h UTC,<br/>ou à la main"]
|
||||
subgraph dastw["DAST · dast.yml"]
|
||||
zscan["zap<br/>seed + scan actif OWASP ZAP"]
|
||||
end
|
||||
|
||||
planifie --> zscan
|
||||
push -->|"PR sur dast.yml<br/>ou dast-token.sh"| zscan
|
||||
```
|
||||
|
||||
## Déclenchement
|
||||
@@ -97,7 +105,7 @@ environnement.
|
||||
|
||||
## Déploiement
|
||||
|
||||
`deploy.yml` est le sixième workflow, et le seul qui ne tourne pas chez GitHub : il s'exécute sur
|
||||
`deploy.yml` est l'un des sept workflows, et le seul qui ne tourne pas chez GitHub : il s'exécute sur
|
||||
un runner auto-hébergé installé sur la VM ENI, label `eni-g3`, parce que les runners hébergés ne
|
||||
joignent pas une adresse privée d'école. Le runner se connecte en sortie vers GitHub, aucun port
|
||||
entrant n'est ouvert.
|
||||
@@ -250,11 +258,20 @@ UTC, et sur une PR qui modifie le scan lui-même. Pas à chaque PR : un scan act
|
||||
minutes.
|
||||
|
||||
Le job démarre sur le runner la base (même image TimescaleDB que `docker-compose.yml`, base
|
||||
jetable) et le backend, puis `scripts/dast-token.sh` crée un compte **`lecteur`** et rend son
|
||||
jeton. ZAP charge le contrat `/openapi.json` (`zap-api-scan.py -f openapi`) et envoie ce jeton
|
||||
dans l'en-tête `Authorization`. Sans lui, ZAP ne verrait que les deux sondes et `/auth/login`.
|
||||
jetable), applique les migrations, y sème un site et deux relevés (`db/seeds/` est vide, pas
|
||||
encore d'outillage de jeu de données pour la CI ; sans données, `GET /sites` rend `[]`, chaque
|
||||
`/{site_id}` rend 404, et le scan actif ne frappe que des gestionnaires d'erreur), démarre le
|
||||
backend, puis `scripts/dast-token.sh` crée un compte **`lecteur`** et rend son jeton.
|
||||
|
||||
Trois décisions à savoir défendre :
|
||||
ZAP charge le contrat `/openapi.json` depuis un fichier (`zap-api-scan.py -f openapi -t
|
||||
/zap/wrk/openapi.json`) et en importe les 26 opérations **quel que soit le jeton** : c'est le
|
||||
contrat qui décide de ce qui est exploré, pas l'authentification. Le jeton ne change que les
|
||||
réponses obtenues sur les routes gardées : sans lui, elles répondraient toutes `401` plutôt que
|
||||
de dérouler leur logique. Huit routes n'exigent aucun jeton porteur (les deux sondes, `login`,
|
||||
`refresh`, `logout`, `forgot-password`, `reset-password` et `reset-password/validate`) et
|
||||
répondent donc pareil avec ou sans lui.
|
||||
|
||||
Décisions à savoir défendre :
|
||||
|
||||
- **Le compte du scan est `lecteur`, jamais `admin`.** Un scan actif avec un jeton admin frapperait
|
||||
`POST /users` et la réinitialisation de mots de passe pour de bon. Le script passe par un admin
|
||||
@@ -262,42 +279,57 @@ Trois décisions à savoir défendre :
|
||||
- **Un compte neuf est en `must_change_password`**, et toute route gardée le refuse tant que le
|
||||
mot de passe n'est pas changé. Le script fait ce changement et vérifie `GET /sites` = 200 avant
|
||||
de rendre le jeton ; sans cela, tout le scan authentifié ne testerait que des `403`.
|
||||
`POST /auth/password` rend déjà un nouveau jeton valide (l'`iat` tronqué documenté dans
|
||||
`app/api/deps.py` ne le rejette pas comme antérieur à la session) : le script s'en sert
|
||||
directement plutôt que de se reconnecter, deux hachages Argon2id (19456 Kio chacun) et deux
|
||||
allers-retours de refresh-token de moins sur le chemin critique de la CI.
|
||||
- **`APP_ACCESS_TOKEN_TTL_SECONDS=3600`** (plafond de la configuration) : le jeton par défaut
|
||||
dure 15 minutes et le scan bien plus.
|
||||
dure 15 minutes. `scanner.maxScanDurationInMins=15` (ci-dessous) borne le scan actif très en
|
||||
dessous, marge comprise pour les étapes qui l'entourent.
|
||||
- **Le jeton ne transite ni par `${{ }}` dans le script de l'étape, ni par l'argv de `docker
|
||||
run`.** Le premier finirait en clair dans le fichier de commande que GitHub écrit sur le disque
|
||||
du runner pour toute la durée de l'étape ; le second serait visible par `ps aux` et par
|
||||
`docker inspect zap` tant que le conteneur existe. Il est écrit dans un fichier de
|
||||
configuration ZAP séparé (`-configfile`), monté en lecture seule hors de `/zap/wrk` pour ne
|
||||
jamais atterrir dans l'artefact publié. ZAP journalise malgré tout la valeur de chaque
|
||||
`-config`/`-configfile` chargé à un niveau visible sans `-d` : les copies de `zap.log` et
|
||||
`zap-stdout.log` publiées en artefact sont donc caviardées avant publication.
|
||||
|
||||
Les routes d'authentification qui changent l'état du compte (`login`, `password`, `logout-all`,
|
||||
`forgot-password`, `reset-password`) sont exclues du scan actif : elles y déclencheraient la
|
||||
limitation de débit et fermeraient les sessions sans rien apprendre de plus.
|
||||
|
||||
**Un scan vert n'est pas un scan qui a testé quelque chose.** Au premier passage, le job était vert
|
||||
alors que ZAP n'avait importé que **2 URL sur 26 opérations** du contrat (`Number of Imported URLs:
|
||||
2`) : il n'avait envoyé que des requêtes vouées au 404, sans jamais atteindre une route gardée
|
||||
(rapport : 100 % de réponses 4xx, zéro alerte). ZAP « réussit » dans ce cas. Le job porte donc un
|
||||
garde-fou qui, lui, **bloque** : il échoue si moins de 10 URL sont importées. Le journal interne de
|
||||
ZAP (`zap.log`) et sa sortie complète (`zap-stdout.log`) sont publiés dans l'artefact `zap-report`
|
||||
(dossier `zap-logs/`) pour diagnostiquer un import raté.
|
||||
**Un scan vert n'est pas un scan qui a testé quelque chose.** Deux garde-fous, eux, **bloquent** :
|
||||
|
||||
Diagnostic du premier passage : `zap-api-scan.py` appelle `importUrl` sur `/openapi.json`, ZAP répond
|
||||
**400**, le contrat n'est pas chargé et ZAP se rabat sur l'exploration de la racine. Le job charge
|
||||
donc le contrat **depuis un fichier** (`-t /zap/wrk/openapi.json -O http://localhost:8000`) et
|
||||
renomme dans cette copie, sans toucher au contrat versionné, les deux schémas de sécurité aux noms
|
||||
accentués (`Jeton d'accès`, `Cookie de rafraîchissement`) que l'analyseur de ZAP peut refuser. La
|
||||
cause exacte du 400 n'est pas confirmée : si l'import échoue encore, `zap-logs/zap.log` la donne.
|
||||
Deuxième diagnostic (contrat importé, 81 endpoints) : **toutes** les requêtes de ZAP recevaient un 400
|
||||
`Invalid HTTP request received` d'uvicorn, y compris `/api/v1/health/live` sans authentification, et
|
||||
le job restait vert. Un dump des octets échangés (`socat -v`, retiré depuis) a montré la cause : ZAP
|
||||
ajoutait à chaque requête une ligne d'en-tête au **nom vide**, `: Bearer <jeton>`. La clé de
|
||||
configuration du nom d'en-tête pour la règle Replacer est **`matchstr`** ; le job écrivait
|
||||
`matchstring` (nom utilisé par le job d'automatisation ZAP, pas par `-config`). ZAP accepte
|
||||
n'importe quelle clé `-config` sans erreur, il a donc laissé le nom vide. Le job porte deux garde-fous
|
||||
qui font échouer un scan qui n'a rien testé : moins de 10 URL importées, ou 100 % de réponses 4xx.
|
||||
- **Moins de 80% des opérations du contrat importées.** Constaté une première fois : 2 URL sur 26
|
||||
opérations importées, ZAP n'avait envoyé que des requêtes vouées au 404 (l'analyseur de ZAP
|
||||
refusait alors le nom accentué d'un des deux schémas de sécurité du contrat, corrigé depuis en
|
||||
ASCII côté backend). Le seuil est dérivé du contrat (`zap-out/openapi.json`, présent à cette
|
||||
étape) plutôt que d'un nombre fixe : un contrat qui grossit ne doit pas rendre la garde plus
|
||||
permissive qu'elle ne l'était.
|
||||
- **Aucune réponse 2xx.** Constaté une deuxième fois, cause différente : la clé de configuration
|
||||
du nom d'en-tête pour la règle Replacer est `matchstr`, pas `matchstring` (celui-ci n'existe que
|
||||
pour le job d'automatisation ZAP, pas pour `-config`) ; ZAP acceptait la mauvaise clé sans
|
||||
erreur et laissait le nom d'en-tête vide, qu'uvicorn refusait par un `400` sur **toute** requête,
|
||||
y compris les routes publiques. Piège de conception rencontré en corrigeant cette garde : borner
|
||||
le *pourcentage* de 4xx ne marche pas, un scan actif fuzze délibérément un grand nombre
|
||||
d'entrées invalides, si bien qu'un scan sain contre l'API seedée reste à 98% de 4xx avec
|
||||
seulement 1% de 2xx. C'est la forme normale d'un scan actif. Le signal qui distingue vraiment un
|
||||
scan cassé (2xx nul, absent du rapport dans les deux incidents) d'un scan sain (2xx non nul,
|
||||
aussi faible soit-il) est l'absence de succès, pas la part d'échecs. Les deux gardes lisent
|
||||
`zap-out/zap-report.json` (champs structurés `insights[]`), pas le texte libre du rapport
|
||||
Markdown.
|
||||
|
||||
Piège de permissions : le dossier `zap-out` appartient à l'uid 1000 du conteneur, le runner n'y écrit
|
||||
plus après le `chown` ; les journaux vont donc dans `zap-logs/`, que le runner possède.
|
||||
Le journal interne de ZAP (`zap.log`) et sa sortie complète (`zap-stdout.log`) sont publiés dans
|
||||
l'artefact `zap-report` (dossier `zap-logs/`, propriété du runner : `zap-out/` bascule sous l'uid
|
||||
1000 du conteneur ZAP dès que le contrat y est copié, le runner n'y écrit plus ensuite) pour
|
||||
diagnostiquer un futur import raté.
|
||||
|
||||
**Non bloquant pour l'instant** (`continue-on-error`) pour ce qui est des alertes. Le volume d'alertes d'un premier passage est
|
||||
inconnu ; le rapport HTML/JSON/Markdown est publié en artefact `zap-report` et dans le résumé du
|
||||
job. Fixer un seuil viendra une fois les alertes triées.
|
||||
**Non bloquant pour l'instant** (`continue-on-error`, sur la seule étape du scan) pour ce qui est
|
||||
des alertes elles-mêmes. Le volume d'un premier passage trié est inconnu ; le rapport
|
||||
HTML/JSON/Markdown est publié en artefact `zap-report`, et sa synthèse (jusqu'aux tableaux
|
||||
d'alertes, sans le détail par alerte) dans le résumé du job. Fixer un seuil viendra une fois les
|
||||
alertes triées.
|
||||
|
||||
**Limite à ne pas oublier :** le scan tape la configuration par défaut du backend (`APP_ENV=local`,
|
||||
pas de TLS, pas de reverse proxy). Il remontera des alertes qui n'existent pas derrière le proxy
|
||||
|
||||
+17
-10
@@ -34,34 +34,41 @@ connexion() {
|
||||
| jq -r '.access_token'
|
||||
}
|
||||
|
||||
# Rend le nouveau jeton d'accès : `/auth/password` en émet un (avec l'`iat` de la session en
|
||||
# cours, cf. le piège documenté dans `app/api/deps.py`), pas seulement une confirmation. S'y fier
|
||||
# évite une reconnexion, donc un second hachage Argon2id (19456 Kio) et un aller-retour de
|
||||
# refresh-token superflus sur le chemin critique de la CI.
|
||||
changer_mot_de_passe() {
|
||||
local jeton="$1" ancien="$2" nouveau="$3"
|
||||
curl -fsS -o /dev/null -X POST "$API/auth/password" \
|
||||
curl -fsS -X POST "$API/auth/password" \
|
||||
-H "Authorization: Bearer $jeton" -H 'Content-Type: application/json' \
|
||||
-d "$(jq -n --arg a "$ancien" --arg n "$nouveau" '{current_password:$a, new_password:$n}')"
|
||||
-d "$(jq -n --arg a "$ancien" --arg n "$nouveau" '{current_password:$a, new_password:$n}')" \
|
||||
| jq -r '.access_token'
|
||||
}
|
||||
|
||||
journal "création de l'administrateur $EMAIL_ADMIN"
|
||||
SORTIE="$(uv run --frozen --no-sync --no-build python -m app.cli create-admin --email "$EMAIL_ADMIN" --generate)"
|
||||
if ! SORTIE="$(uv run --frozen --no-sync --no-build python -m app.cli create-admin --email "$EMAIL_ADMIN" --generate)"; then
|
||||
journal "la création de l'administrateur a échoué :"
|
||||
journal "$SORTIE"
|
||||
exit 1
|
||||
fi
|
||||
MDP_ADMIN="$(sed -n 's/^Mot de passe généré, il ne sera plus affiché : //p' <<<"$SORTIE")"
|
||||
[[ -n "$MDP_ADMIN" ]] || { journal "mot de passe administrateur introuvable dans la sortie"; exit 1; }
|
||||
[[ -n "$MDP_ADMIN" ]] || { journal "mot de passe administrateur introuvable dans la sortie :"; journal "$SORTIE"; exit 1; }
|
||||
|
||||
JETON="$(connexion "$EMAIL_ADMIN" "$MDP_ADMIN")"
|
||||
NOUVEAU_ADMIN="$(nouveau_mot_de_passe)"
|
||||
changer_mot_de_passe "$JETON" "$MDP_ADMIN" "$NOUVEAU_ADMIN"
|
||||
# Le changement de mot de passe ferme les sessions : le jeton précédent ne vaut plus rien.
|
||||
JETON="$(connexion "$EMAIL_ADMIN" "$NOUVEAU_ADMIN")"
|
||||
JETON="$(changer_mot_de_passe "$JETON" "$MDP_ADMIN" "$NOUVEAU_ADMIN")"
|
||||
|
||||
journal "création du lecteur $EMAIL_LECTEUR"
|
||||
REPONSE="$(curl -fsS -X POST "$API/users" -H "Authorization: Bearer $JETON" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$(jq -n --arg e "$EMAIL_LECTEUR" '{email:$e, role:"lecteur"}')")"
|
||||
MDP_TEMPORAIRE="$(jq -r '.temporary_password' <<<"$REPONSE")"
|
||||
MDP_TEMPORAIRE="$(jq -r '.temporary_password // empty' <<<"$REPONSE")"
|
||||
[[ -n "$MDP_TEMPORAIRE" ]] || { journal "mot de passe temporaire introuvable dans la réponse de POST /users :"; journal "$REPONSE"; exit 1; }
|
||||
|
||||
JETON="$(connexion "$EMAIL_LECTEUR" "$MDP_TEMPORAIRE")"
|
||||
NOUVEAU_LECTEUR="$(nouveau_mot_de_passe)"
|
||||
changer_mot_de_passe "$JETON" "$MDP_TEMPORAIRE" "$NOUVEAU_LECTEUR"
|
||||
JETON="$(connexion "$EMAIL_LECTEUR" "$NOUVEAU_LECTEUR")"
|
||||
JETON="$(changer_mot_de_passe "$JETON" "$MDP_TEMPORAIRE" "$NOUVEAU_LECTEUR")"
|
||||
|
||||
# Vérifie que le jeton ouvre bien une route gardée avant de le rendre.
|
||||
CODE="$(curl -sS -o /dev/null -w '%{http_code}' "$API/sites" -H "Authorization: Bearer $JETON")"
|
||||
|
||||
Reference in New Issue
Block a user