REST et JWT - Partie 2 : Implémentation en PHP

← Partie 1 : Concepts et Théorie | Retour aux tutoriels | Partie 3 : REST/JWT en Python →

Parcours d'apprentissage (novice vers expert)

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.

1. Structure du projet PHP

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

Prérequis

# Installer Composer (gestionnaire de dépendances PHP)
composer init

# Installer les dépendances
composer require vlucas/phpdotenv
composer require firebase/php-jwt

2. Configuration (.env et database.php)

.env

# 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

config/database.php

<?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,
    ]
];

Base de données SQL

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');

3. Utilitaires (JWT, Database, Response)

src/Utils/Database.php

<?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;
    }
}

src/Utils/JWT.php

<?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));
    }
}

src/Utils/Response.php

<?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);
    }
}

4. Modèle User

src/Models/User.php

<?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]);
    }
}

5. Middleware d'authentification

src/Middleware/AuthMiddleware.php

<?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);
        }
    }
}

6. Contrôleur d'authentification

src/Controllers/AuthController.php

<?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);
    }
}

7. Contrôleur utilisateur (exemple de ressource protégée)

src/Controllers/UserController.php

<?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);
    }
}

8. Router et point d'entrée

src/Router.php

<?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);
    }
}

public/index.php

<?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();

composer.json

{
    "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/"
        }
    }
}

Démarrer le serveur

# Installer les dépendances
composer install

# Démarrer le serveur PHP intégré
php -S localhost:8000 -t public/

9. Tester l'API

1. Inscription

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"
  }
}

2. Connexion

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"
    }
  }
}

3. Accéder à une ressource protégée

# 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"
  }
}

4. Renouveler le token

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
  }
}

5. Déconnexion

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
}

10. Sécurité et améliorations

Sécurité

Améliorations possibles

Défi progression (vers expert)

Mission: faire évoluer l'API PHP JWT vers un niveau production.

← Partie 1 : Concepts et Théorie | Retour aux tutoriels | Partie 3 : REST/JWT en Python →