From 2da6bc416bcbcf697af77f9c2eb7618f7e2ba703 Mon Sep 17 00:00:00 2001 From: julien Date: Mon, 9 Mar 2026 17:52:51 +0100 Subject: [PATCH] Added login page --- src/Bootstrap.php | 7 +- src/Container.php | 34 ++++++++ src/Controllers/AuthController.php | 118 ++++++++++++++++++++++++++ src/Database/Migrator.php | 17 +++- src/Middleware/AuthMiddleware.php | 35 ++++++++ src/Models/User.php | 125 +++++++++++++++++++++++++++ src/Repositories/UserRepository.php | 66 +++++++++++++++ src/Routes.php | 44 ++++++++-- src/Services/AuthService.php | 127 ++++++++++++++++++++++++++++ views/layout.twig | 61 +++++++++++-- views/pages/login.twig | 47 ++++++++++ views/pages/register.twig | 63 ++++++++++++++ views/partials/_header.twig | 35 ++++++-- 13 files changed, 754 insertions(+), 25 deletions(-) create mode 100644 src/Controllers/AuthController.php create mode 100644 src/Middleware/AuthMiddleware.php create mode 100644 src/Models/User.php create mode 100644 src/Repositories/UserRepository.php create mode 100644 src/Services/AuthService.php create mode 100644 views/pages/login.twig create mode 100644 views/pages/register.twig diff --git a/src/Bootstrap.php b/src/Bootstrap.php index d60515a..46aefda 100644 --- a/src/Bootstrap.php +++ b/src/Bootstrap.php @@ -117,8 +117,11 @@ final class Bootstrap */ private function registerRoutes(): void { - $controller = $this->container->get('postController'); - Routes::register($this->app, $controller); + $postController = $this->container->get('postController'); + $authController = $this->container->get('authController'); + $authMiddleware = $this->container->get('authMiddleware'); + + Routes::register($this->app, $postController, $authController, $authMiddleware); } /** diff --git a/src/Container.php b/src/Container.php index 82ddaa7..4ee1c7a 100644 --- a/src/Container.php +++ b/src/Container.php @@ -11,10 +11,14 @@ use Slim\Views\Twig; use Slim\Csrf\Guard; use Medoo\Medoo; use App\Controllers\PostController; +use App\Controllers\AuthController; use App\Repositories\PostRepository; +use App\Repositories\UserRepository; use App\Services\HtmlSanitizer; use App\Services\HtmlPurifierFactory; use App\Services\CsrfExtension; +use App\Services\AuthService; +use App\Middleware\AuthMiddleware; use App\Database\Migrator; /** @@ -136,6 +140,29 @@ final class Container implements ContainerInterface return new PostRepository($db); }; + $this->factories['userRepository'] = function (): UserRepository { + $db = $this->get('database'); + return new UserRepository($db); + }; + + // ============================================ + // Services d'authentification + // ============================================ + + $this->factories['authService'] = function (): AuthService { + $userRepository = $this->get('userRepository'); + return new AuthService($userRepository); + }; + + // ============================================ + // Middlewares + // ============================================ + + $this->factories['authMiddleware'] = function (): AuthMiddleware { + $authService = $this->get('authService'); + return new AuthMiddleware($authService); + }; + // ============================================ // Controllers // ============================================ @@ -147,6 +174,13 @@ final class Container implements ContainerInterface $this->get('htmlSanitizer') ); }; + + $this->factories['authController'] = function (): AuthController { + return new AuthController( + $this->get('twig'), + $this->get('authService') + ); + }; } /** diff --git a/src/Controllers/AuthController.php b/src/Controllers/AuthController.php new file mode 100644 index 0000000..dd1e5b8 --- /dev/null +++ b/src/Controllers/AuthController.php @@ -0,0 +1,118 @@ +authService->isLoggedIn()) { + return $res->withHeader('Location', '/admin')->withStatus(302); + } + + return $this->view->render($res, 'pages/login.twig', [ + 'error' => $_SESSION['login_error'] ?? null, + ]); + } + + /** + * Traite la soumission du formulaire de connexion. + */ + public function login(Request $req, Response $res): Response + { + $data = $req->getParsedBody(); + $username = trim((string)($data['username'] ?? '')); + $password = trim((string)($data['password'] ?? '')); + + $user = $this->authService->authenticate($username, $password); + + if (!$user) { + $_SESSION['login_error'] = 'Identifiants invalides'; + return $res->withHeader('Location', '/login')->withStatus(302); + } + + // Connecter l'utilisateur + $this->authService->login($user); + unset($_SESSION['login_error']); + + return $res->withHeader('Location', '/admin')->withStatus(302); + } + + /** + * Affiche la page d'enregistrement. + */ + public function showRegister(Request $req, Response $res): Response + { + // Si déjà connecté, rediriger vers admin + if ($this->authService->isLoggedIn()) { + return $res->withHeader('Location', '/admin')->withStatus(302); + } + + return $this->view->render($res, 'pages/register.twig', [ + 'error' => $_SESSION['register_error'] ?? null, + ]); + } + + /** + * Traite la soumission du formulaire d'enregistrement. + */ + public function register(Request $req, Response $res): Response + { + $data = $req->getParsedBody(); + $username = trim((string)($data['username'] ?? '')); + $email = trim((string)($data['email'] ?? '')); + $password = trim((string)($data['password'] ?? '')); + $passwordConfirm = trim((string)($data['password_confirm'] ?? '')); + + // Vérifier que les mots de passe correspondent + if ($password !== $passwordConfirm) { + $_SESSION['register_error'] = 'Les mots de passe ne correspondent pas'; + return $res->withHeader('Location', '/register')->withStatus(302); + } + + try { + $this->authService->register($username, $email, $password); + unset($_SESSION['register_error']); + + // Connecter automatiquement après l'enregistrement + $user = $this->authService->authenticate($username, $password); + if ($user) { + $this->authService->login($user); + } + + return $res->withHeader('Location', '/admin')->withStatus(302); + } catch (\InvalidArgumentException $e) { + $_SESSION['register_error'] = $e->getMessage(); + return $res->withHeader('Location', '/register')->withStatus(302); + } + } + + /** + * Déconnecte l'utilisateur. + */ + public function logout(Request $req, Response $res): Response + { + $this->authService->logout(); + return $res->withHeader('Location', '/')->withStatus(302); + } +} diff --git a/src/Database/Migrator.php b/src/Database/Migrator.php index 1327ba5..6ab7c68 100644 --- a/src/Database/Migrator.php +++ b/src/Database/Migrator.php @@ -11,8 +11,7 @@ final class Migrator public static function run(Medoo $db): void { self::createPostTable($db); - // self::createCommentsTable($db); - // self::createUsersTable($db); + self::createUsersTable($db); } private static function createPostTable(Medoo $db): void @@ -28,4 +27,18 @@ final class Migrator ) "); } + + private static function createUsersTable(Medoo $db): void + { + $db->pdo->exec(" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + "); + } } diff --git a/src/Middleware/AuthMiddleware.php b/src/Middleware/AuthMiddleware.php new file mode 100644 index 0000000..5e18879 --- /dev/null +++ b/src/Middleware/AuthMiddleware.php @@ -0,0 +1,35 @@ +authService->isLoggedIn()) { + // Redirection vers la page de connexion + $response = new \Slim\Psr7\Response(); + return $response + ->withHeader('Location', '/login') + ->withStatus(302); + } + + return $handler->handle($request); + } +} diff --git a/src/Models/User.php b/src/Models/User.php new file mode 100644 index 0000000..205d807 --- /dev/null +++ b/src/Models/User.php @@ -0,0 +1,125 @@ +createdAt = $createdAt ?? new DateTime(); + $this->updatedAt = $updatedAt ?? new DateTime(); + $this->validate(); + } + + /** + * Crée une instance depuis un tableau (ligne DB). + * + * @param array $data + * @return self + */ + public static function fromArray(array $data): self + { + $id = (int)($data['id'] ?? 0); + $username = (string)($data['username'] ?? ''); + $email = (string)($data['email'] ?? ''); + $passwordHash = (string)($data['password_hash'] ?? ''); + + $createdAt = isset($data['created_at']) + ? new DateTime($data['created_at']) + : new DateTime(); + + $updatedAt = isset($data['updated_at']) + ? new DateTime($data['updated_at']) + : new DateTime(); + + return new self($id, $username, $email, $passwordHash, $createdAt, $updatedAt); + } + + /** + * Valide les données de l'utilisateur. + * + * @throws \InvalidArgumentException + */ + private function validate(): void + { + if (empty($this->username) || mb_strlen($this->username) < 3) { + throw new \InvalidArgumentException('Le nom d\'utilisateur doit contenir au moins 3 caractères'); + } + + if (mb_strlen($this->username) > 50) { + throw new \InvalidArgumentException('Le nom d\'utilisateur ne peut pas dépasser 50 caractères'); + } + + if (empty($this->email) || !filter_var($this->email, FILTER_VALIDATE_EMAIL)) { + throw new \InvalidArgumentException('L\'email n\'est pas valide'); + } + + if (empty($this->passwordHash)) { + throw new \InvalidArgumentException('Le hash du mot de passe ne peut pas être vide'); + } + } + + // ============================================ + // Getters + // ============================================ + + public function getId(): int + { + return $this->id; + } + + public function getUsername(): string + { + return $this->username; + } + + public function getEmail(): string + { + return $this->email; + } + + public function getPasswordHash(): string + { + return $this->passwordHash; + } + + public function getCreatedAt(): DateTime + { + return $this->createdAt; + } + + public function getUpdatedAt(): DateTime + { + return $this->updatedAt; + } + + /** + * Retourne les données prêtes à persister en DB. + * + * @return array{username:string,email:string,password_hash:string} + */ + public function toPersistableArray(): array + { + return [ + 'username' => $this->username, + 'email' => $this->email, + 'password_hash' => $this->passwordHash, + ]; + } +} diff --git a/src/Repositories/UserRepository.php b/src/Repositories/UserRepository.php new file mode 100644 index 0000000..cdb88ef --- /dev/null +++ b/src/Repositories/UserRepository.php @@ -0,0 +1,66 @@ +db->get('users', '*', ['id' => $id]); + return $row ? User::fromArray($row) : null; + } + + /** + * Trouve un utilisateur par nom d'utilisateur. + */ + public function findByUsername(string $username): ?User + { + $row = $this->db->get('users', '*', ['username' => $username]); + return $row ? User::fromArray($row) : null; + } + + /** + * Trouve un utilisateur par email. + */ + public function findByEmail(string $email): ?User + { + $row = $this->db->get('users', '*', ['email' => $email]); + return $row ? User::fromArray($row) : null; + } + + /** + * Crée un nouvel utilisateur. + */ + public function create(User $user): int + { + $this->db->insert('users', [ + 'username' => $user->getUsername(), + 'email' => $user->getEmail(), + 'password_hash' => $user->getPasswordHash(), + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + return (int)$this->db->id(); + } + + /** + * Retourne tous les utilisateurs. + */ + public function findAll(): array + { + $rows = $this->db->select('users', '*', ['ORDER' => ['id' => 'DESC']]); + return array_map(fn ($row) => User::fromArray($row), $rows ?: []); + } +} diff --git a/src/Routes.php b/src/Routes.php index 127a37c..6803607 100644 --- a/src/Routes.php +++ b/src/Routes.php @@ -6,17 +6,45 @@ namespace App; use Slim\App; use App\Controllers\PostController; +use App\Controllers\AuthController; +use App\Middleware\AuthMiddleware; final class Routes { - public static function register(App $app, PostController $controller): void + public static function register(App $app, PostController $postController, AuthController $authController, AuthMiddleware $authMiddleware): void { - $app->get('/', [$controller, 'index']); - $app->get('/article/{slug}', [$controller, 'show']); - $app->get('/admin', [$controller, 'admin']); - $app->get('/admin/edit/{id}', [$controller, 'form']); - $app->post('/admin/create', [$controller, 'create']); - $app->post('/admin/edit/{id}', [$controller, 'update']); - $app->post('/admin/delete/{id}', [$controller, 'delete']); + // ============================================ + // Routes publiques + // ============================================ + + $app->get('/', [$postController, 'index']); + $app->get('/article/{slug}', [$postController, 'show']); + + // ============================================ + // Routes d'authentification + // ============================================ + + $app->get('/login', [$authController, 'showLogin']); + $app->post('/login', [$authController, 'login']); + + $app->get('/register', [$authController, 'showRegister']); + $app->post('/register', [$authController, 'register']); + + $app->post('/logout', [$authController, 'logout']); + + // ============================================ + // Routes admin (protégées par authentification) + // ============================================ + + $adminGroup = $app->group('', function ($group) use ($postController) { + $group->get('/admin', [$postController, 'admin']); + $group->get('/admin/edit/{id}', [$postController, 'form']); + $group->post('/admin/create', [$postController, 'create']); + $group->post('/admin/edit/{id}', [$postController, 'update']); + $group->post('/admin/delete/{id}', [$postController, 'delete']); + }); + + // Appliquer le middleware d'authentification au groupe admin + $adminGroup->add($authMiddleware); } } diff --git a/src/Services/AuthService.php b/src/Services/AuthService.php new file mode 100644 index 0000000..0d62464 --- /dev/null +++ b/src/Services/AuthService.php @@ -0,0 +1,127 @@ +userRepository->findByUsername($username)) { + throw new \InvalidArgumentException('Ce nom d\'utilisateur est déjà pris'); + } + + if ($this->userRepository->findByEmail($email)) { + throw new \InvalidArgumentException('Cet email est déjà utilisé'); + } + + // Valider le mot de passe + if (mb_strlen($plainPassword) < 8) { + throw new \InvalidArgumentException('Le mot de passe doit contenir au moins 8 caractères'); + } + + // Créer l'utilisateur avec le hash du mot de passe + $passwordHash = password_hash($plainPassword, PASSWORD_BCRYPT, ['cost' => 12]); + $user = new User(0, $username, $email, $passwordHash); + + // Persister en DB + $this->userRepository->create($user); + + return $user; + } + + /** + * Authentifie un utilisateur et retourne son instance si valide. + * + * @param string $username + * @param string $plainPassword + * @return User|null + */ + public function authenticate(string $username, string $plainPassword): ?User + { + $username = trim($username); + $plainPassword = trim($plainPassword); + + $user = $this->userRepository->findByUsername($username); + + if (!$user) { + return null; + } + + // Vérifier le mot de passe + if (!password_verify($plainPassword, $user->getPasswordHash())) { + return null; + } + + return $user; + } + + /** + * Vérifie si un utilisateur est actuellement connecté. + * + * @return bool + */ + public function isLoggedIn(): bool + { + return isset($_SESSION['user_id']) && !empty($_SESSION['user_id']); + } + + /** + * Récupère l'utilisateur actuellement connecté. + * + * @return User|null + */ + public function getCurrentUser(): ?User + { + if (!$this->isLoggedIn()) { + return null; + } + + return $this->userRepository->findById((int)$_SESSION['user_id']); + } + + /** + * Connecte un utilisateur (crée la session). + * + * @param User $user + */ + public function login(User $user): void + { + $_SESSION['user_id'] = $user->getId(); + $_SESSION['username'] = $user->getUsername(); + } + + /** + * Déconnecte l'utilisateur. + */ + public function logout(): void + { + session_destroy(); + } +} diff --git a/views/layout.twig b/views/layout.twig index 10b29fa..bef48a4 100644 --- a/views/layout.twig +++ b/views/layout.twig @@ -1,20 +1,66 @@ + {% block title %}Mon Blog{% endblock %} - {# #} + {# + #} + {# Header commun #} - {% include 'partials/_header.twig' %} + {% include 'partials/_header.twig' with {'session': _SESSION} %} {# Zone principale – chaque page injecte son contenu #}
@@ -25,8 +71,9 @@ {% include 'partials/_footer.twig' %} {# Scripts globaux #} - {# #} + {# + #} {% block scripts %}{% endblock %} - + \ No newline at end of file diff --git a/views/pages/login.twig b/views/pages/login.twig new file mode 100644 index 0000000..03eb824 --- /dev/null +++ b/views/pages/login.twig @@ -0,0 +1,47 @@ +{% extends "layout.twig" %} + +{% block title %}Connexion – Mon Blog{% endblock %} + +{% block content %} +
+

Connexion

+ + {% if error %} +
+ {{ error }} +
+ {% endif %} + +
+ {# Tokens CSRF #} + + + +

+ +

+ +

+ +

+ +

+ +

+
+ +

+ Pas encore de compte ? S'enregistrer +

+
+{% endblock %} \ No newline at end of file diff --git a/views/pages/register.twig b/views/pages/register.twig new file mode 100644 index 0000000..d26d2c1 --- /dev/null +++ b/views/pages/register.twig @@ -0,0 +1,63 @@ +{% extends "layout.twig" %} + +{% block title %}Enregistrement – Mon Blog{% endblock %} + +{% block content %} +
+

S'enregistrer

+ + {% if error %} +
+ {{ error }} +
+ {% endif %} + +
+ {# Tokens CSRF #} + + + +

+ + Minimum 3 caractères +

+ +

+ +

+ +

+ + Minimum 8 caractères +

+ +

+ +

+ +

+ +

+
+ +

+ Vous avez déjà un compte ? Se connecter +

+
+{% endblock %} \ No newline at end of file diff --git a/views/partials/_header.twig b/views/partials/_header.twig index 7ce8c98..b4c5726 100644 --- a/views/partials/_header.twig +++ b/views/partials/_header.twig @@ -1,6 +1,29 @@ -
-

- Mon Blog | - Admin -

-
+
+
+

+ Mon Blog +

+ + +
+
\ No newline at end of file