From ef711014534f0b15cc0cb8b8c74bcec83a1a4cc5 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Tue, 23 Jun 2026 15:34:27 +0200 Subject: [PATCH] feat(auth): envoi reel de l'email de reset via relais SMTP (Brevo) (#96) --- .env.example | 13 +++ .env.prod.example | 13 +++ src/app/Auth/SmtpClient.php | 98 ++++++++++++++++ src/app/Auth/SmtpMailer.php | 101 +++++++++++++++++ src/app/Auth/SmtpTransport.php | 28 +++++ src/app/Auth/StreamSmtpTransport.php | 95 ++++++++++++++++ .../Controllers/PasswordResetController.php | 32 +++++- tests/Support/FakeSmtpTransport.php | 66 +++++++++++ tests/Unit/Auth/SmtpClientTest.php | 107 ++++++++++++++++++ tests/Unit/Auth/SmtpMailerTest.php | 68 +++++++++++ 10 files changed, 620 insertions(+), 1 deletion(-) create mode 100644 src/app/Auth/SmtpClient.php create mode 100644 src/app/Auth/SmtpMailer.php create mode 100644 src/app/Auth/SmtpTransport.php create mode 100644 src/app/Auth/StreamSmtpTransport.php create mode 100644 tests/Support/FakeSmtpTransport.php create mode 100644 tests/Unit/Auth/SmtpClientTest.php create mode 100644 tests/Unit/Auth/SmtpMailerTest.php diff --git a/.env.example b/.env.example index 1375dd3..571e1d1 100644 --- a/.env.example +++ b/.env.example @@ -131,3 +131,16 @@ CRON_TIMEZONE=Europe/Paris # Nom du reseau Docker externe partage avec le Traefik de l'hote (doit exister # AVANT le up : cree par la stack Traefik, ou `docker network create `). REVERSE_PROXY_NETWORK=admin_proxy + +# =================================================================== +# Envoi d'email (reinitialisation mot de passe) - OPTIONNEL +# =================================================================== +# Absentes en local : l'app journalise le lien de reset (LogMailer), aucun envoi. +# Renseigner SMTP_HOST + SMTP_USER + SMTP_PASSWORD active l'envoi via relais SMTP. +# Mettre les vraies valeurs uniquement dans le .env de l'hote (jamais versionnees). +# SMTP_HOST=smtp-relay.brevo.com +# SMTP_PORT=587 +# SMTP_USER= +# SMTP_PASSWORD= +# MAIL_FROM_EMAIL=noreply@example.com +# MAIL_FROM_NAME=Wakdo diff --git a/.env.prod.example b/.env.prod.example index be11cac..49f03bf 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -60,3 +60,16 @@ CRON_TIMEZONE=Europe/Paris # Nom du reseau Docker externe du Traefik de l'hote (doit exister avant le up). REVERSE_PROXY_NETWORK= + +# =================================================================== +# Envoi d'email (reinitialisation mot de passe) - relais SMTP +# =================================================================== +# Si SMTP_HOST + SMTP_USER + SMTP_PASSWORD sont presents, l'app envoie via le +# relais ; sinon elle se rabat sur le journal (LogMailer). Renseigner ces 3 +# valeurs UNIQUEMENT ici (jamais dans le depot). Exemple : relais Brevo. +SMTP_HOST=smtp-relay.brevo.com +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +MAIL_FROM_EMAIL=noreply@a3n.fr +MAIL_FROM_NAME=Wakdo diff --git a/src/app/Auth/SmtpClient.php b/src/app/Auth/SmtpClient.php new file mode 100644 index 0000000..25c6b70 --- /dev/null +++ b/src/app/Auth/SmtpClient.php @@ -0,0 +1,98 @@ +assertNoInjection($from, 'expediteur'); + $this->assertNoInjection($to, 'destinataire'); + + $t = $this->transport; + + try { + $t->open($host, $port, 15); + $this->expect($t->readReply(), 220, 'greeting'); + + $this->command('EHLO ' . $this->heloName, 250, 'EHLO'); + $this->command('STARTTLS', 220, 'STARTTLS'); + $t->enableCrypto(); + // Re-EHLO obligatoire apres bascule TLS (la session repart de zero). + $this->command('EHLO ' . $this->heloName, 250, 'EHLO TLS'); + + $this->command('AUTH LOGIN', 334, 'AUTH LOGIN'); + $this->command(base64_encode($user), 334, 'AUTH user'); + $this->command(base64_encode($password), 235, 'AUTH password'); + + $this->command('MAIL FROM:<' . $from . '>', 250, 'MAIL FROM'); + $this->command('RCPT TO:<' . $to . '>', 250, 'RCPT TO'); + $this->command('DATA', 354, 'DATA'); + + // Corps + terminateur ".". + $t->write($message . "\r\n.\r\n"); + $this->expect($t->readReply(), 250, 'corps du message'); + + $t->write("QUIT\r\n"); + // La fermeture (221) n'est pas bloquante : le message est deja accepte. + $t->readReply(); + } finally { + $t->close(); + } + } + + private function command(string $line, int $expected, string $stage): void + { + $this->transport->write($line . "\r\n"); + $this->expect($this->transport->readReply(), $expected, $stage); + } + + private function assertNoInjection(string $address, string $label): void + { + if (preg_match('/[\r\n]/', $address) === 1) { + throw new RuntimeException( + sprintf('SMTP : adresse %s invalide (saut de ligne interdit)', $label), + ); + } + } + + private function expect(string $reply, int $code, string $stage): void + { + $got = (int) substr(ltrim($reply), 0, 3); + if ($got !== $code) { + // On ne journalise pas le corps : il peut contenir le lien de reset. + throw new RuntimeException( + sprintf('SMTP %s : attendu %d, recu "%s"', $stage, $code, trim($reply)), + ); + } + } +} diff --git a/src/app/Auth/SmtpMailer.php b/src/app/Auth/SmtpMailer.php new file mode 100644 index 0000000..e5eed89 --- /dev/null +++ b/src/app/Auth/SmtpMailer.php @@ -0,0 +1,101 @@ +buildMessage($email, $subject, $body); + + $this->client->send( + $this->host, + $this->port, + $this->user, + $this->password, + $this->fromEmail, + $email, + $message, + ); + } + + /** Assemble en-tetes + corps en CRLF, avec dot-stuffing pour la phase DATA. */ + private function buildMessage(string $to, string $subject, string $body): string + { + $headers = [ + 'From: ' . $this->encodeHeader($this->fromName) . ' <' . $this->fromEmail . '>', + 'To: <' . $to . '>', + 'Subject: ' . $this->encodeHeader($subject), + 'MIME-Version: 1.0', + 'Content-Type: text/plain; charset=UTF-8', + 'Content-Transfer-Encoding: 8bit', + ]; + + $raw = implode("\r\n", $headers) . "\r\n\r\n" . $this->normalizeEol($body); + + return $this->dotStuff($raw); + } + + /** RFC 2047 (encoded-word base64) si la valeur sort de l'ASCII imprimable. */ + private function encodeHeader(string $value): string + { + if (preg_match('/^[\x20-\x7E]*$/', $value) === 1) { + return $value; + } + + return '=?UTF-8?B?' . base64_encode($value) . '?='; + } + + /** Normalise toutes les fins de ligne en CRLF (LF ou CR isoles -> CRLF). */ + private function normalizeEol(string $text): string + { + return (string) preg_replace('/\r\n|\r|\n/', "\r\n", $text); + } + + /** Double un point en debut de ligne (RFC 5321 transparency). */ + private function dotStuff(string $message): string + { + $lines = explode("\r\n", $message); + foreach ($lines as $i => $line) { + if (isset($line[0]) && $line[0] === '.') { + $lines[$i] = '.' . $line; + } + } + + return implode("\r\n", $lines); + } +} diff --git a/src/app/Auth/SmtpTransport.php b/src/app/Auth/SmtpTransport.php new file mode 100644 index 0000000..acebe6c --- /dev/null +++ b/src/app/Auth/SmtpTransport.php @@ -0,0 +1,28 @@ +stream = $stream; + } + + public function write(string $raw): void + { + fwrite($this->requireStream(), $raw); + } + + public function readReply(): string + { + $stream = $this->requireStream(); + $data = ''; + $lines = 0; + + while (($line = fgets($stream, 515)) !== false) { + $data .= $line; + + // Bornes anti-boucle sur reponse malformee (ni ligne finale, ni EOF). + if (++$lines > 100 || strlen($data) > 65536) { + break; + } + + // Continuation UNIQUEMENT si '-' en 4e position ; toute autre ligne + // (y compris trop courte) termine la reponse. + if (!(strlen($line) >= 4 && $line[3] === '-')) { + break; + } + } + + if ($data === '') { + throw new RuntimeException('SMTP : aucune reponse du serveur'); + } + + return $data; + } + + public function enableCrypto(): void + { + if (!stream_socket_enable_crypto($this->requireStream(), true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { + throw new RuntimeException('SMTP : echec de la negociation TLS (STARTTLS)'); + } + } + + public function close(): void + { + if (is_resource($this->stream)) { + fclose($this->stream); + } + $this->stream = null; + } + + /** @return resource */ + private function requireStream() + { + if (!is_resource($this->stream)) { + throw new RuntimeException('SMTP : transport non ouvert'); + } + + return $this->stream; + } +} diff --git a/src/app/Controllers/PasswordResetController.php b/src/app/Controllers/PasswordResetController.php index efa0580..1f07a67 100644 --- a/src/app/Controllers/PasswordResetController.php +++ b/src/app/Controllers/PasswordResetController.php @@ -7,9 +7,13 @@ namespace App\Controllers; use Throwable; use App\Auth\Csrf; use App\Auth\LogMailer; +use App\Auth\Mailer; use App\Auth\PasswordHasher; use App\Auth\PasswordResetService; use App\Auth\SessionManager; +use App\Auth\SmtpClient; +use App\Auth\SmtpMailer; +use App\Auth\StreamSmtpTransport; use App\Core\Controller; use App\Core\Response; @@ -124,7 +128,33 @@ class PasswordResetController extends Controller $this->database, $this->config, new PasswordHasher($this->config), - new LogMailer(), + $this->mailer(), + ); + } + + /** + * SMTP reel si configure (SMTP_HOST + SMTP_USER + SMTP_PASSWORD presents), + * sinon repli sur LogMailer (le lien est journalise, pas d'envoi) : le dev + * reste sans infra mail, la prod envoie via le relais. + */ + protected function mailer(): Mailer + { + $host = $this->config->get('SMTP_HOST'); + $user = $this->config->get('SMTP_USER'); + $password = $this->config->get('SMTP_PASSWORD'); + + if ($host === null || $user === null || $password === null) { + return new LogMailer(); + } + + return new SmtpMailer( + new SmtpClient(new StreamSmtpTransport()), + $host, + (int) ($this->config->get('SMTP_PORT', '587') ?? '587'), + $user, + $password, + $this->config->get('MAIL_FROM_EMAIL', 'noreply@localhost') ?? 'noreply@localhost', + $this->config->get('MAIL_FROM_NAME', 'Wakdo') ?? 'Wakdo', ); } diff --git a/tests/Support/FakeSmtpTransport.php b/tests/Support/FakeSmtpTransport.php new file mode 100644 index 0000000..f4f3407 --- /dev/null +++ b/tests/Support/FakeSmtpTransport.php @@ -0,0 +1,66 @@ + ce que le client a ecrit, dans l'ordre */ + public array $writes = []; + + public bool $cryptoEnabled = false; + public bool $closed = false; + public bool $opened = false; + + /** @var list reponses a rendre, dans l'ordre des readReply() */ + private array $replies; + + /** @param list $replies */ + public function __construct(array $replies) + { + $this->replies = $replies; + } + + public function open(string $host, int $port, int $timeoutSeconds): void + { + $this->opened = true; + } + + public function write(string $raw): void + { + $this->writes[] = $raw; + } + + public function readReply(): string + { + if ($this->replies === []) { + throw new RuntimeException('FakeSmtpTransport : plus de reponse scriptee'); + } + + return array_shift($this->replies); + } + + public function enableCrypto(): void + { + $this->cryptoEnabled = true; + } + + public function close(): void + { + $this->closed = true; + } + + /** Concatene toutes les ecritures (pratique pour assertions sur le message). */ + public function written(): string + { + return implode('', $this->writes); + } +} diff --git a/tests/Unit/Auth/SmtpClientTest.php b/tests/Unit/Auth/SmtpClientTest.php new file mode 100644 index 0000000..59b233a --- /dev/null +++ b/tests/Unit/Auth/SmtpClientTest.php @@ -0,0 +1,107 @@ + sequence nominale de reponses serveur */ + private function happyReplies(): array + { + return [ + "220 smtp.brevo ready\r\n", // greeting + "250-smtp\r\n250 AUTH LOGIN\r\n", // EHLO (multiligne) + "220 go ahead\r\n", // STARTTLS + "250 ok\r\n", // EHLO post-TLS + "334 VXNlcm5hbWU6\r\n", // AUTH LOGIN + "334 UGFzc3dvcmQ6\r\n", // user + "235 authenticated\r\n", // password + "250 ok\r\n", // MAIL FROM + "250 ok\r\n", // RCPT TO + "354 end data with .\r\n", // DATA + "250 queued\r\n", // body + "221 bye\r\n", // QUIT + ]; + } + + public function testNominalConversationAuthenticatesAndSends(): void + { + $t = new FakeSmtpTransport($this->happyReplies()); + $client = new SmtpClient($t); + + $client->send('smtp-relay.brevo.com', 587, 'user@x', 'secret', 'from@a.fr', 'to@b.fr', "Subject: hi\r\n\r\ncorps"); + + self::assertTrue($t->opened); + self::assertTrue($t->cryptoEnabled, 'STARTTLS doit basculer le transport en TLS'); + self::assertTrue($t->closed, 'le transport doit etre ferme'); + + $sent = $t->written(); + self::assertStringContainsString("STARTTLS\r\n", $sent); + self::assertStringContainsString("AUTH LOGIN\r\n", $sent); + self::assertStringContainsString(base64_encode('user@x') . "\r\n", $sent); + self::assertStringContainsString(base64_encode('secret') . "\r\n", $sent); + self::assertStringContainsString("MAIL FROM:\r\n", $sent); + self::assertStringContainsString("RCPT TO:\r\n", $sent); + self::assertStringContainsString("DATA\r\n", $sent); + self::assertStringContainsString("\r\n.\r\n", $sent, 'le corps doit finir par le terminateur DATA'); + self::assertStringContainsString("QUIT\r\n", $sent); + } + + public function testReEhloHappensAfterStarttls(): void + { + $t = new FakeSmtpTransport($this->happyReplies()); + (new SmtpClient($t))->send('h', 587, 'u', 'p', 'f@a.fr', 't@b.fr', "x"); + + // Deux EHLO : un avant STARTTLS, un apres (session repart a zero apres TLS). + $ehloCount = substr_count($t->written(), 'EHLO '); + self::assertSame(2, $ehloCount); + } + + public function testRejectedAuthThrowsAndCloses(): void + { + $replies = $this->happyReplies(); + $replies[6] = "535 authentication failed\r\n"; // reponse au mot de passe + + $t = new FakeSmtpTransport($replies); + $client = new SmtpClient($t); + + try { + $client->send('h', 587, 'u', 'bad', 'f@a.fr', 't@b.fr', 'x'); + self::fail('une auth refusee doit lever'); + } catch (RuntimeException $e) { + self::assertStringContainsString('AUTH password', $e->getMessage()); + } + + self::assertTrue($t->closed, 'le transport doit etre ferme meme en cas d echec (finally)'); + } + + public function testUnexpectedGreetingThrows(): void + { + $t = new FakeSmtpTransport(["554 service unavailable\r\n"]); + $this->expectException(RuntimeException::class); + (new SmtpClient($t))->send('h', 587, 'u', 'p', 'f@a.fr', 't@b.fr', 'x'); + } + + public function testRejectsCrlfInRecipientBeforeConnecting(): void + { + // Tentative d'injection d'une commande RCPT via le destinataire. + $t = new FakeSmtpTransport($this->happyReplies()); + $client = new SmtpClient($t); + + try { + $client->send('h', 587, 'u', 'p', 'f@a.fr', "t@b.fr>\r\nRCPT TO:getMessage()); + } + + self::assertFalse($t->opened, 'aucune connexion ne doit s ouvrir si l adresse est invalide'); + self::assertSame([], $t->writes, 'rien ne doit etre emis'); + } +} diff --git a/tests/Unit/Auth/SmtpMailerTest.php b/tests/Unit/Auth/SmtpMailerTest.php new file mode 100644 index 0000000..63c0eae --- /dev/null +++ b/tests/Unit/Auth/SmtpMailerTest.php @@ -0,0 +1,68 @@ + sequence nominale de reponses serveur */ + private function happyReplies(): array + { + return [ + "220 ready\r\n", "250 ok\r\n", "220 go\r\n", "250 ok\r\n", + "334 u\r\n", "334 p\r\n", "235 ok\r\n", "250 ok\r\n", "250 ok\r\n", + "354 data\r\n", "250 queued\r\n", "221 bye\r\n", + ]; + } + + private function mailer(FakeSmtpTransport $t): SmtpMailer + { + return new SmtpMailer( + new SmtpClient($t), + 'smtp-relay.brevo.com', + 587, + 'login@smtp-brevo.com', + 'secret', + 'noreply@a3n.fr', + 'Wakdo', + ); + } + + public function testBuildsAndSendsResetMessage(): void + { + $t = new FakeSmtpTransport($this->happyReplies()); + $this->mailer($t)->sendPasswordReset('client@example.fr', 'https://corentin-wakdo-admin.stark.a3n.fr/reset_password?token=abc'); + + $sent = $t->written(); + self::assertStringContainsString('From: Wakdo ', $sent); + self::assertStringContainsString('To: ', $sent); + self::assertStringContainsString('Subject: Reinitialisation de votre mot de passe Wakdo', $sent); + self::assertStringContainsString('Content-Type: text/plain; charset=UTF-8', $sent); + self::assertStringContainsString('https://corentin-wakdo-admin.stark.a3n.fr/reset_password?token=abc', $sent); + // L'enveloppe SMTP doit porter l'expediteur et le destinataire reels. + self::assertStringContainsString('MAIL FROM:', $sent); + self::assertStringContainsString('RCPT TO:', $sent); + } + + public function testRejectsInvalidRecipient(): void + { + $t = new FakeSmtpTransport($this->happyReplies()); + $this->expectException(\RuntimeException::class); + $this->mailer($t)->sendPasswordReset("victim@x.fr\r\nBcc: evil@x.com", 'https://x/reset?token=t'); + } + + public function testHeaderAndBodySeparatedByBlankLine(): void + { + $t = new FakeSmtpTransport($this->happyReplies()); + $this->mailer($t)->sendPasswordReset('c@e.fr', 'https://x/reset?token=t'); + + // En-tetes et corps separes par une ligne vide (CRLF CRLF). + self::assertStringContainsString("Content-Transfer-Encoding: 8bit\r\n\r\nBonjour,", $t->written()); + } +}