Objectif: construire une API REST JWT fonctionnelle en PHP puis la rendre fiable en production.
Fonctionnement: exécute chaque endpoint, compare les réponses attendues, puis ajoute des cas d'erreur.
api-php/ ├── public/ │ └── index.php # Point d'entrée (router) ├── src/ │ ├── Controllers/ │ │ ├── AuthController.php │ │ └── UserController.php │ ├── Middleware/ │ │ └── AuthMiddleware.php │ ├── Models/ │ │ └── User.php │ ├── Utils/ │ │ ├── JWT.php │ │ ├── Database.php │ │ └── Response.php │ └── Router.php ├── config/ │ └── database.php ├── composer.json └── .env
# Installer Composer (gestionnaire de dépendances PHP) composer init # Installer les dépendances composer require vlucas/phpdotenv composer require firebase/php-jwt
# Base de données DB_HOST=localhost DB_PORT=3306 DB_NAME=api_rest DB_USER=root DB_PASS=secret # JWT JWT_SECRET=3F2504E0-4F89-11D3-9A0C-0305E82C3301-8A8F6B9C2D3E4F5A6B7C8D9E0F1A2B3C JWT_EXPIRE=900 # 15 minutes (en secondes) JWT_REFRESH_EXPIRE=604800 # 7 jours # Application APP_ENV=development APP_URL=http://localhost:8000
<?php
return [
'host' => $_ENV['DB_HOST'],
'port' => $_ENV['DB_PORT'],
'database' => $_ENV['DB_NAME'],
'username' => $_ENV['DB_USER'],
'password' => $_ENV['DB_PASS'],
'charset' => 'utf8mb4',
'options' => [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
];
CREATE DATABASE IF NOT EXISTS api_rest CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE api_rest;
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
first_name VARCHAR(50),
last_name VARCHAR(50),
role ENUM('user', 'admin') DEFAULT 'user',
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_username (username),
INDEX idx_email (email)
) ENGINE=InnoDB;
CREATE TABLE refresh_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token VARCHAR(255) NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_token (token),
INDEX idx_user_id (user_id)
) ENGINE=InnoDB;
-- Utilisateur de test (password = "password123")
INSERT INTO users (username, email, password, first_name, last_name, role) VALUES
('admin', 'admin@example.com', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'Admin', 'User', 'admin'),
('john_doe', 'john@example.com', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'John', 'Doe', 'user');
<?php
namespace App\Utils;
use PDO;
class Database {
private static ?PDO $connection = null;
public static function getConnection(): PDO {
if (self::$connection === null) {
$config = require __DIR__ . '/../../config/database.php';
$dsn = sprintf(
"mysql:host=%s;port=%s;dbname=%s;charset=%s",
$config['host'],
$config['port'],
$config['database'],
$config['charset']
);
self::$connection = new PDO(
$dsn,
$config['username'],
$config['password'],
$config['options']
);
}
return self::$connection;
}
}
<?php
namespace App\Utils;
use Firebase\JWT\JWT as FirebaseJWT;
use Firebase\JWT\Key;
use Exception;
class JWT {
private static function getSecret(): string {
return $_ENV['JWT_SECRET'];
}
/**
* Générer un access token
*/
public static function encode(array $payload): string {
$issuedAt = time();
$expire = $issuedAt + (int)$_ENV['JWT_EXPIRE'];
$data = [
'iss' => $_ENV['APP_URL'], // Issuer
'aud' => $_ENV['APP_URL'], // Audience
'iat' => $issuedAt, // Issued at
'nbf' => $issuedAt, // Not before
'exp' => $expire, // Expiration
'data' => $payload // Données utilisateur
];
return FirebaseJWT::encode($data, self::getSecret(), 'HS256');
}
/**
* Décoder et vérifier un token
*/
public static function decode(string $token): object {
try {
return FirebaseJWT::decode($token, new Key(self::getSecret(), 'HS256'));
} catch (Exception $e) {
throw new Exception('Token invalide ou expiré: ' . $e->getMessage());
}
}
/**
* Extraire le token du header Authorization
*/
public static function getBearerToken(): ?string {
$headers = getallheaders();
if (isset($headers['Authorization'])) {
$matches = [];
if (preg_match('/Bearer\s+(.*)$/i', $headers['Authorization'], $matches)) {
return $matches[1];
}
}
return null;
}
/**
* Générer un refresh token (chaîne aléatoire)
*/
public static function generateRefreshToken(): string {
return bin2hex(random_bytes(32));
}
}
<?php
namespace App\Utils;
class Response {
/**
* Envoyer une réponse JSON
*/
public static function json(mixed $data, int $statusCode = 200): void {
http_response_code($statusCode);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
/**
* Envoyer une réponse de succès
*/
public static function success(mixed $data = null, string $message = 'Success', int $statusCode = 200): void {
self::json([
'success' => true,
'message' => $message,
'data' => $data
], $statusCode);
}
/**
* Envoyer une réponse d'erreur
*/
public static function error(string $message, int $statusCode = 400, ?array $details = null): void {
$response = [
'success' => false,
'error' => [
'code' => $statusCode,
'message' => $message
]
];
if ($details !== null) {
$response['error']['details'] = $details;
}
self::json($response, $statusCode);
}
}
<?php
namespace App\Models;
use App\Utils\Database;
use PDO;
class User {
private PDO $db;
public function __construct() {
$this->db = Database::getConnection();
}
/**
* Créer un utilisateur
*/
public function create(array $data): ?int {
$sql = "INSERT INTO users (username, email, password, first_name, last_name)
VALUES (:username, :email, :password, :first_name, :last_name)";
$stmt = $this->db->prepare($sql);
$stmt->execute([
'username' => $data['username'],
'email' => $data['email'],
'password' => password_hash($data['password'], PASSWORD_BCRYPT),
'first_name' => $data['first_name'] ?? null,
'last_name' => $data['last_name'] ?? null
]);
return (int)$this->db->lastInsertId() ?: null;
}
/**
* Trouver un utilisateur par son username
*/
public function findByUsername(string $username): ?array {
$sql = "SELECT * FROM users WHERE username = :username AND is_active = 1";
$stmt = $this->db->prepare($sql);
$stmt->execute(['username' => $username]);
$user = $stmt->fetch();
return $user ?: null;
}
/**
* Trouver un utilisateur par son ID
*/
public function findById(int $id): ?array {
$sql = "SELECT id, username, email, first_name, last_name, role, created_at, updated_at
FROM users WHERE id = :id AND is_active = 1";
$stmt = $this->db->prepare($sql);
$stmt->execute(['id' => $id]);
$user = $stmt->fetch();
return $user ?: null;
}
/**
* Vérifier si un username existe
*/
public function usernameExists(string $username): bool {
$sql = "SELECT COUNT(*) FROM users WHERE username = :username";
$stmt = $this->db->prepare($sql);
$stmt->execute(['username' => $username]);
return $stmt->fetchColumn() > 0;
}
/**
* Vérifier si un email existe
*/
public function emailExists(string $email): bool {
$sql = "SELECT COUNT(*) FROM users WHERE email = :email";
$stmt = $this->db->prepare($sql);
$stmt->execute(['email' => $email]);
return $stmt->fetchColumn() > 0;
}
/**
* Sauvegarder un refresh token
*/
public function saveRefreshToken(int $userId, string $token): void {
$expiresAt = date('Y-m-d H:i:s', time() + (int)$_ENV['JWT_REFRESH_EXPIRE']);
$sql = "INSERT INTO refresh_tokens (user_id, token, expires_at)
VALUES (:user_id, :token, :expires_at)";
$stmt = $this->db->prepare($sql);
$stmt->execute([
'user_id' => $userId,
'token' => $token,
'expires_at' => $expiresAt
]);
}
/**
* Vérifier un refresh token
*/
public function verifyRefreshToken(string $token): ?int {
$sql = "SELECT user_id FROM refresh_tokens
WHERE token = :token AND expires_at > NOW()";
$stmt = $this->db->prepare($sql);
$stmt->execute(['token' => $token]);
$result = $stmt->fetch();
return $result ? (int)$result['user_id'] : null;
}
/**
* Révoquer un refresh token
*/
public function revokeRefreshToken(string $token): void {
$sql = "DELETE FROM refresh_tokens WHERE token = :token";
$stmt = $this->db->prepare($sql);
$stmt->execute(['token' => $token]);
}
/**
* Révoquer tous les refresh tokens d'un utilisateur
*/
public function revokeAllRefreshTokens(int $userId): void {
$sql = "DELETE FROM refresh_tokens WHERE user_id = :user_id";
$stmt = $this->db->prepare($sql);
$stmt->execute(['user_id' => $userId]);
}
}
<?php
namespace App\Middleware;
use App\Utils\JWT;
use App\Utils\Response;
use Exception;
class AuthMiddleware {
/**
* Vérifier l'authentification JWT
*/
public static function authenticate(): ?object {
$token = JWT::getBearerToken();
if (!$token) {
Response::error('Token non fourni', 401);
}
try {
$decoded = JWT::decode($token);
// Vérifier que le token contient les données utilisateur
if (!isset($decoded->data)) {
Response::error('Token invalide', 401);
}
return $decoded->data;
} catch (Exception $e) {
Response::error('Token invalide ou expiré', 401);
}
}
/**
* Vérifier que l'utilisateur a un rôle spécifique
*/
public static function requireRole(object $user, string $role): void {
if ($user->role !== $role) {
Response::error('Accès refusé : permissions insuffisantes', 403);
}
}
}
<?php
namespace App\Controllers;
use App\Models\User;
use App\Utils\JWT;
use App\Utils\Response;
use App\Middleware\AuthMiddleware;
class AuthController {
private User $userModel;
public function __construct() {
$this->userModel = new User();
}
/**
* Inscription (créer un compte)
*/
public function register(): void {
$data = json_decode(file_get_contents('php://input'), true);
// Validation
$errors = [];
if (empty($data['username'])) {
$errors[] = ['field' => 'username', 'message' => 'Username requis'];
} elseif ($this->userModel->usernameExists($data['username'])) {
$errors[] = ['field' => 'username', 'message' => 'Username déjà utilisé'];
}
if (empty($data['email'])) {
$errors[] = ['field' => 'email', 'message' => 'Email requis'];
} elseif (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
$errors[] = ['field' => 'email', 'message' => 'Email invalide'];
} elseif ($this->userModel->emailExists($data['email'])) {
$errors[] = ['field' => 'email', 'message' => 'Email déjà utilisé'];
}
if (empty($data['password'])) {
$errors[] = ['field' => 'password', 'message' => 'Mot de passe requis'];
} elseif (strlen($data['password']) < 8) {
$errors[] = ['field' => 'password', 'message' => 'Le mot de passe doit contenir au moins 8 caractères'];
}
if (!empty($errors)) {
Response::error('Erreur de validation', 400, $errors);
}
// Créer l'utilisateur
$userId = $this->userModel->create($data);
if (!$userId) {
Response::error('Erreur lors de la création du compte', 500);
}
Response::success(
['id' => $userId, 'username' => $data['username']],
'Compte créé avec succès',
201
);
}
/**
* Connexion (obtenir les tokens)
*/
public function login(): void {
$data = json_decode(file_get_contents('php://input'), true);
// Validation
if (empty($data['username']) || empty($data['password'])) {
Response::error('Username et password requis', 400);
}
// Trouver l'utilisateur
$user = $this->userModel->findByUsername($data['username']);
if (!$user || !password_verify($data['password'], $user['password'])) {
Response::error('Identifiants incorrects', 401);
}
// Générer les tokens
$payload = [
'id' => $user['id'],
'username' => $user['username'],
'email' => $user['email'],
'role' => $user['role']
];
$accessToken = JWT::encode($payload);
$refreshToken = JWT::generateRefreshToken();
// Sauvegarder le refresh token
$this->userModel->saveRefreshToken($user['id'], $refreshToken);
Response::success([
'accessToken' => $accessToken,
'refreshToken' => $refreshToken,
'tokenType' => 'Bearer',
'expiresIn' => (int)$_ENV['JWT_EXPIRE'],
'user' => [
'id' => $user['id'],
'username' => $user['username'],
'email' => $user['email'],
'role' => $user['role']
]
], 'Connexion réussie');
}
/**
* Renouveler l'access token
*/
public function refresh(): void {
$data = json_decode(file_get_contents('php://input'), true);
if (empty($data['refreshToken'])) {
Response::error('Refresh token requis', 400);
}
// Vérifier le refresh token
$userId = $this->userModel->verifyRefreshToken($data['refreshToken']);
if (!$userId) {
Response::error('Refresh token invalide ou expiré', 401);
}
// Récupérer l'utilisateur
$user = $this->userModel->findById($userId);
if (!$user) {
Response::error('Utilisateur non trouvé', 404);
}
// Générer un nouveau access token
$payload = [
'id' => $user['id'],
'username' => $user['username'],
'email' => $user['email'],
'role' => $user['role']
];
$accessToken = JWT::encode($payload);
// Optionnel : rotation du refresh token (meilleure sécurité)
$newRefreshToken = JWT::generateRefreshToken();
$this->userModel->revokeRefreshToken($data['refreshToken']);
$this->userModel->saveRefreshToken($userId, $newRefreshToken);
Response::success([
'accessToken' => $accessToken,
'refreshToken' => $newRefreshToken,
'tokenType' => 'Bearer',
'expiresIn' => (int)$_ENV['JWT_EXPIRE']
], 'Token renouvelé');
}
/**
* Déconnexion (révoquer le refresh token)
*/
public function logout(): void {
$data = json_decode(file_get_contents('php://input'), true);
if (!empty($data['refreshToken'])) {
$this->userModel->revokeRefreshToken($data['refreshToken']);
}
Response::success(null, 'Déconnexion réussie');
}
/**
* Informations de l'utilisateur connecté
*/
public function me(): void {
$user = AuthMiddleware::authenticate();
$userDetails = $this->userModel->findById($user->id);
if (!$userDetails) {
Response::error('Utilisateur non trouvé', 404);
}
Response::success($userDetails);
}
}
<?php
namespace App\Controllers;
use App\Models\User;
use App\Utils\Response;
use App\Middleware\AuthMiddleware;
class UserController {
private User $userModel;
public function __construct() {
$this->userModel = new User();
}
/**
* Récupérer un utilisateur par ID
*/
public function show(int $id): void {
// Authentification requise
$currentUser = AuthMiddleware::authenticate();
// Seul l'utilisateur lui-même ou un admin peut voir les détails
if ($currentUser->id !== $id && $currentUser->role !== 'admin') {
Response::error('Accès refusé', 403);
}
$user = $this->userModel->findById($id);
if (!$user) {
Response::error('Utilisateur non trouvé', 404);
}
Response::success($user);
}
}
<?php
namespace App;
use App\Utils\Response;
class Router {
private array $routes = [];
public function get(string $path, callable $callback): void {
$this->addRoute('GET', $path, $callback);
}
public function post(string $path, callable $callback): void {
$this->addRoute('POST', $path, $callback);
}
public function put(string $path, callable $callback): void {
$this->addRoute('PUT', $path, $callback);
}
public function delete(string $path, callable $callback): void {
$this->addRoute('DELETE', $path, $callback);
}
private function addRoute(string $method, string $path, callable $callback): void {
$this->routes[] = [
'method' => $method,
'path' => $path,
'callback' => $callback
];
}
public function run(): void {
$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
foreach ($this->routes as $route) {
$pattern = preg_replace('/\{[a-zA-Z]+\}/', '([0-9]+)', $route['path']);
$pattern = '#^' . $pattern . '$#';
if ($route['method'] === $method && preg_match($pattern, $path, $matches)) {
array_shift($matches); // Retirer le match complet
call_user_func_array($route['callback'], $matches);
return;
}
}
Response::error('Route non trouvée', 404);
}
}
<?php
require_once __DIR__ . '/../vendor/autoload.php';
use App\Router;
use App\Controllers\AuthController;
use App\Controllers\UserController;
// Charger les variables d'environnement
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->load();
// Headers CORS (à adapter selon vos besoins)
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
// Router
$router = new Router();
// Routes d'authentification (publiques)
$authController = new AuthController();
$router->post('/api/auth/register', [$authController, 'register']);
$router->post('/api/auth/login', [$authController, 'login']);
$router->post('/api/auth/refresh', [$authController, 'refresh']);
$router->post('/api/auth/logout', [$authController, 'logout']);
$router->get('/api/auth/me', [$authController, 'me']);
// Routes utilisateurs (protégées)
$userController = new UserController();
$router->get('/api/users/{id}', [$userController, 'show']);
// Exécuter le router
$router->run();
{
"name": "votre-nom/api-rest-jwt",
"description": "API REST avec authentification JWT",
"type": "project",
"require": {
"php": "^8.0",
"vlucas/phpdotenv": "^5.5",
"firebase/php-jwt": "^6.8"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
# Installer les dépendances composer install # Démarrer le serveur PHP intégré php -S localhost:8000 -t public/
curl -X POST http://localhost:8000/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"username": "testuser",
"email": "test@example.com",
"password": "password123",
"first_name": "Test",
"last_name": "User"
}'
# Réponse
{
"success": true,
"message": "Compte créé avec succès",
"data": {
"id": 3,
"username": "testuser"
}
}
curl -X POST http://localhost:8000/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"username": "testuser",
"password": "password123"
}'
# Réponse
{
"success": true,
"message": "Connexion réussie",
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "a1b2c3d4e5f6...",
"tokenType": "Bearer",
"expiresIn": 900,
"user": {
"id": 3,
"username": "testuser",
"email": "test@example.com",
"role": "user"
}
}
}
# Stocker le token
TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
curl -X GET http://localhost:8000/api/auth/me \
-H "Authorization: Bearer $TOKEN"
# Réponse
{
"success": true,
"message": "Success",
"data": {
"id": 3,
"username": "testuser",
"email": "test@example.com",
"first_name": "Test",
"last_name": "User",
"role": "user",
"created_at": "2025-11-30 12:00:00",
"updated_at": "2025-11-30 12:00:00"
}
}
curl -X POST http://localhost:8000/api/auth/refresh \
-H "Content-Type: application/json" \
-d '{
"refreshToken": "a1b2c3d4e5f6..."
}'
# Réponse
{
"success": true,
"message": "Token renouvelé",
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... (nouveau)",
"refreshToken": "x9y8z7w6v5u4... (nouveau)",
"tokenType": "Bearer",
"expiresIn": 900
}
}
curl -X POST http://localhost:8000/api/auth/logout \
-H "Content-Type: application/json" \
-d '{
"refreshToken": "a1b2c3d4e5f6..."
}'
# Réponse
{
"success": true,
"message": "Déconnexion réussie",
"data": null
}
password_hash() (bcrypt)Mission: faire évoluer l'API PHP JWT vers un niveau production.