Added login page

This commit is contained in:
julien
2026-03-09 17:52:51 +01:00
parent 898b4b75c8
commit 2da6bc416b
13 changed files with 754 additions and 25 deletions

View File

@@ -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);
}
/**

View File

@@ -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')
);
};
}
/**

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Views\Twig;
use App\Services\AuthService;
/**
* Contrôleur pour l'authentification.
*/
final class AuthController
{
public function __construct(
private Twig $view,
private AuthService $authService
) {
}
/**
* Affiche la page de connexion.
*/
public function showLogin(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/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);
}
}

View File

@@ -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
)
");
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Middleware;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
use App\Services\AuthService;
/**
* Middleware pour protéger les routes admin.
* Redirige vers la page de connexion si l'utilisateur n'est pas authentifié.
*/
final class AuthMiddleware implements MiddlewareInterface
{
public function __construct(private AuthService $authService)
{
}
public function process(Request $request, RequestHandler $handler): Response
{
if (!$this->authService->isLoggedIn()) {
// Redirection vers la page de connexion
$response = new \Slim\Psr7\Response();
return $response
->withHeader('Location', '/login')
->withStatus(302);
}
return $handler->handle($request);
}
}

125
src/Models/User.php Normal file
View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Models;
use DateTime;
/**
* Modèle User pour l'authentification.
*/
final class User
{
private DateTime $createdAt;
private DateTime $updatedAt;
public function __construct(
private readonly int $id,
private readonly string $username,
private readonly string $email,
private readonly string $passwordHash,
?DateTime $createdAt = null,
?DateTime $updatedAt = null,
) {
$this->createdAt = $createdAt ?? new DateTime();
$this->updatedAt = $updatedAt ?? new DateTime();
$this->validate();
}
/**
* Crée une instance depuis un tableau (ligne DB).
*
* @param array<string,mixed> $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,
];
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use Medoo\Medoo;
use App\Models\User;
final class UserRepository
{
public function __construct(private Medoo $db)
{
}
/**
* Trouve un utilisateur par ID.
*/
public function findById(int $id): ?User
{
$row = $this->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 ?: []);
}
}

View File

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

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\User;
use App\Repositories\UserRepository;
/**
* Service d'authentification.
* Gère le hash, la vérification et la création d'utilisateurs.
*/
final class AuthService
{
public function __construct(private UserRepository $userRepository)
{
}
/**
* Enregistre un nouvel utilisateur.
*
* @param string $username
* @param string $email
* @param string $plainPassword Mot de passe en clair
* @return User
* @throws \InvalidArgumentException Si la validation échoue
*/
public function register(string $username, string $email, string $plainPassword): User
{
$username = trim($username);
$email = trim($email);
$plainPassword = trim($plainPassword);
// Vérifier que l'utilisateur n'existe pas déjà
if ($this->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();
}
}

View File

@@ -1,20 +1,66 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>{% block title %}Mon Blog{% endblock %}</title>
{# <link rel="stylesheet" href="/css/main.css"> #}
{#
<link rel="stylesheet" href="/css/main.css"> #}
<style>
body {font-family: Arial, sans-serif; margin: 2rem;}
.post {border-bottom: 1px solid #ccc; padding: 1rem 0;}
body {
font-family: Arial, sans-serif;
margin: 2rem;
}
.post {
border-bottom: 1px solid #ccc;
padding: 1rem 0;
}
.admin-actions a,
.admin-actions form {margin-right: .5rem;}
.admin-actions form {
margin-right: .5rem;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 4px;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background: #007bff;
color: white;
border: none;
cursor: pointer;
}
.btn-secondary {
background: #6c757d;
color: white;
border: none;
cursor: pointer;
}
.btn-danger {
background: #dc3545;
color: white;
border: none;
cursor: pointer;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
</style>
</head>
<body>
{# Header commun #}
{% include 'partials/_header.twig' %}
{% include 'partials/_header.twig' with {'session': _SESSION} %}
{# Zone principale chaque page injecte son contenu #}
<main>
@@ -25,7 +71,8 @@
{% include 'partials/_footer.twig' %}
{# Scripts globaux #}
{# <script src="/js/app.js"></script> #}
{#
<script src="/js/app.js"></script> #}
{% block scripts %}{% endblock %}
</body>

47
views/pages/login.twig Normal file
View File

@@ -0,0 +1,47 @@
{% extends "layout.twig" %}
{% block title %}Connexion Mon Blog{% endblock %}
{% block content %}
<div class="auth-container" style="max-width: 400px; margin: 2rem auto;">
<h2>Connexion</h2>
{% if error %}
<div class="alert alert-danger"
style="padding: 1rem; background: #f8d7da; border: 1px solid #f5c6cb; border-radius: 4px; margin-bottom: 1rem; color: #721c24;">
{{ error }}
</div>
{% endif %}
<form method="post" action="/login">
{# Tokens CSRF #}
<input type="hidden" name="{{ csrf.keys.name }}" value="{{ csrf.name }}">
<input type="hidden" name="{{ csrf.keys.value }}" value="{{ csrf.value }}">
<p>
<label for="username">Nom d'utilisateur<br>
<input type="text" id="username" name="username" required autofocus
style="width: 100%; padding: 0.5rem; margin-top: 0.5rem;">
</label>
</p>
<p>
<label for="password">Mot de passe<br>
<input type="password" id="password" name="password" required
style="width: 100%; padding: 0.5rem; margin-top: 0.5rem;">
</label>
</p>
<p>
<button type="submit" class="btn btn-primary"
style="padding: 0.75rem 1.5rem; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer;">
Se connecter
</button>
</p>
</form>
<p style="text-align: center; margin-top: 1.5rem;">
Pas encore de compte ? <a href="/register">S'enregistrer</a>
</p>
</div>
{% endblock %}

63
views/pages/register.twig Normal file
View File

@@ -0,0 +1,63 @@
{% extends "layout.twig" %}
{% block title %}Enregistrement Mon Blog{% endblock %}
{% block content %}
<div class="auth-container" style="max-width: 400px; margin: 2rem auto;">
<h2>S'enregistrer</h2>
{% if error %}
<div class="alert alert-danger"
style="padding: 1rem; background: #f8d7da; border: 1px solid #f5c6cb; border-radius: 4px; margin-bottom: 1rem; color: #721c24;">
{{ error }}
</div>
{% endif %}
<form method="post" action="/register">
{# Tokens CSRF #}
<input type="hidden" name="{{ csrf.keys.name }}" value="{{ csrf.name }}">
<input type="hidden" name="{{ csrf.keys.value }}" value="{{ csrf.value }}">
<p>
<label for="username">Nom d'utilisateur<br>
<input type="text" id="username" name="username" required autofocus minlength="3" maxlength="50"
style="width: 100%; padding: 0.5rem; margin-top: 0.5rem;">
</label>
<small>Minimum 3 caractères</small>
</p>
<p>
<label for="email">Email<br>
<input type="email" id="email" name="email" required
style="width: 100%; padding: 0.5rem; margin-top: 0.5rem;">
</label>
</p>
<p>
<label for="password">Mot de passe<br>
<input type="password" id="password" name="password" required minlength="8"
style="width: 100%; padding: 0.5rem; margin-top: 0.5rem;">
</label>
<small>Minimum 8 caractères</small>
</p>
<p>
<label for="password_confirm">Confirmer le mot de passe<br>
<input type="password" id="password_confirm" name="password_confirm" required minlength="8"
style="width: 100%; padding: 0.5rem; margin-top: 0.5rem;">
</label>
</p>
<p>
<button type="submit" class="btn btn-primary"
style="padding: 0.75rem 1.5rem; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer;">
S'enregistrer
</button>
</p>
</form>
<p style="text-align: center; margin-top: 1.5rem;">
Vous avez déjà un compte ? <a href="/login">Se connecter</a>
</p>
</div>
{% endblock %}

View File

@@ -1,6 +1,29 @@
<header>
<h1>
<a href="/">Mon Blog</a> |
<a href="/admin">Admin</a>
<header style="border-bottom: 1px solid #ccc; padding: 1rem 0; margin-bottom: 2rem;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h1 style="margin: 0;">
<a href="/" style="text-decoration: none; color: inherit;">Mon Blog</a>
</h1>
<nav>
{% if session.user_id is defined and session.user_id %}
{# Utilisateur connecté #}
<span style="margin-right: 1rem;">
Connecté en tant que : <strong>{{ session.username }}</strong>
</span>
<a href="/admin" style="margin-right: 1rem;">Admin</a>
<form method="post" action="/logout" style="display: inline;">
<input type="hidden" name="{{ csrf.keys.name }}" value="{{ csrf.name }}">
<input type="hidden" name="{{ csrf.keys.value }}" value="{{ csrf.value }}">
<button type="submit"
style="background: none; border: none; color: #007bff; cursor: pointer; text-decoration: underline;">
Déconnexion
</button>
</form>
{% else %}
{# Utilisateur non connecté #}
<a href="/login" style="margin-right: 1rem;">Connexion</a>
<a href="/register">S'enregistrer</a>
{% endif %}
</nav>
</div>
</header>