feat(auth): envoi reel de l'email de reset via relais SMTP (Brevo)
All checks were successful
CI / secret-scan (pull_request) Successful in 15s
CI / php-lint (pull_request) Successful in 28s
CI / static-tests (pull_request) Successful in 1m6s
CI / js-tests (pull_request) Successful in 40s
CI / secret-scan (push) Successful in 14s
CI / php-lint (push) Successful in 33s
CI / static-tests (push) Successful in 1m11s
CI / js-tests (push) Successful in 38s
All checks were successful
CI / secret-scan (pull_request) Successful in 15s
CI / php-lint (pull_request) Successful in 28s
CI / static-tests (pull_request) Successful in 1m6s
CI / js-tests (pull_request) Successful in 40s
CI / secret-scan (push) Successful in 14s
CI / php-lint (push) Successful in 33s
CI / static-tests (push) Successful in 1m11s
CI / js-tests (push) Successful in 38s
Client SMTP maison (zero lib, contrainte from-scratch) : ESMTP + STARTTLS + AUTH LOGIN, conduit par SmtpClient contre un SmtpTransport injectable (seam de test). SmtpMailer assemble un message text/plain UTF-8 (dot-stuffing, en-tetes RFC2047) et implemente l'interface Mailer existante. PasswordResetController choisit SmtpMailer si SMTP_HOST+USER+PASSWORD presents, sinon garde LogMailer (dev sans infra mail inchange). STARTTLS exige avant AUTH (pas d'auth en clair). Garde anti-injection CRLF sur les adresses (SmtpClient) + filter_var du destinataire (SmtpMailer). readReply borne (anti-boucle sur reponse malformee). Secrets uniquement en .env (hote) : placeholders dans .env.example / .env.prod.example, rien de versionne. Revue compliance : verdict block initial (injection CRLF + readReply non borne), 2 must_fix corriges + tests de regression. 8 tests SMTP, 429 total, PHPStan L6.
This commit is contained in:
parent
80b8272291
commit
693e4a03bf
10 changed files with 620 additions and 1 deletions
13
.env.example
13
.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 <nom>`).
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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=<REMPLIR-reseau-traefik>
|
||||
|
||||
# ===================================================================
|
||||
# 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=<REMPLIR-login-smtp>
|
||||
SMTP_PASSWORD=<REMPLIR-cle-smtp-secrete>
|
||||
MAIL_FROM_EMAIL=noreply@a3n.fr
|
||||
MAIL_FROM_NAME=Wakdo
|
||||
|
|
|
|||
98
src/app/Auth/SmtpClient.php
Normal file
98
src/app/Auth/SmtpClient.php
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Client SMTP minimal (sans dependance) : ESMTP + STARTTLS + AUTH LOGIN, suffisant
|
||||
* pour un relais authentifie type Brevo. Conduit la conversation contre un
|
||||
* SmtpTransport injecte ; chaque etape verifie le code de reponse attendu et leve
|
||||
* en cas d'ecart. La construction du message est laissee a l'appelant (SmtpMailer).
|
||||
*/
|
||||
final class SmtpClient
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SmtpTransport $transport,
|
||||
private readonly string $heloName = 'wakdo',
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ouvre la session, s'authentifie, transmet un message deja assemble
|
||||
* (en-tetes + corps, lignes en CRLF, dot-stuffing applique) puis ferme.
|
||||
*/
|
||||
public function send(
|
||||
string $host,
|
||||
int $port,
|
||||
string $user,
|
||||
string $password,
|
||||
string $from,
|
||||
string $to,
|
||||
string $message,
|
||||
): void {
|
||||
// Defense en profondeur : un CRLF dans une adresse injecterait une commande
|
||||
// SMTP (RCPT supplementaire) ou un en-tete. On refuse avant toute connexion.
|
||||
$this->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 "<CRLF>.<CRLF>".
|
||||
$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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
101
src/app/Auth/SmtpMailer.php
Normal file
101
src/app/Auth/SmtpMailer.php
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Mailer SMTP reel (relais authentifie type Brevo). Implemente l'interface Mailer
|
||||
* a la place de LogMailer quand le SMTP est configure (voir PasswordResetController).
|
||||
* Assemble un message texte/plain UTF-8 conforme puis delegue l'envoi a SmtpClient.
|
||||
*/
|
||||
final class SmtpMailer implements Mailer
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SmtpClient $client,
|
||||
private readonly string $host,
|
||||
private readonly int $port,
|
||||
private readonly string $user,
|
||||
private readonly string $password,
|
||||
private readonly string $fromEmail,
|
||||
private readonly string $fromName,
|
||||
) {
|
||||
}
|
||||
|
||||
public function sendPasswordReset(string $email, string $resetUrl): void
|
||||
{
|
||||
// Garde destinataire : une adresse valide ne contient ni CRLF ni structure
|
||||
// d'injection (verrou en plus de la garde transport de SmtpClient).
|
||||
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
|
||||
throw new RuntimeException('SmtpMailer : adresse destinataire invalide');
|
||||
}
|
||||
|
||||
$subject = 'Reinitialisation de votre mot de passe Wakdo';
|
||||
$body = "Bonjour,\r\n\r\n"
|
||||
. "Une reinitialisation de mot de passe a ete demandee pour ce compte.\r\n"
|
||||
. "Pour definir un nouveau mot de passe, ouvrez ce lien :\r\n\r\n"
|
||||
. $resetUrl . "\r\n\r\n"
|
||||
. "Ce lien expire rapidement. Si vous n'etes pas a l'origine de la demande, "
|
||||
. "ignorez cet email.\r\n";
|
||||
|
||||
$message = $this->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);
|
||||
}
|
||||
}
|
||||
28
src/app/Auth/SmtpTransport.php
Normal file
28
src/app/Auth/SmtpTransport.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
/**
|
||||
* Couche transport d'une session SMTP : abstrait le socket reel pour que la
|
||||
* logique du protocole (SmtpClient) soit testable sans reseau (double en test).
|
||||
*/
|
||||
interface SmtpTransport
|
||||
{
|
||||
public function open(string $host, int $port, int $timeoutSeconds): void;
|
||||
|
||||
/** Ecrit exactement $raw sur la connexion (CRLF inclus par l'appelant). */
|
||||
public function write(string $raw): void;
|
||||
|
||||
/**
|
||||
* Lit une reponse SMTP complete. Gere le multiligne (RFC 5321 : les lignes
|
||||
* de continuation ont un '-' en 4e position, la derniere un espace).
|
||||
*/
|
||||
public function readReply(): string;
|
||||
|
||||
/** Bascule la connexion en TLS (apres STARTTLS). */
|
||||
public function enableCrypto(): void;
|
||||
|
||||
public function close(): void;
|
||||
}
|
||||
95
src/app/Auth/StreamSmtpTransport.php
Normal file
95
src/app/Auth/StreamSmtpTransport.php
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Transport SMTP reel sur socket TCP (stream_socket_client + STARTTLS). Aucune
|
||||
* dependance externe. Non teste unitairement (effet de bord reseau) : la logique
|
||||
* du protocole est couverte via SmtpClient + un transport double.
|
||||
*/
|
||||
final class StreamSmtpTransport implements SmtpTransport
|
||||
{
|
||||
/** @var resource|null */
|
||||
private $stream = null;
|
||||
|
||||
public function open(string $host, int $port, int $timeoutSeconds): void
|
||||
{
|
||||
$errno = 0;
|
||||
$errstr = '';
|
||||
$stream = @stream_socket_client(
|
||||
sprintf('tcp://%s:%d', $host, $port),
|
||||
$errno,
|
||||
$errstr,
|
||||
$timeoutSeconds,
|
||||
);
|
||||
|
||||
if ($stream === false) {
|
||||
throw new RuntimeException(sprintf('SMTP : connexion echouee (%s)', $errstr));
|
||||
}
|
||||
|
||||
stream_set_timeout($stream, $timeoutSeconds);
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
66
tests/Support/FakeSmtpTransport.php
Normal file
66
tests/Support/FakeSmtpTransport.php
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Support;
|
||||
|
||||
use App\Auth\SmtpTransport;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Transport SMTP double : rejoue des reponses serveur scriptees et enregistre les
|
||||
* ecritures du client, pour tester la logique du protocole sans reseau.
|
||||
*/
|
||||
final class FakeSmtpTransport implements SmtpTransport
|
||||
{
|
||||
/** @var list<string> 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<string> reponses a rendre, dans l'ordre des readReply() */
|
||||
private array $replies;
|
||||
|
||||
/** @param list<string> $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);
|
||||
}
|
||||
}
|
||||
107
tests/Unit/Auth/SmtpClientTest.php
Normal file
107
tests/Unit/Auth/SmtpClientTest.php
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Auth;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use App\Auth\SmtpClient;
|
||||
use App\Tests\Support\FakeSmtpTransport;
|
||||
use RuntimeException;
|
||||
|
||||
final class SmtpClientTest extends TestCase
|
||||
{
|
||||
/** @return list<string> 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 <CRLF>.<CRLF>\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:<from@a.fr>\r\n", $sent);
|
||||
self::assertStringContainsString("RCPT TO:<to@b.fr>\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:<evil@x.com", 'x');
|
||||
self::fail('un CRLF dans l adresse doit lever');
|
||||
} catch (RuntimeException $e) {
|
||||
self::assertStringContainsString('destinataire', $e->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');
|
||||
}
|
||||
}
|
||||
68
tests/Unit/Auth/SmtpMailerTest.php
Normal file
68
tests/Unit/Auth/SmtpMailerTest.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Auth;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use App\Auth\SmtpClient;
|
||||
use App\Auth\SmtpMailer;
|
||||
use App\Tests\Support\FakeSmtpTransport;
|
||||
|
||||
final class SmtpMailerTest extends TestCase
|
||||
{
|
||||
/** @return list<string> 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 <noreply@a3n.fr>', $sent);
|
||||
self::assertStringContainsString('To: <client@example.fr>', $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:<noreply@a3n.fr>', $sent);
|
||||
self::assertStringContainsString('RCPT TO:<client@example.fr>', $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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue