Skip to content

Commit

Permalink
Add CAPTCHA and CSRF token verification (#4)
Browse files Browse the repository at this point in the history
* Add CAPTCHA and CSRF token verification

* Split email sending into its own function

* Add type declarations and use strict types
  • Loading branch information
Spencer14420 authored Nov 18, 2024
1 parent f51a34e commit 457556e
Show file tree
Hide file tree
Showing 4 changed files with 188 additions and 29 deletions.
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"minimum-stability": "dev",
"prefer-stable": true,
"require": {
"phpmailer/phpmailer": "^6.9"
"phpmailer/phpmailer": "^6.9",
"spencer14420/sp-anti-csrf": "^1.0@alpha"
},
"autoload": {
"psr-4": {
Expand Down
46 changes: 42 additions & 4 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

56 changes: 56 additions & 0 deletions src/CaptchaVerifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

namespace spencer14420\PhpEmailHandler;

class CaptchaVerifier
{
private $secret;
private $verifyUrl;

public function __construct(string $secret, string $verifyUrl)
{
$this->secret = $secret;
$this->verifyUrl = $verifyUrl;
}

public function verify(?string $token, string $remoteIp): bool
{
if (empty($this->secret) || empty($this->verifyUrl)) {
return true; // Skip verification if CAPTCHA is not configured
}

if (empty($token)) {
throw new \Exception('CAPTCHA verification failed: $captchaToken does not exist or is not set.');
}

$data = [
"secret" => $this->secret,
"response" => $token,
"remoteip" => $remoteIp,
];

$curl = curl_init();
curl_setopt($curl, CURLOPT_URL, $this->verifyUrl);
curl_setopt($curl, CURLOPT_POST, true);
curl_setopt($curl, CURLOPT_POSTFIELDS, $data);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($curl);

if (curl_errno($curl)) {
curl_close($curl);
throw new \Exception("CAPTCHA verification failed due to a network error: " . curl_error($curl));
}

$responseData = json_decode($response, true);
curl_close($curl);

if (!empty($responseData['error-codes'])) {
throw new \Exception("CAPTCHA verification failed: " . implode(", ", $responseData['error-codes']));
}

return isset($responseData['success']) && $responseData['success'] === true;
}
}
112 changes: 88 additions & 24 deletions src/EmailHandler.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
<?php

declare(strict_types=1);

namespace spencer14420\PhpEmailHandler;

use PHPMailer\PHPMailer\PHPMailer;
use spencer14420\PhpEmailHandler\CaptchaVerifier;
use spencer14420\SpAntiCsrf\AntiCsrf;

class EmailHandler
{
Expand All @@ -10,8 +15,13 @@ class EmailHandler
private $replyToEmail;
private $siteDomain;
private $siteName;
private $captchaToken;
private $captchaSecret;
private $captchaVerifyURL;
private $checkCsrf;
private $csrfToken;

public function __construct($configFile)
public function __construct(string $configFile)
{
require_once $configFile;

Expand All @@ -28,69 +38,123 @@ public function __construct($configFile)
$this->replyToEmail = $replyToEmail;
$this->siteDomain = isset($siteDomain) && !empty($siteDomain) ? $siteDomain : $_SERVER['HTTP_HOST'];
$this->siteName = isset($siteName) && !empty($siteName) ? $siteName : ucfirst(explode('.', $this->siteDomain)[0]);
$this->captchaToken = $captchaToken;
$this->captchaSecret = isset($captchaSecret) && !empty($captchaSecret) ? $captchaSecret : "";
$this->captchaVerifyURL = isset($captchaVerifyURL) && !empty($captchaVerifyURL) && filter_var($captchaVerifyURL, FILTER_VALIDATE_URL) ? $captchaVerifyURL : "";
$this->checkCsrf = $checkCsrf ?? false;
$this->csrfToken = $csrfToken;
}

private function validateEmailVar($emailVar)
private function validateEmailVar(string $emailVar): void
{
if (!isset($emailVar) || empty($emailVar) || !filter_var($emailVar, FILTER_VALIDATE_EMAIL)) {
$this->jsonErrorResponse("Error: Server configuration error.", 500);
}
}

private function setDefaultEmailIfEmpty(&$emailVar, $defaultEmail)
private function setDefaultEmailIfEmpty(string &$emailVar, string $defaultEmail): void
{
if (!isset($emailVar) || empty($emailVar)) {
$emailVar = $defaultEmail;
}
}

private function jsonErrorResponse($message = "An error occurred. Please try again later.", $code = 500)
private function jsonErrorResponse(string $message = "An error occurred. Please try again later.", int $code = 500): void
{
http_response_code($code);
echo json_encode(['status' => 'error', 'message' => $message]);
exit;
}

public function handleRequest()
private function verifyCaptcha(): void
{
try {
$captchaVerifier = new CaptchaVerifier($this->captchaSecret, $this->captchaVerifyURL);
$captchaVerifier->verify($this->captchaToken, $_SERVER['REMOTE_ADDR']);
} catch (\Exception $e) {
$this->jsonErrorResponse($e->getMessage(), 403);
}
}

private function verifyCsrf(): void {
if (!$this->checkCsrf) {
return;
}

if (empty($this->csrfToken)) {
$this->jsonErrorResponse('Server error: $csrfToken does not exist or is not set.', 500);;
}

$csrfVerifier = new AntiCsrf();
if (!$csrfVerifier->tokenIsValid($this->csrfToken)) {
$this->jsonErrorResponse("Error: There was a issue with your session. Please refresh the page and try again.", 403);
}
}

private function sendEmail(
PHPMailer $email,
string $from,
string $to,
string $subject,
string $body,
string $replyTo = null
): void {
$email->setFrom($from, $this->siteName);
$email->addAddress($to);
$email->Subject = $subject;
$email->Body = $body;

if ($replyTo) {
$email->addReplyTo($replyTo);
}

if (!$email->send()) {
$this->jsonErrorResponse("Error: " . $email->ErrorInfo, 500);
}
}


public function handleRequest(): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->jsonErrorResponse("Error: Method not allowed", 405);
}

$this->verifyCaptcha();
$this->verifyCsrf();

// Sanitize user inputs
$email = filter_var($_POST["email"] ?? "", FILTER_SANITIZE_EMAIL);
$message = htmlspecialchars($_POST["message"] ?? "");
$name = htmlspecialchars($_POST["name"] ?? "somebody");

//Errors
if (empty($email) || empty($message)) {
$this->jsonErrorResponse("Error: Missing required fields.", 422);
}

if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->jsonErrorResponse("Error: Invalid email address.", 422);
}

// Prepare and send the main email to the mailbox
$inquryEmail = new PHPMailer();

$inquryEmail->setFrom($this->fromEmail, $this->siteName);
$inquryEmail->addReplyTo($email);
$inquryEmail->addAddress($this->mailboxEmail, $this->siteName);
$inquryEmail->Subject = "Message from $name via $this->siteDomain";
$inquryEmail->Body = "From: {$name} ({$email})\n\nMessage:\n" . wordwrap($message, 70);

if (!$inquryEmail->send()) {
$this->jsonErrorResponse("Error: ". $inquryEmail->ErrorInfo, 500);
}
$this->sendEmail(
new PHPMailer(),
$this->fromEmail,
$this->mailboxEmail,
"Message from $name via $this->siteDomain",
"From: {$name} ({$email})\n\nMessage:\n" . wordwrap($message, 70),
$email
);

// Prepare and send the confirmation email to the sender
$confirmationEmail = new PHPMailer();
$confirmationEmail->setFrom($this->fromEmail, $this->siteName);
$confirmationEmail->addReplyTo($this->replyToEmail);
$confirmationEmail->addAddress($email);
$confirmationEmail->Subject = "Your message to $this->siteName has been received";
$confirmationEmail->Body = "Dear $name ($email),\n\nYour message (shown below) has been received. We will get back to you as soon as possible.\n\nSincerely,\n$this->siteName\n\nPlease note: This message was sent to the email address provided in our contact form. If you did not enter your email, please disregard this message.\n\nYour message:\n$message";
$confirmationEmail->send();
$this->sendEmail(
new PHPMailer(),
$this->fromEmail,
$email,
"Your message to $this->siteName has been received",
"Dear $name ($email),\n\nYour message (shown below) has been received. We will get back to you as soon as possible.\n\nSincerely,\n$this->siteName\n\nPlease note: This message was sent to the email address provided in our contact form. If you did not enter your email, please disregard this message.\n\nYour message:\n$message",
$this->replyToEmail
);

echo json_encode(['status' => 'success']);
}
Expand Down

0 comments on commit 457556e

Please sign in to comment.