Votre clé de webservice est bonne. L’appel revient pourtant en 401. Et vous voilà parti pour des heures, à fabriquer des clés innocentes. La plupart du temps, votre clé n’arrive même pas jusqu’à PHP, Apache l’ayant arrachée de la requête en chemin. Autrement, elle arrive bien, seulement le droit réclamé lui fait défaut. Je vous montre comment trancher entre les deux, sur une 1.7, une 8.x comme une 9.x, puis ce que l’Admin API de la 9 change pour de bon.
Ce que le cœur vérifie avant de renvoyer 401
Tout se joue dans WebserviceRequest::authenticate(), que vous trouverez sous classes/webservice/. Cinq refus différents aboutissent à un 401, chacun portant son code interne : pas de clé, clé vide, longueur qui s’écarte de 32 caractères, compte désactivé, et pour finir aucune permission du tout. Ce dernier cas m’égare plus que les autres, vu que la clé demeure active et tout à fait valide (aucun indice ne vous oriente).
Les valeurs sur lesquelles je m’appuie dorment dans ps_webservice_account, ps_webservice_permission et ps_webservice_account_shop. Lisez-les avant de régénérer la moindre clé. J’ajoute une remarque : webservice coupé dans les paramètres avancés, et c’est un 503 qui vous revient, accompagné du message « The PrestaShop webservice is disabled », donc un vrai 401 vous apprend déjà que le webservice tourne (ça fait ça de pris).
-- Adapter le prefixe ps_ a celui de la boutique.
-- 1. Les trois controles faits par WebserviceRequest::authenticate() :
-- cle non vide, longueur exactement egale a 32, compte actif.
SELECT wa.id_webservice_account,
wa.`key`,
CHAR_LENGTH(wa.`key`) AS longueur,
wa.active,
wa.class_name,
wa.description
FROM ps_webservice_account wa
ORDER BY wa.id_webservice_account;
-- 2. Une cle active sans aucune permission renvoie quand meme 401,
-- avec le code interne 21 "No permission for this authentication key".
SELECT wa.`key`, COUNT(wp.id_webservice_permission) AS droits
FROM ps_webservice_account wa
LEFT JOIN ps_webservice_permission wp
ON wp.id_webservice_account = wa.id_webservice_account
GROUP BY wa.id_webservice_account
HAVING droits = 0;
-- 3. Droits reellement accordes, ressource par ressource.
SELECT wp.resource, wp.method
FROM ps_webservice_permission wp
JOIN ps_webservice_account wa
ON wa.id_webservice_account = wp.id_webservice_account
WHERE wa.`key` = 'VOTRE_CLE_DE_32_CARACTERES'
ORDER BY wp.resource, wp.method;
-- 4. Rattachement multiboutique : sans ligne pour la boutique appelee,
-- la ressource est refusee meme si la permission existe.
SELECT was.id_shop, s.name
FROM ps_webservice_account_shop was
JOIN ps_shop s ON s.id_shop = was.id_shop
JOIN ps_webservice_account wa
ON wa.id_webservice_account = was.id_webservice_account
WHERE wa.`key` = 'VOTRE_CLE_DE_32_CARACTERES';
-- 5. Webservice actif ou non. Desactive, le coeur repond 503 et non 401.
SELECT name, id_shop_group, id_shop, value
FROM ps_configuration
WHERE name IN ('PS_WEBSERVICE', 'PS_WEBSERVICE_CGI_HOST');
Longueur qui s’éloigne de 32, et la clé se fait recaler sans que personne soit allé la chercher en base, puisque le contrôle de taille intervient avant toute requête. Le grand classique, c’est un copier-coller qui embarque une espace au passage (comptez vos caractères).
L’en-tête Authorization filtré par Apache
Par défaut, les scripts ne voient pas l’en-tête Authorization : Apache le leur soustrait, histoire qu’ils n’aillent pas fouiller dans des identifiants déjà traités côté serveur. Sous PHP-CGI, sous PHP-FPM ou derrière n’importe quel backend qui emprunte ap_add_common_vars(), votre clé se volatilise bien avant d’atteindre $_SERVER.
PrestaShop a anticipé le coup : son dispatcher refabrique PHP_AUTH_USER en partant de HTTP_AUTHORIZATION, et bascule sur REDIRECT_HTTP_AUTHORIZATION au cas où Apache aurait rebaptisé la variable. Encore en faut-il une de disponible (la plupart du temps, je n’en déniche aucune). Côté documentation officielle, on vous oriente vers CGIPassAuth On, une directive native d’Apache arrivée avec la 2.4.13, qui s’écrit dans un .htaccess à partir du moment où votre AllowOverride embarque AuthConfig.
# A placer dans le VirtualHost, ou dans le .htaccess a la racine de la boutique
# mais EN DEHORS du bloc delimite par "# ~~start~~" et "# ~~end~~" :
# Tools::generateHtaccess() reecrit integralement ce qui est entre les deux
# a chaque enregistrement des parametres SEO et URL.
# Solution recommandee par la documentation PrestaShop.
# Directive du coeur d'Apache, disponible a partir de la 2.4.13.
# Contextes autorises : repertoire et .htaccess. Override requis : AuthConfig.
CGIPassAuth On
# Repli quand CGIPassAuth n'est pas disponible ou pas autorise :
# recopie l'en-tete dans une variable d'environnement lue par PHP.
<IfModule mod_setenvif.c>
SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1
</IfModule>
# Troisieme voie, celle que PrestaShop ecrit lui-meme dans le bloc genere
# quand l'option "Activer le mode CGI pour PHP" est cochee en back-office
# (cle de configuration PS_WEBSERVICE_CGI_HOST).
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTP:Authorization} ^(.*)
RewriteRule . - [E=HTTP_AUTHORIZATION:%1]
</IfModule>
# Le webservice a aussi besoin de mod_auth_basic : ServerRequirementsChecker
# le signale explicitement quand le module manque sous Apache.
Attention à un détail. Les majuscules comptent : la documentation note HTTP_AUTHORIZATION, et le dispatcher ne cherche rien d’autre que ce nom-là, plus sa variante préfixée REDIRECT_. Reprenez une casse mixte sur un forum, et vous récoltez une directive active qui ne réparera jamais rien. Du coup, relisez votre directive lettre par lettre.
Isoler la cause en cinq appels
Cessez de deviner, déroulez chaque fois la même séquence. Tout repose sur une bizarrerie du dispatcher, qui attaque par $_SERVER['PHP_AUTH_USER'] avant de basculer sur $_GET['ws_key']. Cette seconde porte se moque des en-têtes et se présente à l’identique en 1.7.8, en 8.2 et en 9.0 (je les ai passées toutes les trois). Dispatcher ou WebserviceRequest surchargés chez vous ? Assurez-vous avant tout qu’elle est bien prise en compte : pourquoi un override n’est pas chargé détaille les comportements bancals qu’on observe à cet endroit-là.
L’appel par ws_key passe pendant que l’appel authentifié se casse la figure ? Votre diagnostic s’arrête là. L’en-tête se perd quelque part en route, entre le client et PHP, et votre clé n’a rien à se reprocher. Réservez-le au diagnostic, rien de plus : il affiche votre clé en clair, dans les journaux d’accès comme dans l’historique de navigation.
CLE='VOTRE_CLE_DE_32_CARACTERES'
BOUTIQUE='https://boutique.tld'
# 1. Appel nominal : la cle sert d'identifiant, le mot de passe reste vide.
curl -i -u "${CLE}:" "${BOUTIQUE}/api/"
# 2. Le meme appel, en posant l'en-tete a la main.
# base64 de "cle:" avec le deux-points final, sans mot de passe.
AUTH=$(printf '%s:' "${CLE}" | base64 -w0)
curl -i -H "Authorization: Basic ${AUTH}" "${BOUTIQUE}/api/"
# 3. Test decisif. Le dispatcher accepte aussi la cle en parametre d'URL :
# webservice/dispatcher.php lit $_SERVER['PHP_AUTH_USER'], puis $_GET['ws_key'].
# Si cet appel repond 200 alors que les deux premiers renvoient 401,
# l'en-tete Authorization est perdu avant PHP, et la cle est hors de cause.
curl -i "${BOUTIQUE}/api/?ws_key=${CLE}"
# 4. Appel direct au dispatcher, sans passer par la regle de reecriture ^api.
# Une 401 ici mais un 404 en 3 designe un .htaccess sans la regle ^api.
curl -i "${BOUTIQUE}/webservice/dispatcher.php?url=&ws_key=${CLE}"
# 5. Lecture du code et du message d'erreur renvoyes par le coeur.
curl -s -u "${CLE}:" "${BOUTIQUE}/api/customers" | head -n 20
Comparez les appels trois et quatre : vous démasquerez en prime un .htaccess privé de sa règle ^api, le dispatcher répondant en direct quand l’URL publique retourne un 404. Au cinquième, je récupère le message d’erreur que produit le cœur, celui qui nomme précisément le motif du refus. Si j’ai pris customers comme ressource sur ce dernier appel, c’est qu’on la laisse presque toujours accessible en lecture, tandis que créer un client et son adresse, ça se joue bien plus souvent côté cœur que via l’API.
PrestaShop 9 : l’Admin API arrive à côté, pas à la place
Une confusion tenace circule à ce sujet. Le webservice historique, en 9.0, est resté exactement le même : webservice/dispatcher.php est là, la règle ^api du .htaccess aussi, les tables ps_webservice_account également, et les deux clés PS_WEBSERVICE et PS_WEBSERVICE_CGI_HOST avec elles. Tout ce qui précède vaut donc encore chez vous.
Ce qui débarque, c’est une deuxième API. Avec sa table, ps_api_client. Avec son point d’entrée, admin-api/index.php, qu’un noyau dédié sert et qu’accompagne un .htaccess à part. Pour l’authentification, OAuth2 en flux client_credentials, et un jeton JWT que vous décrochez sur /admin-api/access_token. En 9.0, le cœur s’en tient à deux ressources, les langues d’une part, les informations du client d’autre part, tout le reste arrivant avec le module ps_apiresources (installez-le, sinon vous allez tourner en rond).
# PrestaShop 9 uniquement. Le webservice historique en /api/ existe toujours
# a cote : les deux mecanismes sont independants.
# Creation d'un client OAuth2 en ligne de commande. La commande est declaree
# dans src/PrestaShopBundle/Command/APIClientCommand.php et n'accepte que
# les actions create et delete.
php bin/console prestashop:api-client create mon_client \
--name="Connecteur ERP" \
--description="Synchronisation catalogue" \
--scopes="api_client_read" \
--timeout=3600
# Le secret n'est affiche qu'une fois, a la creation.
# Meme operation possible en back-office : Parametres avances, Admin API.
# Demande d'un jeton. Endpoint declare dans
# src/PrestaShopBundle/Resources/config/routing/admin-api/oauth2.yml.
curl -s -X POST 'https://boutique.tld/admin-api/access_token' \
--form 'grant_type=client_credentials' \
--form 'client_id=mon_client' \
--form 'client_secret=LE_SECRET_AFFICHE_A_LA_CREATION' \
--form 'scope[]=api_client_read'
# Reponse attendue :
# {"token_type":"Bearer","expires_in":3600,"access_token":"eyJ0eXAiOiJKV1Qi..."}
# Appel d'une ressource avec le jeton.
curl -s 'https://boutique.tld/admin-api/api-client/infos' \
-H 'Authorization: Bearer LE_JETON_RECU'
Sur cette API, un jeton parfaitement valide à qui manque le bon périmètre vous rapporte un 403 assorti du détail « Invalid scope ». Chantier après chantier, je consigne ces différences, et ça atterrit sur le blog.
Sécuriser et surveiller les accès une fois rétablis
Filtrez les adresses appelantes du côté du serveur web, en amont de PHP, et resserrez vos droits ressource après ressource au lieu de distribuer GET, POST, PUT et DELETE partout. Une clé de webservice vaut un mot de passe auquel manque le second facteur, et elle circule en base64 à chaque appel. En multiboutique, ce qu’enregistre ps_webservice_account_shop rétrécit encore son champ d’action. Et si l’import s’exécute en local, sur la boutique elle-même, le problème disparaît : créer un produit directement en PHP vous dispense de la moindre ouverture en écriture à distance.
Un bémol sur ce tri par adresse. Il s’effondre le jour où votre prestataire déménage d’hébergeur ou bascule sur une adresse dynamique, et en général la nouvelle tombe le matin qui suit un import nocturne raté. Décidez à l’avance qui prévenir.
Pour ma part, j’entretiens côté surveillance un script maison qui rejoue la séquence de diagnostic entière et note chaque code obtenu (deux minutes de cron quotidiennes). Une clé qui repart en 401 après une montée de version, je remonte presque chaque fois à un .htaccess reconstruit, amputé de la directive glissée à la main, donc à un bloc placé au mauvais endroit.
<?php
/**
* test-webservice.php
* Script de surveillance a poser hors de la racine web, ou a supprimer
* apres usage. Mesure le temps de reponse et distingue les causes de 401.
*/
$cle = 'VOTRE_CLE_DE_32_CARACTERES';
$boutique = 'https://boutique.tld';
function appeler(string $url, ?string $cle = null): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
if ($cle !== null) {
// Equivalent exact de "curl -u cle:" : identifiant seul, sans mot de passe.
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, $cle . ':');
}
$corps = curl_exec($ch);
$infos = curl_getinfo($ch);
curl_close($ch);
return [
'code' => (int) $infos['http_code'],
'duree' => round($infos['total_time'] * 1000) . ' ms',
'corps' => is_string($corps) ? substr($corps, 0, 300) : '',
];
}
// Trois appels qui separent les causes possibles.
$resultats = [
'entete Authorization' => appeler($boutique . '/api/', $cle),
'parametre ws_key' => appeler($boutique . '/api/?ws_key=' . rawurlencode($cle)),
'sans identifiant' => appeler($boutique . '/api/'),
];
foreach ($resultats as $libelle => $r) {
printf('%-22s %d en %s%s', $libelle, $r['code'], $r['duree'], PHP_EOL);
}
// Lecture : 401 sur le premier et 200 sur le second, l'en-tete est filtre.
// 401 sur les deux premiers, la cle est inactive ou sans permission.
// 503 partout, le webservice est desactive (PS_WEBSERVICE a 0).
Le même canevas vous donne aussi la base d’un connecteur : appeler une API JSON depuis PHP obéit à la même mécanique une fois l’authentification réglée. Branchez ensuite cette sonde sur la supervision déjà en place chez vous, un terrain où j’interviens en développement sur mesure.
Questions fréquentes
Comment résoudre une erreur 401 du webservice sous Apache ?
Établissez d’abord si votre clé parvient jusqu’à PHP. J’attaque toujours par le même test, l’appel rejoué avec ?ws_key= glissé dans l’URL, le dispatcher allant consulter ce paramètre chaque fois que PHP_AUTH_USER lui fait défaut. Cet appel passe ? Le serveur filtre bien l’en-tête Authorization.
On recommande CGIPassAuth On, livrée avec Apache depuis la 2.4.13 et admise dans un .htaccess tant que AllowOverride englobe AuthConfig. Deuxième issue, l’option CGI du back-office, qui conduit PrestaShop à écrire lui-même une réécriture équivalente dans son bloc généré. Et si rien de tout ça ne passe, SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1 remplit la même variable.
Où placer la directive dans le .htaccess pour qu’elle survive ?
Hors du bloc que délimitent les commentaires # ~~start~~ et # ~~end~~. Tools::generateHtaccess() respecte l’amont du premier marqueur et l’aval du second, puis repart de zéro sur la zone centrale.
Posez une directive à l’intérieur, et elle s’évapore sitôt qu’on enregistre les paramètres SEO, qu’on crée une clé de webservice ou qu’on touche à l’URL de la boutique, ces trois gestes déclenchant la régénération. Voilà l’explication que je retrouve le plus souvent quand un webservice remarche un moment, puis replonge en 401 alors que personne n’a touché à rien.
Comment tester proprement l’accès à l’API avec une clé ?
Au terminal, avec la clé pour identifiant et rien du tout comme mot de passe : curl -i -u 'VOTRE_CLE:' https://boutique.tld/api/. Ce deux-points de fin ne décore rien, c’est lui qui bâtit la paire attendue par le codage base64, un identifiant suivi d’un mot de passe vide.
Un 503 annonce juste un webservice éteint du côté des paramètres avancés, sans rapport aucun avec l’authentification. Un 401 vous rapporte un en-tête WWW-Authenticate avec, dans le corps, un message assez bavard pour faire la différence entre une clé inactive et une clé dépourvue de permission. Et si c’est un XML énumérant les ressources autorisées qui revient ? Tout marche.
Qu’est-ce qui change avec l’Admin API de PrestaShop 9 ?
En 9.0, l’Admin API vient s’ajouter au webservice historique sans prendre sa place, et chez vous /api/, ps_webservice_account et vos clés de 32 caractères marchent exactement comme hier.
Les clients logent dans ps_api_client, avec chacun son identifiant, son secret, ses périmètres accordés et la durée de vie du jeton. Le point d’entrée, lui, est à part : admin-api/index.php, son noyau à lui, son .htaccess à lui. Pour l’authentification, OAuth2 en flux client_credentials, et un JWT dont la validité expire, à votre connecteur de le redemander régulièrement.
Comment obtenir un jeton OAuth2 sur PrestaShop 9 ?
Un POST sur /admin-api/access_token fera l’affaire, avec grant_type=client_credentials, l’identifiant du client, son secret et les périmètres voulus. Reviennent alors token_type, expires_in et access_token, ce dernier allant se poser dans un en-tête Authorization: Bearer.
Le client se fabrique sous Paramètres avancés puis Admin API, ou via php bin/console prestashop:api-client create, commande qui n’accepte que create et delete. Son secret ne s’affiche qu’une seule fois (notez-le immédiatement). Et réclamez tous vos périmètres sur la requête initiale, un jeton ne s’élargit pas après coup. Vous avez un connecteur métier à câbler ? On en discute volontiers depuis la page contact.