diff --git a/README.md b/README.md index ce119d3c..78624970 100644 --- a/README.md +++ b/README.md @@ -713,11 +713,16 @@ You can tune the middleware behavior using middleware specific configuration par - "dbAuth.returnedColumns": The columns returned on successful login, empty means 'all' ("") - "dbAuth.usernameFormField": The name of the form field that holds the username ("username") - "dbAuth.passwordFormField": The name of the form field that holds the password ("password") +- "dbAuth.emailFormField": The name of the form field that holds the email ("email") - "dbAuth.newPasswordFormField": The name of the form field that holds the new password ("newPassword") - "dbAuth.registerUser": JSON user data (or "1") in case you want the /register endpoint enabled ("") - "dbAuth.loginAfterRegistration": 1 or zero if registered users should be logged in after registration ("") - "dbAuth.passwordLength": Minimum length that the password must have ("12") - "dbAuth.sessionName": The name of the PHP session that is started ("") +- "dbAuth.confirmEmail": zero or 1 if registered users should confirm email after registration ("") +- "dbAuth.emailColumn": The users table column that holds email ("email") +- "dbAuth.tokenColumn": The users table column that holds email confirmation token ("token") +- "dbAuth.confirmedColumn": The users table column knowing if user has confirmed his email address ("confirmed") - "wpAuth.mode": Set to "optional" if you want to allow anonymous access ("required") - "wpAuth.wpDirectory": The folder/path where the Wordpress install can be found (".") - "wpAuth.usernameFormField": The name of the form field that holds the username ("username") @@ -820,6 +825,7 @@ The database authentication middleware defines five new routes: --------------------------------------------------------------------------------------------------- GET /me - - returns the user that is currently logged in POST /register - username, password - adds a user with given username and password + GET /confirm - token - enables the user if the token matches the one sent to the user's email address POST /login - username, password - logs a user in by username and password POST /password - username, password, newPassword - updates the password of the logged in user POST /logout - - logs out the currently logged in user @@ -831,6 +837,33 @@ The passwords are stored as hashes in the password column in the users table. Yo using the register endpoint, but this functionality must be turned on using the "dbAuth.registerUser" configuration parameter. +By enabling `dbAuth.confirmEmail` you can send a confirmation email to the user's address and wait +for them to follow the link in order to enable the account. The library used to send the email is +phpmailer, the protocol used is SMTP and some extra configuration is needed. +The database requires three more columns for the users table: email, confirmed, token. + + $emailSettings = [ + 'host' => '', + 'username' => '', + 'password' => '', + 'secure' => 'ssl', + 'port' => 465, + 'from' => '', + 'confirmSubject' => 'Confirmation Email', + 'confirmTemplate' => '

Dear User,
Congratulations!

You have successfully registered.
In order to validate your email address, please click on the link below:


' + ]; + + $config = new Config([ + ... + 'dbAuth.confirmEmail' => 1, + 'dbAuth.emailSettings' => $emailSettings, + 'dbAuth.confirmedColumn' => 'confirmed', + 'dbAuth.emailColumn' => 'email', + 'dbAuth.tokenColumn' => 'token', + ... + ]); + + It is IMPORTANT to restrict access to the users table using the 'authorization' middleware, otherwise all users can freely add, modify or delete any account! The minimal configuration is shown below: @@ -1435,6 +1468,7 @@ The following errors may be reported: | 1020 | 409 Conflict | User already exists | 1021 | 422 Unprocessable entity | Password too short | 1022 | 422 Unprocessable entity | Username is empty +| 1023 | 403 Forbidden | Email not confirmed | 9999 | 500 Internal server error | Unknown error The following JSON structure is used: diff --git a/api.include.php b/api.include.php index b0541006..24306997 100644 --- a/api.include.php +++ b/api.include.php @@ -197,7 +197,7 @@ interface MessageInterface * * @return string HTTP protocol version. */ - public function getProtocolVersion(); + public function getProtocolVersion(): string; /** * Return an instance with the specified HTTP protocol version. @@ -212,7 +212,7 @@ public function getProtocolVersion(); * @param string $version HTTP protocol version * @return static */ - public function withProtocolVersion(string $version); + public function withProtocolVersion(string $version): MessageInterface; /** * Retrieves all message header values. @@ -239,7 +239,7 @@ public function withProtocolVersion(string $version); * key MUST be a header name, and each value MUST be an array of strings * for that header. */ - public function getHeaders(); + public function getHeaders(): array; /** * Checks if a header exists by the given case-insensitive name. @@ -249,7 +249,7 @@ public function getHeaders(); * name using a case-insensitive string comparison. Returns false if * no matching header name is found in the message. */ - public function hasHeader(string $name); + public function hasHeader(string $name): bool; /** * Retrieves a message header value by the given case-insensitive name. @@ -265,7 +265,7 @@ public function hasHeader(string $name); * header. If the header does not appear in the message, this method MUST * return an empty array. */ - public function getHeader(string $name); + public function getHeader(string $name): array; /** * Retrieves a comma-separated string of the values for a single header. @@ -286,7 +286,7 @@ public function getHeader(string $name); * concatenated together using a comma. If the header does not appear in * the message, this method MUST return an empty string. */ - public function getHeaderLine(string $name); + public function getHeaderLine(string $name): string; /** * Return an instance with the provided value replacing the specified header. @@ -303,7 +303,7 @@ public function getHeaderLine(string $name); * @return static * @throws \InvalidArgumentException for invalid header names or values. */ - public function withHeader(string $name, $value); + public function withHeader(string $name, $value): MessageInterface; /** * Return an instance with the specified header appended with the given value. @@ -321,7 +321,7 @@ public function withHeader(string $name, $value); * @return static * @throws \InvalidArgumentException for invalid header names or values. */ - public function withAddedHeader(string $name, $value); + public function withAddedHeader(string $name, $value): MessageInterface; /** * Return an instance without the specified header. @@ -335,14 +335,14 @@ public function withAddedHeader(string $name, $value); * @param string $name Case-insensitive header field name to remove. * @return static */ - public function withoutHeader(string $name); + public function withoutHeader(string $name): MessageInterface; /** * Gets the body of the message. * * @return StreamInterface Returns the body as a stream. */ - public function getBody(); + public function getBody(): StreamInterface; /** * Return an instance with the specified message body. @@ -357,7 +357,7 @@ public function getBody(); * @return static * @throws \InvalidArgumentException When the body is not valid. */ - public function withBody(StreamInterface $body); + public function withBody(StreamInterface $body): MessageInterface; } } @@ -401,7 +401,7 @@ interface RequestInterface extends MessageInterface * * @return string */ - public function getRequestTarget(); + public function getRequestTarget(): string; /** * Return an instance with the specific request-target. @@ -420,14 +420,14 @@ public function getRequestTarget(); * @param string $requestTarget * @return static */ - public function withRequestTarget(string $requestTarget); + public function withRequestTarget(string $requestTarget): RequestInterface; /** * Retrieves the HTTP method of the request. * * @return string Returns the request method. */ - public function getMethod(); + public function getMethod(): string; /** * Return an instance with the provided HTTP method. @@ -444,7 +444,7 @@ public function getMethod(); * @return static * @throws \InvalidArgumentException for invalid HTTP methods. */ - public function withMethod(string $method); + public function withMethod(string $method): RequestInterface; /** * Retrieves the URI instance. @@ -455,7 +455,7 @@ public function withMethod(string $method); * @return UriInterface Returns a UriInterface instance * representing the URI of the request. */ - public function getUri(); + public function getUri(): UriInterface; /** * Returns an instance with the provided URI. @@ -487,7 +487,7 @@ public function getUri(); * @param bool $preserveHost Preserve the original state of the Host header. * @return static */ - public function withUri(UriInterface $uri, bool $preserveHost = false); + public function withUri(UriInterface $uri, bool $preserveHost = false): RequestInterface; } } @@ -519,7 +519,7 @@ interface ResponseInterface extends MessageInterface * * @return int Status code. */ - public function getStatusCode(); + public function getStatusCode(): int; /** * Return an instance with the specified status code and, optionally, reason phrase. @@ -541,7 +541,7 @@ public function getStatusCode(); * @return static * @throws \InvalidArgumentException For invalid status code arguments. */ - public function withStatus(int $code, string $reasonPhrase = ''); + public function withStatus(int $code, string $reasonPhrase = ''): ResponseInterface; /** * Gets the response reason phrase associated with the status code. @@ -556,7 +556,7 @@ public function withStatus(int $code, string $reasonPhrase = ''); * @link http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml * @return string Reason phrase; must return an empty string if none present. */ - public function getReasonPhrase(); + public function getReasonPhrase(): string; } } @@ -612,7 +612,7 @@ interface ServerRequestInterface extends RequestInterface * * @return array */ - public function getServerParams(); + public function getServerParams(): array; /** * Retrieve cookies. @@ -624,7 +624,7 @@ public function getServerParams(); * * @return array */ - public function getCookieParams(); + public function getCookieParams(): array; /** * Return an instance with the specified cookies. @@ -643,7 +643,7 @@ public function getCookieParams(); * @param array $cookies Array of key/value pairs representing cookies. * @return static */ - public function withCookieParams(array $cookies); + public function withCookieParams(array $cookies): ServerRequestInterface; /** * Retrieve query string arguments. @@ -657,7 +657,7 @@ public function withCookieParams(array $cookies); * * @return array */ - public function getQueryParams(); + public function getQueryParams(): array; /** * Return an instance with the specified query string arguments. @@ -681,7 +681,7 @@ public function getQueryParams(); * $_GET. * @return static */ - public function withQueryParams(array $query); + public function withQueryParams(array $query): ServerRequestInterface; /** * Retrieve normalized file upload data. @@ -695,7 +695,7 @@ public function withQueryParams(array $query); * @return array An array tree of UploadedFileInterface instances; an empty * array MUST be returned if no data is present. */ - public function getUploadedFiles(); + public function getUploadedFiles(): array; /** * Create a new instance with the specified uploaded files. @@ -708,7 +708,7 @@ public function getUploadedFiles(); * @return static * @throws \InvalidArgumentException if an invalid structure is provided. */ - public function withUploadedFiles(array $uploadedFiles); + public function withUploadedFiles(array $uploadedFiles): ServerRequestInterface; /** * Retrieve any parameters provided in the request body. @@ -755,7 +755,7 @@ public function getParsedBody(); * @throws \InvalidArgumentException if an unsupported argument type is * provided. */ - public function withParsedBody($data); + public function withParsedBody($data): ServerRequestInterface; /** * Retrieve attributes derived from the request. @@ -768,7 +768,7 @@ public function withParsedBody($data); * * @return array Attributes derived from the request. */ - public function getAttributes(); + public function getAttributes(): array; /** * Retrieve a single derived request attribute. @@ -802,7 +802,7 @@ public function getAttribute(string $name, $default = null); * @param mixed $value The value of the attribute. * @return static */ - public function withAttribute(string $name, $value); + public function withAttribute(string $name, $value): ServerRequestInterface; /** * Return an instance that removes the specified derived request attribute. @@ -818,7 +818,7 @@ public function withAttribute(string $name, $value); * @param string $name The attribute name. * @return static */ - public function withoutAttribute(string $name); + public function withoutAttribute(string $name): ServerRequestInterface; } } @@ -848,14 +848,14 @@ interface StreamInterface * @see http://php.net/manual/en/language.oop5.magic.php#object.tostring * @return string */ - public function __toString(); + public function __toString(): string; /** * Closes the stream and any underlying resources. * * @return void */ - public function close(); + public function close(): void; /** * Separates any underlying resources from the stream. @@ -871,7 +871,7 @@ public function detach(); * * @return int|null Returns the size in bytes if known, or null if unknown. */ - public function getSize(); + public function getSize(): ?int; /** * Returns the current position of the file read/write pointer @@ -879,21 +879,21 @@ public function getSize(); * @return int Position of the file pointer * @throws \RuntimeException on error. */ - public function tell(); + public function tell(): int; /** * Returns true if the stream is at the end of the stream. * * @return bool */ - public function eof(); + public function eof(): bool; /** * Returns whether or not the stream is seekable. * * @return bool */ - public function isSeekable(); + public function isSeekable(): bool; /** * Seek to a position in the stream. @@ -907,7 +907,7 @@ public function isSeekable(); * SEEK_END: Set position to end-of-stream plus offset. * @throws \RuntimeException on failure. */ - public function seek(int $offset, int $whence = SEEK_SET); + public function seek(int $offset, int $whence = SEEK_SET): void; /** * Seek to the beginning of the stream. @@ -919,14 +919,14 @@ public function seek(int $offset, int $whence = SEEK_SET); * @link http://www.php.net/manual/en/function.fseek.php * @throws \RuntimeException on failure. */ - public function rewind(); + public function rewind(): void; /** * Returns whether or not the stream is writable. * * @return bool */ - public function isWritable(); + public function isWritable(): bool; /** * Write data to the stream. @@ -935,14 +935,14 @@ public function isWritable(); * @return int Returns the number of bytes written to the stream. * @throws \RuntimeException on failure. */ - public function write(string $string); + public function write(string $string): int; /** * Returns whether or not the stream is readable. * * @return bool */ - public function isReadable(); + public function isReadable(): bool; /** * Read data from the stream. @@ -954,7 +954,7 @@ public function isReadable(); * if no bytes are available. * @throws \RuntimeException if an error occurs. */ - public function read(int $length); + public function read(int $length): string; /** * Returns the remaining contents in a string @@ -963,7 +963,7 @@ public function read(int $length); * @throws \RuntimeException if unable to read or an error occurs while * reading. */ - public function getContents(); + public function getContents(): string; /** * Get stream metadata as an associative array or retrieve a specific key. @@ -1010,7 +1010,7 @@ interface UploadedFileInterface * @throws \RuntimeException in cases when no stream is available or can be * created. */ - public function getStream(); + public function getStream(): StreamInterface; /** * Move the uploaded file to a new location. @@ -1044,7 +1044,7 @@ public function getStream(); * @throws \RuntimeException on any error during the move operation, or on * the second or subsequent call to the method. */ - public function moveTo(string $targetPath); + public function moveTo(string $targetPath): void; /** * Retrieve the file size. @@ -1055,7 +1055,7 @@ public function moveTo(string $targetPath); * * @return int|null The file size in bytes or null if unknown. */ - public function getSize(); + public function getSize(): ?int; /** * Retrieve the error associated with the uploaded file. @@ -1071,7 +1071,7 @@ public function getSize(); * @see http://php.net/manual/en/features.file-upload.errors.php * @return int One of PHP's UPLOAD_ERR_XXX constants. */ - public function getError(); + public function getError(): int; /** * Retrieve the filename sent by the client. @@ -1086,7 +1086,7 @@ public function getError(); * @return string|null The filename sent by the client or null if none * was provided. */ - public function getClientFilename(); + public function getClientFilename(): ?string; /** * Retrieve the media type sent by the client. @@ -1101,7 +1101,7 @@ public function getClientFilename(); * @return string|null The media type sent by the client or null if none * was provided. */ - public function getClientMediaType(); + public function getClientMediaType(): ?string; } } @@ -1144,7 +1144,7 @@ interface UriInterface * @see https://tools.ietf.org/html/rfc3986#section-3.1 * @return string The URI scheme. */ - public function getScheme(); + public function getScheme(): string; /** * Retrieve the authority component of the URI. @@ -1164,7 +1164,7 @@ public function getScheme(); * @see https://tools.ietf.org/html/rfc3986#section-3.2 * @return string The URI authority, in "[user-info@]host[:port]" format. */ - public function getAuthority(); + public function getAuthority(): string; /** * Retrieve the user information component of the URI. @@ -1181,7 +1181,7 @@ public function getAuthority(); * * @return string The URI user information, in "username[:password]" format. */ - public function getUserInfo(); + public function getUserInfo(): string; /** * Retrieve the host component of the URI. @@ -1194,7 +1194,7 @@ public function getUserInfo(); * @see http://tools.ietf.org/html/rfc3986#section-3.2.2 * @return string The URI host. */ - public function getHost(); + public function getHost(): string; /** * Retrieve the port component of the URI. @@ -1211,7 +1211,7 @@ public function getHost(); * * @return null|int The URI port. */ - public function getPort(); + public function getPort(): ?int; /** * Retrieve the path component of the URI. @@ -1238,7 +1238,7 @@ public function getPort(); * @see https://tools.ietf.org/html/rfc3986#section-3.3 * @return string The URI path. */ - public function getPath(); + public function getPath(): string; /** * Retrieve the query string of the URI. @@ -1260,7 +1260,7 @@ public function getPath(); * @see https://tools.ietf.org/html/rfc3986#section-3.4 * @return string The URI query string. */ - public function getQuery(); + public function getQuery(): string; /** * Retrieve the fragment component of the URI. @@ -1278,7 +1278,7 @@ public function getQuery(); * @see https://tools.ietf.org/html/rfc3986#section-3.5 * @return string The URI fragment. */ - public function getFragment(); + public function getFragment(): string; /** * Return an instance with the specified scheme. @@ -1295,7 +1295,7 @@ public function getFragment(); * @return static A new instance with the specified scheme. * @throws \InvalidArgumentException for invalid or unsupported schemes. */ - public function withScheme(string $scheme); + public function withScheme(string $scheme): UriInterface; /** * Return an instance with the specified user information. @@ -1311,7 +1311,7 @@ public function withScheme(string $scheme); * @param null|string $password The password associated with $user. * @return static A new instance with the specified user information. */ - public function withUserInfo(string $user, ?string $password = null); + public function withUserInfo(string $user, ?string $password = null): UriInterface; /** * Return an instance with the specified host. @@ -1325,7 +1325,7 @@ public function withUserInfo(string $user, ?string $password = null); * @return static A new instance with the specified host. * @throws \InvalidArgumentException for invalid hostnames. */ - public function withHost(string $host); + public function withHost(string $host): UriInterface; /** * Return an instance with the specified port. @@ -1344,7 +1344,7 @@ public function withHost(string $host); * @return static A new instance with the specified port. * @throws \InvalidArgumentException for invalid ports. */ - public function withPort(?int $port); + public function withPort(?int $port): UriInterface; /** * Return an instance with the specified path. @@ -1368,7 +1368,7 @@ public function withPort(?int $port); * @return static A new instance with the specified path. * @throws \InvalidArgumentException for invalid paths. */ - public function withPath(string $path); + public function withPath(string $path): UriInterface; /** * Return an instance with the specified query string. @@ -1385,7 +1385,7 @@ public function withPath(string $path); * @return static A new instance with the specified query string. * @throws \InvalidArgumentException for invalid query strings. */ - public function withQuery(string $query); + public function withQuery(string $query): UriInterface; /** * Return an instance with the specified URI fragment. @@ -1401,7 +1401,7 @@ public function withQuery(string $query); * @param string $fragment The fragment to use with the new instance. * @return static A new instance with the specified fragment. */ - public function withFragment(string $fragment); + public function withFragment(string $fragment): UriInterface; /** * Return the string representation as a URI reference. @@ -1426,7 +1426,7 @@ public function withFragment(string $fragment); * @see http://tools.ietf.org/html/rfc3986#section-4.1 * @return string */ - public function __toString(); + public function __toString(): string; } } @@ -2513,11 +2513,19 @@ public function getContents(): string throw new \RuntimeException('Stream is detached'); } - if (false === $contents = @\stream_get_contents($this->stream)) { - throw new \RuntimeException('Unable to read stream contents: ' . (\error_get_last()['message'] ?? '')); - } + $exception = null; + + \set_error_handler(static function ($type, $message) use (&$exception) { + throw $exception = new \RuntimeException('Unable to read stream contents: ' . $message); + }); - return $contents; + try { + return \stream_get_contents($this->stream); + } catch (\Throwable $e) { + throw $e === $exception ? $e : new \RuntimeException('Unable to read stream contents: ' . $e->getMessage(), 0, $e); + } finally { + \restore_error_handler(); + } } /** @@ -3306,7 +3314,7 @@ public function fromGlobals(): ServerRequestInterface /** * {@inheritdoc} */ - public function fromArrays(array $server, array $headers = [], array $cookie = [], array $get = [], /*?array*/ $post = null, array $files = [], $body = null): ServerRequestInterface + public function fromArrays(array $server, array $headers = [], array $cookie = [], array $get = [], ?array $post = null, array $files = [], $body = null): ServerRequestInterface { $method = $this->getMethodFromEnv($server); $uri = $this->getUriFromEnvWithHTTP($server); @@ -3575,7 +3583,8 @@ public function fromArrays( array $server, array $headers = [], array $cookie = [], - array $get = [], /*?array*/ $post = null, + array $get = [], + ?array $post = null, array $files = [], $body = null ): ServerRequestInterface; @@ -8537,6 +8546,9 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface // file: src/Tqdev/PhpCrudApi/Middleware/DbAuthMiddleware.php namespace Tqdev\PhpCrudApi\Middleware { + use PHPMailer\PHPMailer\PHPMailer; + use PHPMailer\PHPMailer\SMTP; + use PHPMailer\PHPMailer\Exception; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -8551,11 +8563,45 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface use Tqdev\PhpCrudApi\Record\OrderingInfo; use Tqdev\PhpCrudApi\RequestUtils; + require 'vendor/phpmailer/phpmailer/src/Exception.php'; + require 'vendor/phpmailer/phpmailer/src/PHPMailer.php'; + require 'vendor/phpmailer/phpmailer/src/SMTP.php'; + class DbAuthMiddleware extends Middleware { private $reflection; private $db; private $ordering; + + private function sendConfirmationEmail($to, $token, $smtpSettings) + { + $mail = new PHPMailer(true); + try { + //Server settings + $mail->SMTPDebug = 0; + $mail->isSMTP(); + $mail->Host = $smtpSettings['host']; + $mail->SMTPAuth = true; + $mail->Username = $smtpSettings['username']; + $mail->Password = $smtpSettings['password']; + $mail->SMTPSecure = $smtpSettings['secure']; + $mail->Port = $smtpSettings['port']; + //Recipients + $mail->setFrom($smtpSettings['from'], 'Mailer'); + $mail->addAddress($to); + //Content + $mail->isHTML(true); + $mail->Subject = $smtpSettings['confirmSubject']; + $base_url="https://".$_SERVER['SERVER_NAME'].dirname($_SERVER["REQUEST_URI"].'?').'/'; + $mail->Body = $smtpSettings['confirmTemplate'] . '
Confirm'; + $mail->send(); + return true; + } catch (Exception $e) { + //echo 'Message could not be sent.'; + //echo 'Mailer Error: ' . $mail->ErrorInfo; + return false; + } + } public function __construct(Router $router, Responder $responder, Config $config, string $middleware, ReflectionService $reflection, GenericDB $db) { @@ -8587,11 +8633,13 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface } $path = RequestUtils::getPathSegment($request, 1); $method = $request->getMethod(); + $confirmEmail = $this->getProperty('confirmEmail', ''); if ($method == 'POST' && in_array($path, ['login', 'register', 'password'])) { $body = $request->getParsedBody(); $usernameFormFieldName = $this->getProperty('usernameFormField', 'username'); $passwordFormFieldName = $this->getProperty('passwordFormField', 'password'); $newPasswordFormFieldName = $this->getProperty('newPasswordFormField', 'newPassword'); + $emailFormFieldName = $this->getProperty('emailFormField', 'email'); $username = isset($body->$usernameFormFieldName) ? $body->$usernameFormFieldName : ''; $password = isset($body->$passwordFormFieldName) ? $body->$passwordFormFieldName : ''; $newPassword = isset($body->$newPasswordFormFieldName) ? $body->$newPasswordFormFieldName : ''; @@ -8610,6 +8658,10 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface $pkName = $table->getPk()->getName(); $registerUser = $this->getProperty('registerUser', ''); $loginAfterRegistration = $this->getProperty('loginAfterRegistration', ''); + $emailColumnName = $this->getProperty('emailColumn', 'email'); + $tokenColumnName = $this->getProperty('tokenColumn', 'token'); + $confirmedColumnName = $this->getProperty('confirmedColumn', 'confirmed'); + $emailSettings = $this->getProperty('emailSettings', ''); $condition = new ColumnCondition($usernameColumn, 'eq', $username); $returnedColumns = $this->getProperty('returnedColumns', ''); if (!$returnedColumns) { @@ -8634,13 +8686,26 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface if (!empty($users)) { return $this->responder->error(ErrorCode::USER_ALREADY_EXIST, $username); } + $email = isset($body->$emailFormFieldName) ? $body->$emailFormFieldName : ''; $data = json_decode($registerUser, true); $data = is_array($data) ? $data : []; $data[$usernameColumnName] = $username; $data[$passwordColumnName] = password_hash($password, PASSWORD_DEFAULT); + $data[$emailColumnName] = $email; + if ($confirmEmail) { + $data[$confirmedColumnName] = 0; + $data[$emailColumnName] = $email; + $data[$tokenColumnName] = bin2hex(random_bytes(40)); + $emailSent = $this->sendConfirmationEmail($data[$emailColumnName], $data[$tokenColumnName], $emailSettings); + } $this->db->createSingle($table, $data); $users = $this->db->selectAll($table, $columnNames, $condition, $columnOrdering, 0, 1); foreach ($users as $user) { + if ($confirmEmail) { + unset($user[$tokenColumnName]); + unset($user[$passwordColumnName]); + return $this->responder->success($user); + } if ($loginAfterRegistration) { if (!headers_sent()) { session_regenerate_id(true); @@ -8659,10 +8724,16 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface $users = $this->db->selectAll($table, $columnNames, $condition, $columnOrdering, 0, 1); foreach ($users as $user) { if (password_verify($password, $user[$passwordColumnName]) == 1) { + if ($confirmEmail && !$user[$confirmedColumnName]) { + return $this->responder->error(ErrorCode::EMAIL_NOT_CONFIRMED, $username); + } if (!headers_sent()) { session_regenerate_id(true); } unset($user[$passwordColumnName]); + if ($confirmEmail) { + unset($user[$tokenColumnName]); + } $_SESSION['user'] = $user; return $this->responder->success($user); } @@ -8683,6 +8754,9 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface $users = $this->db->selectAll($table, $userColumns, $condition, $columnOrdering, 0, 1); foreach ($users as $user) { if (password_verify($password, $user[$passwordColumnName]) == 1) { + if ($confirmEmail && !$user[$confirmedColumnName]) { + return $this->responder->error(ErrorCode::EMAIL_NOT_CONFIRMED, $username); + } if (!headers_sent()) { session_regenerate_id(true); } @@ -8715,6 +8789,31 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface } return $this->responder->error(ErrorCode::AUTHENTICATION_REQUIRED, ''); } + if ($method == 'GET' && $path == 'confirm' && $confirmEmail) { + $tableName = $this->getProperty('usersTable', 'users'); + $table = $this->reflection->getTable($tableName); + $pkName = $table->getPk()->getName(); + $tokenColumnName = $this->getProperty('tokenColumn', 'token'); + $confirmedColumnName = $this->getProperty('confirmedColumn', 'confirmed'); + $passwordColumnName = $this->getProperty('passwordColumn', 'password'); + $userColumns = $table->getColumnNames(); + if (!in_array($pkName, $userColumns)) { + array_push($userColumns, $pkName); + } + $tokenColumn = $table->getColumn($tokenColumnName); + $confirmationToken = RequestUtils::getPathSegment($request, 2); + $tokenCondition = new ColumnCondition($tokenColumn, 'eq', $confirmationToken); + $columnOrdering = $this->ordering->getDefaultColumnOrdering($table); + $users = $this->db->selectAll($table, $userColumns, $tokenCondition, $columnOrdering, 0, 1); + foreach ($users as $user) { + $data = [$confirmedColumnName => 1]; + $this->db->updateSingle($table, $data, $user[$pkName]); + unset($user[$tokenColumnName]); + unset($user[$passwordColumnName]); + $user[$confirmedColumnName] = 1; + return $this->responder->success($user); + } + } if (!isset($_SESSION['user']) || !$_SESSION['user']) { $authenticationMode = $this->getProperty('mode', 'required'); if ($authenticationMode == 'required') { @@ -11453,6 +11552,7 @@ class ErrorCode const USER_ALREADY_EXIST = 1020; const PASSWORD_TOO_SHORT = 1021; const USERNAME_EMPTY = 1022; + const EMAIL_NOT_CONFIRMED = 1023; private $values = [ 0000 => ["Success", ResponseFactory::OK], @@ -11479,6 +11579,7 @@ class ErrorCode 1020 => ["User '%s' already exists", ResponseFactory::CONFLICT], 1021 => ["Password too short (<%d characters)", ResponseFactory::UNPROCESSABLE_ENTITY], 1022 => ["Username is empty or only whitespaces", ResponseFactory::UNPROCESSABLE_ENTITY], + 1023 => ["Email not confirmed for '%s'", ResponseFactory::FORBIDDEN], 9999 => ["%s", ResponseFactory::INTERNAL_SERVER_ERROR], ]; diff --git a/api.php b/api.php index 3987f654..e808c702 100644 --- a/api.php +++ b/api.php @@ -197,7 +197,7 @@ interface MessageInterface * * @return string HTTP protocol version. */ - public function getProtocolVersion(); + public function getProtocolVersion(): string; /** * Return an instance with the specified HTTP protocol version. @@ -212,7 +212,7 @@ public function getProtocolVersion(); * @param string $version HTTP protocol version * @return static */ - public function withProtocolVersion(string $version); + public function withProtocolVersion(string $version): MessageInterface; /** * Retrieves all message header values. @@ -239,7 +239,7 @@ public function withProtocolVersion(string $version); * key MUST be a header name, and each value MUST be an array of strings * for that header. */ - public function getHeaders(); + public function getHeaders(): array; /** * Checks if a header exists by the given case-insensitive name. @@ -249,7 +249,7 @@ public function getHeaders(); * name using a case-insensitive string comparison. Returns false if * no matching header name is found in the message. */ - public function hasHeader(string $name); + public function hasHeader(string $name): bool; /** * Retrieves a message header value by the given case-insensitive name. @@ -265,7 +265,7 @@ public function hasHeader(string $name); * header. If the header does not appear in the message, this method MUST * return an empty array. */ - public function getHeader(string $name); + public function getHeader(string $name): array; /** * Retrieves a comma-separated string of the values for a single header. @@ -286,7 +286,7 @@ public function getHeader(string $name); * concatenated together using a comma. If the header does not appear in * the message, this method MUST return an empty string. */ - public function getHeaderLine(string $name); + public function getHeaderLine(string $name): string; /** * Return an instance with the provided value replacing the specified header. @@ -303,7 +303,7 @@ public function getHeaderLine(string $name); * @return static * @throws \InvalidArgumentException for invalid header names or values. */ - public function withHeader(string $name, $value); + public function withHeader(string $name, $value): MessageInterface; /** * Return an instance with the specified header appended with the given value. @@ -321,7 +321,7 @@ public function withHeader(string $name, $value); * @return static * @throws \InvalidArgumentException for invalid header names or values. */ - public function withAddedHeader(string $name, $value); + public function withAddedHeader(string $name, $value): MessageInterface; /** * Return an instance without the specified header. @@ -335,14 +335,14 @@ public function withAddedHeader(string $name, $value); * @param string $name Case-insensitive header field name to remove. * @return static */ - public function withoutHeader(string $name); + public function withoutHeader(string $name): MessageInterface; /** * Gets the body of the message. * * @return StreamInterface Returns the body as a stream. */ - public function getBody(); + public function getBody(): StreamInterface; /** * Return an instance with the specified message body. @@ -357,7 +357,7 @@ public function getBody(); * @return static * @throws \InvalidArgumentException When the body is not valid. */ - public function withBody(StreamInterface $body); + public function withBody(StreamInterface $body): MessageInterface; } } @@ -401,7 +401,7 @@ interface RequestInterface extends MessageInterface * * @return string */ - public function getRequestTarget(); + public function getRequestTarget(): string; /** * Return an instance with the specific request-target. @@ -420,14 +420,14 @@ public function getRequestTarget(); * @param string $requestTarget * @return static */ - public function withRequestTarget(string $requestTarget); + public function withRequestTarget(string $requestTarget): RequestInterface; /** * Retrieves the HTTP method of the request. * * @return string Returns the request method. */ - public function getMethod(); + public function getMethod(): string; /** * Return an instance with the provided HTTP method. @@ -444,7 +444,7 @@ public function getMethod(); * @return static * @throws \InvalidArgumentException for invalid HTTP methods. */ - public function withMethod(string $method); + public function withMethod(string $method): RequestInterface; /** * Retrieves the URI instance. @@ -455,7 +455,7 @@ public function withMethod(string $method); * @return UriInterface Returns a UriInterface instance * representing the URI of the request. */ - public function getUri(); + public function getUri(): UriInterface; /** * Returns an instance with the provided URI. @@ -487,7 +487,7 @@ public function getUri(); * @param bool $preserveHost Preserve the original state of the Host header. * @return static */ - public function withUri(UriInterface $uri, bool $preserveHost = false); + public function withUri(UriInterface $uri, bool $preserveHost = false): RequestInterface; } } @@ -519,7 +519,7 @@ interface ResponseInterface extends MessageInterface * * @return int Status code. */ - public function getStatusCode(); + public function getStatusCode(): int; /** * Return an instance with the specified status code and, optionally, reason phrase. @@ -541,7 +541,7 @@ public function getStatusCode(); * @return static * @throws \InvalidArgumentException For invalid status code arguments. */ - public function withStatus(int $code, string $reasonPhrase = ''); + public function withStatus(int $code, string $reasonPhrase = ''): ResponseInterface; /** * Gets the response reason phrase associated with the status code. @@ -556,7 +556,7 @@ public function withStatus(int $code, string $reasonPhrase = ''); * @link http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml * @return string Reason phrase; must return an empty string if none present. */ - public function getReasonPhrase(); + public function getReasonPhrase(): string; } } @@ -612,7 +612,7 @@ interface ServerRequestInterface extends RequestInterface * * @return array */ - public function getServerParams(); + public function getServerParams(): array; /** * Retrieve cookies. @@ -624,7 +624,7 @@ public function getServerParams(); * * @return array */ - public function getCookieParams(); + public function getCookieParams(): array; /** * Return an instance with the specified cookies. @@ -643,7 +643,7 @@ public function getCookieParams(); * @param array $cookies Array of key/value pairs representing cookies. * @return static */ - public function withCookieParams(array $cookies); + public function withCookieParams(array $cookies): ServerRequestInterface; /** * Retrieve query string arguments. @@ -657,7 +657,7 @@ public function withCookieParams(array $cookies); * * @return array */ - public function getQueryParams(); + public function getQueryParams(): array; /** * Return an instance with the specified query string arguments. @@ -681,7 +681,7 @@ public function getQueryParams(); * $_GET. * @return static */ - public function withQueryParams(array $query); + public function withQueryParams(array $query): ServerRequestInterface; /** * Retrieve normalized file upload data. @@ -695,7 +695,7 @@ public function withQueryParams(array $query); * @return array An array tree of UploadedFileInterface instances; an empty * array MUST be returned if no data is present. */ - public function getUploadedFiles(); + public function getUploadedFiles(): array; /** * Create a new instance with the specified uploaded files. @@ -708,7 +708,7 @@ public function getUploadedFiles(); * @return static * @throws \InvalidArgumentException if an invalid structure is provided. */ - public function withUploadedFiles(array $uploadedFiles); + public function withUploadedFiles(array $uploadedFiles): ServerRequestInterface; /** * Retrieve any parameters provided in the request body. @@ -755,7 +755,7 @@ public function getParsedBody(); * @throws \InvalidArgumentException if an unsupported argument type is * provided. */ - public function withParsedBody($data); + public function withParsedBody($data): ServerRequestInterface; /** * Retrieve attributes derived from the request. @@ -768,7 +768,7 @@ public function withParsedBody($data); * * @return array Attributes derived from the request. */ - public function getAttributes(); + public function getAttributes(): array; /** * Retrieve a single derived request attribute. @@ -802,7 +802,7 @@ public function getAttribute(string $name, $default = null); * @param mixed $value The value of the attribute. * @return static */ - public function withAttribute(string $name, $value); + public function withAttribute(string $name, $value): ServerRequestInterface; /** * Return an instance that removes the specified derived request attribute. @@ -818,7 +818,7 @@ public function withAttribute(string $name, $value); * @param string $name The attribute name. * @return static */ - public function withoutAttribute(string $name); + public function withoutAttribute(string $name): ServerRequestInterface; } } @@ -848,14 +848,14 @@ interface StreamInterface * @see http://php.net/manual/en/language.oop5.magic.php#object.tostring * @return string */ - public function __toString(); + public function __toString(): string; /** * Closes the stream and any underlying resources. * * @return void */ - public function close(); + public function close(): void; /** * Separates any underlying resources from the stream. @@ -871,7 +871,7 @@ public function detach(); * * @return int|null Returns the size in bytes if known, or null if unknown. */ - public function getSize(); + public function getSize(): ?int; /** * Returns the current position of the file read/write pointer @@ -879,21 +879,21 @@ public function getSize(); * @return int Position of the file pointer * @throws \RuntimeException on error. */ - public function tell(); + public function tell(): int; /** * Returns true if the stream is at the end of the stream. * * @return bool */ - public function eof(); + public function eof(): bool; /** * Returns whether or not the stream is seekable. * * @return bool */ - public function isSeekable(); + public function isSeekable(): bool; /** * Seek to a position in the stream. @@ -907,7 +907,7 @@ public function isSeekable(); * SEEK_END: Set position to end-of-stream plus offset. * @throws \RuntimeException on failure. */ - public function seek(int $offset, int $whence = SEEK_SET); + public function seek(int $offset, int $whence = SEEK_SET): void; /** * Seek to the beginning of the stream. @@ -919,14 +919,14 @@ public function seek(int $offset, int $whence = SEEK_SET); * @link http://www.php.net/manual/en/function.fseek.php * @throws \RuntimeException on failure. */ - public function rewind(); + public function rewind(): void; /** * Returns whether or not the stream is writable. * * @return bool */ - public function isWritable(); + public function isWritable(): bool; /** * Write data to the stream. @@ -935,14 +935,14 @@ public function isWritable(); * @return int Returns the number of bytes written to the stream. * @throws \RuntimeException on failure. */ - public function write(string $string); + public function write(string $string): int; /** * Returns whether or not the stream is readable. * * @return bool */ - public function isReadable(); + public function isReadable(): bool; /** * Read data from the stream. @@ -954,7 +954,7 @@ public function isReadable(); * if no bytes are available. * @throws \RuntimeException if an error occurs. */ - public function read(int $length); + public function read(int $length): string; /** * Returns the remaining contents in a string @@ -963,7 +963,7 @@ public function read(int $length); * @throws \RuntimeException if unable to read or an error occurs while * reading. */ - public function getContents(); + public function getContents(): string; /** * Get stream metadata as an associative array or retrieve a specific key. @@ -1010,7 +1010,7 @@ interface UploadedFileInterface * @throws \RuntimeException in cases when no stream is available or can be * created. */ - public function getStream(); + public function getStream(): StreamInterface; /** * Move the uploaded file to a new location. @@ -1044,7 +1044,7 @@ public function getStream(); * @throws \RuntimeException on any error during the move operation, or on * the second or subsequent call to the method. */ - public function moveTo(string $targetPath); + public function moveTo(string $targetPath): void; /** * Retrieve the file size. @@ -1055,7 +1055,7 @@ public function moveTo(string $targetPath); * * @return int|null The file size in bytes or null if unknown. */ - public function getSize(); + public function getSize(): ?int; /** * Retrieve the error associated with the uploaded file. @@ -1071,7 +1071,7 @@ public function getSize(); * @see http://php.net/manual/en/features.file-upload.errors.php * @return int One of PHP's UPLOAD_ERR_XXX constants. */ - public function getError(); + public function getError(): int; /** * Retrieve the filename sent by the client. @@ -1086,7 +1086,7 @@ public function getError(); * @return string|null The filename sent by the client or null if none * was provided. */ - public function getClientFilename(); + public function getClientFilename(): ?string; /** * Retrieve the media type sent by the client. @@ -1101,7 +1101,7 @@ public function getClientFilename(); * @return string|null The media type sent by the client or null if none * was provided. */ - public function getClientMediaType(); + public function getClientMediaType(): ?string; } } @@ -1144,7 +1144,7 @@ interface UriInterface * @see https://tools.ietf.org/html/rfc3986#section-3.1 * @return string The URI scheme. */ - public function getScheme(); + public function getScheme(): string; /** * Retrieve the authority component of the URI. @@ -1164,7 +1164,7 @@ public function getScheme(); * @see https://tools.ietf.org/html/rfc3986#section-3.2 * @return string The URI authority, in "[user-info@]host[:port]" format. */ - public function getAuthority(); + public function getAuthority(): string; /** * Retrieve the user information component of the URI. @@ -1181,7 +1181,7 @@ public function getAuthority(); * * @return string The URI user information, in "username[:password]" format. */ - public function getUserInfo(); + public function getUserInfo(): string; /** * Retrieve the host component of the URI. @@ -1194,7 +1194,7 @@ public function getUserInfo(); * @see http://tools.ietf.org/html/rfc3986#section-3.2.2 * @return string The URI host. */ - public function getHost(); + public function getHost(): string; /** * Retrieve the port component of the URI. @@ -1211,7 +1211,7 @@ public function getHost(); * * @return null|int The URI port. */ - public function getPort(); + public function getPort(): ?int; /** * Retrieve the path component of the URI. @@ -1238,7 +1238,7 @@ public function getPort(); * @see https://tools.ietf.org/html/rfc3986#section-3.3 * @return string The URI path. */ - public function getPath(); + public function getPath(): string; /** * Retrieve the query string of the URI. @@ -1260,7 +1260,7 @@ public function getPath(); * @see https://tools.ietf.org/html/rfc3986#section-3.4 * @return string The URI query string. */ - public function getQuery(); + public function getQuery(): string; /** * Retrieve the fragment component of the URI. @@ -1278,7 +1278,7 @@ public function getQuery(); * @see https://tools.ietf.org/html/rfc3986#section-3.5 * @return string The URI fragment. */ - public function getFragment(); + public function getFragment(): string; /** * Return an instance with the specified scheme. @@ -1295,7 +1295,7 @@ public function getFragment(); * @return static A new instance with the specified scheme. * @throws \InvalidArgumentException for invalid or unsupported schemes. */ - public function withScheme(string $scheme); + public function withScheme(string $scheme): UriInterface; /** * Return an instance with the specified user information. @@ -1311,7 +1311,7 @@ public function withScheme(string $scheme); * @param null|string $password The password associated with $user. * @return static A new instance with the specified user information. */ - public function withUserInfo(string $user, ?string $password = null); + public function withUserInfo(string $user, ?string $password = null): UriInterface; /** * Return an instance with the specified host. @@ -1325,7 +1325,7 @@ public function withUserInfo(string $user, ?string $password = null); * @return static A new instance with the specified host. * @throws \InvalidArgumentException for invalid hostnames. */ - public function withHost(string $host); + public function withHost(string $host): UriInterface; /** * Return an instance with the specified port. @@ -1344,7 +1344,7 @@ public function withHost(string $host); * @return static A new instance with the specified port. * @throws \InvalidArgumentException for invalid ports. */ - public function withPort(?int $port); + public function withPort(?int $port): UriInterface; /** * Return an instance with the specified path. @@ -1368,7 +1368,7 @@ public function withPort(?int $port); * @return static A new instance with the specified path. * @throws \InvalidArgumentException for invalid paths. */ - public function withPath(string $path); + public function withPath(string $path): UriInterface; /** * Return an instance with the specified query string. @@ -1385,7 +1385,7 @@ public function withPath(string $path); * @return static A new instance with the specified query string. * @throws \InvalidArgumentException for invalid query strings. */ - public function withQuery(string $query); + public function withQuery(string $query): UriInterface; /** * Return an instance with the specified URI fragment. @@ -1401,7 +1401,7 @@ public function withQuery(string $query); * @param string $fragment The fragment to use with the new instance. * @return static A new instance with the specified fragment. */ - public function withFragment(string $fragment); + public function withFragment(string $fragment): UriInterface; /** * Return the string representation as a URI reference. @@ -1426,7 +1426,7 @@ public function withFragment(string $fragment); * @see http://tools.ietf.org/html/rfc3986#section-4.1 * @return string */ - public function __toString(); + public function __toString(): string; } } @@ -2513,11 +2513,19 @@ public function getContents(): string throw new \RuntimeException('Stream is detached'); } - if (false === $contents = @\stream_get_contents($this->stream)) { - throw new \RuntimeException('Unable to read stream contents: ' . (\error_get_last()['message'] ?? '')); - } + $exception = null; + + \set_error_handler(static function ($type, $message) use (&$exception) { + throw $exception = new \RuntimeException('Unable to read stream contents: ' . $message); + }); - return $contents; + try { + return \stream_get_contents($this->stream); + } catch (\Throwable $e) { + throw $e === $exception ? $e : new \RuntimeException('Unable to read stream contents: ' . $e->getMessage(), 0, $e); + } finally { + \restore_error_handler(); + } } /** @@ -3306,7 +3314,7 @@ public function fromGlobals(): ServerRequestInterface /** * {@inheritdoc} */ - public function fromArrays(array $server, array $headers = [], array $cookie = [], array $get = [], /*?array*/ $post = null, array $files = [], $body = null): ServerRequestInterface + public function fromArrays(array $server, array $headers = [], array $cookie = [], array $get = [], ?array $post = null, array $files = [], $body = null): ServerRequestInterface { $method = $this->getMethodFromEnv($server); $uri = $this->getUriFromEnvWithHTTP($server); @@ -3575,7 +3583,8 @@ public function fromArrays( array $server, array $headers = [], array $cookie = [], - array $get = [], /*?array*/ $post = null, + array $get = [], + ?array $post = null, array $files = [], $body = null ): ServerRequestInterface; @@ -8537,6 +8546,9 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface // file: src/Tqdev/PhpCrudApi/Middleware/DbAuthMiddleware.php namespace Tqdev\PhpCrudApi\Middleware { + use PHPMailer\PHPMailer\PHPMailer; + use PHPMailer\PHPMailer\SMTP; + use PHPMailer\PHPMailer\Exception; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -8551,11 +8563,45 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface use Tqdev\PhpCrudApi\Record\OrderingInfo; use Tqdev\PhpCrudApi\RequestUtils; + require 'vendor/phpmailer/phpmailer/src/Exception.php'; + require 'vendor/phpmailer/phpmailer/src/PHPMailer.php'; + require 'vendor/phpmailer/phpmailer/src/SMTP.php'; + class DbAuthMiddleware extends Middleware { private $reflection; private $db; private $ordering; + + private function sendConfirmationEmail($to, $token, $smtpSettings) + { + $mail = new PHPMailer(true); + try { + //Server settings + $mail->SMTPDebug = 0; + $mail->isSMTP(); + $mail->Host = $smtpSettings['host']; + $mail->SMTPAuth = true; + $mail->Username = $smtpSettings['username']; + $mail->Password = $smtpSettings['password']; + $mail->SMTPSecure = $smtpSettings['secure']; + $mail->Port = $smtpSettings['port']; + //Recipients + $mail->setFrom($smtpSettings['from'], 'Mailer'); + $mail->addAddress($to); + //Content + $mail->isHTML(true); + $mail->Subject = $smtpSettings['confirmSubject']; + $base_url="https://".$_SERVER['SERVER_NAME'].dirname($_SERVER["REQUEST_URI"].'?').'/'; + $mail->Body = $smtpSettings['confirmTemplate'] . '
Confirm'; + $mail->send(); + return true; + } catch (Exception $e) { + //echo 'Message could not be sent.'; + //echo 'Mailer Error: ' . $mail->ErrorInfo; + return false; + } + } public function __construct(Router $router, Responder $responder, Config $config, string $middleware, ReflectionService $reflection, GenericDB $db) { @@ -8587,11 +8633,13 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface } $path = RequestUtils::getPathSegment($request, 1); $method = $request->getMethod(); + $confirmEmail = $this->getProperty('confirmEmail', ''); if ($method == 'POST' && in_array($path, ['login', 'register', 'password'])) { $body = $request->getParsedBody(); $usernameFormFieldName = $this->getProperty('usernameFormField', 'username'); $passwordFormFieldName = $this->getProperty('passwordFormField', 'password'); $newPasswordFormFieldName = $this->getProperty('newPasswordFormField', 'newPassword'); + $emailFormFieldName = $this->getProperty('emailFormField', 'email'); $username = isset($body->$usernameFormFieldName) ? $body->$usernameFormFieldName : ''; $password = isset($body->$passwordFormFieldName) ? $body->$passwordFormFieldName : ''; $newPassword = isset($body->$newPasswordFormFieldName) ? $body->$newPasswordFormFieldName : ''; @@ -8610,6 +8658,10 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface $pkName = $table->getPk()->getName(); $registerUser = $this->getProperty('registerUser', ''); $loginAfterRegistration = $this->getProperty('loginAfterRegistration', ''); + $emailColumnName = $this->getProperty('emailColumn', 'email'); + $tokenColumnName = $this->getProperty('tokenColumn', 'token'); + $confirmedColumnName = $this->getProperty('confirmedColumn', 'confirmed'); + $emailSettings = $this->getProperty('emailSettings', ''); $condition = new ColumnCondition($usernameColumn, 'eq', $username); $returnedColumns = $this->getProperty('returnedColumns', ''); if (!$returnedColumns) { @@ -8634,13 +8686,26 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface if (!empty($users)) { return $this->responder->error(ErrorCode::USER_ALREADY_EXIST, $username); } + $email = isset($body->$emailFormFieldName) ? $body->$emailFormFieldName : ''; $data = json_decode($registerUser, true); $data = is_array($data) ? $data : []; $data[$usernameColumnName] = $username; $data[$passwordColumnName] = password_hash($password, PASSWORD_DEFAULT); + $data[$emailColumnName] = $email; + if ($confirmEmail) { + $data[$confirmedColumnName] = 0; + $data[$emailColumnName] = $email; + $data[$tokenColumnName] = bin2hex(random_bytes(40)); + $emailSent = $this->sendConfirmationEmail($data[$emailColumnName], $data[$tokenColumnName], $emailSettings); + } $this->db->createSingle($table, $data); $users = $this->db->selectAll($table, $columnNames, $condition, $columnOrdering, 0, 1); foreach ($users as $user) { + if ($confirmEmail) { + unset($user[$tokenColumnName]); + unset($user[$passwordColumnName]); + return $this->responder->success($user); + } if ($loginAfterRegistration) { if (!headers_sent()) { session_regenerate_id(true); @@ -8659,10 +8724,16 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface $users = $this->db->selectAll($table, $columnNames, $condition, $columnOrdering, 0, 1); foreach ($users as $user) { if (password_verify($password, $user[$passwordColumnName]) == 1) { + if ($confirmEmail && !$user[$confirmedColumnName]) { + return $this->responder->error(ErrorCode::EMAIL_NOT_CONFIRMED, $username); + } if (!headers_sent()) { session_regenerate_id(true); } unset($user[$passwordColumnName]); + if ($confirmEmail) { + unset($user[$tokenColumnName]); + } $_SESSION['user'] = $user; return $this->responder->success($user); } @@ -8683,6 +8754,9 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface $users = $this->db->selectAll($table, $userColumns, $condition, $columnOrdering, 0, 1); foreach ($users as $user) { if (password_verify($password, $user[$passwordColumnName]) == 1) { + if ($confirmEmail && !$user[$confirmedColumnName]) { + return $this->responder->error(ErrorCode::EMAIL_NOT_CONFIRMED, $username); + } if (!headers_sent()) { session_regenerate_id(true); } @@ -8715,6 +8789,31 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface } return $this->responder->error(ErrorCode::AUTHENTICATION_REQUIRED, ''); } + if ($method == 'GET' && $path == 'confirm' && $confirmEmail) { + $tableName = $this->getProperty('usersTable', 'users'); + $table = $this->reflection->getTable($tableName); + $pkName = $table->getPk()->getName(); + $tokenColumnName = $this->getProperty('tokenColumn', 'token'); + $confirmedColumnName = $this->getProperty('confirmedColumn', 'confirmed'); + $passwordColumnName = $this->getProperty('passwordColumn', 'password'); + $userColumns = $table->getColumnNames(); + if (!in_array($pkName, $userColumns)) { + array_push($userColumns, $pkName); + } + $tokenColumn = $table->getColumn($tokenColumnName); + $confirmationToken = RequestUtils::getPathSegment($request, 2); + $tokenCondition = new ColumnCondition($tokenColumn, 'eq', $confirmationToken); + $columnOrdering = $this->ordering->getDefaultColumnOrdering($table); + $users = $this->db->selectAll($table, $userColumns, $tokenCondition, $columnOrdering, 0, 1); + foreach ($users as $user) { + $data = [$confirmedColumnName => 1]; + $this->db->updateSingle($table, $data, $user[$pkName]); + unset($user[$tokenColumnName]); + unset($user[$passwordColumnName]); + $user[$confirmedColumnName] = 1; + return $this->responder->success($user); + } + } if (!isset($_SESSION['user']) || !$_SESSION['user']) { $authenticationMode = $this->getProperty('mode', 'required'); if ($authenticationMode == 'required') { @@ -11453,6 +11552,7 @@ class ErrorCode const USER_ALREADY_EXIST = 1020; const PASSWORD_TOO_SHORT = 1021; const USERNAME_EMPTY = 1022; + const EMAIL_NOT_CONFIRMED = 1023; private $values = [ 0000 => ["Success", ResponseFactory::OK], @@ -11479,6 +11579,7 @@ class ErrorCode 1020 => ["User '%s' already exists", ResponseFactory::CONFLICT], 1021 => ["Password too short (<%d characters)", ResponseFactory::UNPROCESSABLE_ENTITY], 1022 => ["Username is empty or only whitespaces", ResponseFactory::UNPROCESSABLE_ENTITY], + 1023 => ["Email not confirmed for '%s'", ResponseFactory::FORBIDDEN], 9999 => ["%s", ResponseFactory::INTERNAL_SERVER_ERROR], ]; diff --git a/composer.json b/composer.json index b18ada3b..a2fff8bf 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,7 @@ } ], "require": { - "php": ">=7.0.0", + "php": ">=7.4.0", "ext-zlib": "*", "ext-json": "*", "ext-pdo": "*", @@ -42,7 +42,8 @@ "psr/http-server-handler": "*", "psr/http-server-middleware": "*", "nyholm/psr7": "*", - "nyholm/psr7-server": "*" + "nyholm/psr7-server": "*", + "phpmailer/phpmailer": "^6.9.1" }, "suggest": { "ext-memcache": "*", diff --git a/composer.lock b/composer.lock index 26653700..58ce920c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3667d105fa59f4e36775fe664aef0f2f", + "content-hash": "fd358ee542c343971657cb3fc7cac5cb", "packages": [ { "name": "nyholm/psr7", - "version": "1.8.0", + "version": "1.8.1", "source": { "type": "git", "url": "https://github.com/Nyholm/psr7.git", - "reference": "3cb4d163b58589e47b35103e8e5e6a6a475b47be" + "reference": "aa5fc277a4f5508013d571341ade0c3886d4d00e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Nyholm/psr7/zipball/3cb4d163b58589e47b35103e8e5e6a6a475b47be", - "reference": "3cb4d163b58589e47b35103e8e5e6a6a475b47be", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/aa5fc277a4f5508013d571341ade0c3886d4d00e", + "reference": "aa5fc277a4f5508013d571341ade0c3886d4d00e", "shasum": "" }, "require": { @@ -70,7 +70,7 @@ ], "support": { "issues": "https://github.com/Nyholm/psr7/issues", - "source": "https://github.com/Nyholm/psr7/tree/1.8.0" + "source": "https://github.com/Nyholm/psr7/tree/1.8.1" }, "funding": [ { @@ -82,26 +82,26 @@ "type": "github" } ], - "time": "2023-05-02T11:26:24+00:00" + "time": "2023-11-13T09:31:12+00:00" }, { "name": "nyholm/psr7-server", - "version": "1.0.2", + "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/Nyholm/psr7-server.git", - "reference": "b846a689844cef114e8079d8c80f0afd96745ae3" + "reference": "4335801d851f554ca43fa6e7d2602141538854dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Nyholm/psr7-server/zipball/b846a689844cef114e8079d8c80f0afd96745ae3", - "reference": "b846a689844cef114e8079d8c80f0afd96745ae3", + "url": "https://api.github.com/repos/Nyholm/psr7-server/zipball/4335801d851f554ca43fa6e7d2602141538854dc", + "reference": "4335801d851f554ca43fa6e7d2602141538854dc", "shasum": "" }, "require": { "php": "^7.1 || ^8.0", "psr/http-factory": "^1.0", - "psr/http-message": "^1.0" + "psr/http-message": "^1.0 || ^2.0" }, "require-dev": { "nyholm/nsa": "^1.1", @@ -136,7 +136,7 @@ ], "support": { "issues": "https://github.com/Nyholm/psr7-server/issues", - "source": "https://github.com/Nyholm/psr7-server/tree/1.0.2" + "source": "https://github.com/Nyholm/psr7-server/tree/1.1.0" }, "funding": [ { @@ -148,7 +148,88 @@ "type": "github" } ], - "time": "2021-05-12T11:11:27+00:00" + "time": "2023-11-08T09:30:43+00:00" + }, + { + "name": "phpmailer/phpmailer", + "version": "v6.9.1", + "source": { + "type": "git", + "url": "https://github.com/PHPMailer/PHPMailer.git", + "reference": "039de174cd9c17a8389754d3b877a2ed22743e18" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/039de174cd9c17a8389754d3b877a2ed22743e18", + "reference": "039de174cd9c17a8389754d3b877a2ed22743e18", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "php": ">=5.5.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "doctrine/annotations": "^1.2.6 || ^1.13.3", + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.3.2", + "phpcompatibility/php-compatibility": "^9.3.5", + "roave/security-advisories": "dev-latest", + "squizlabs/php_codesniffer": "^3.7.2", + "yoast/phpunit-polyfills": "^1.0.4" + }, + "suggest": { + "decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication", + "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses", + "ext-openssl": "Needed for secure SMTP sending and DKIM signing", + "greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication", + "hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication", + "league/oauth2-google": "Needed for Google XOAUTH2 authentication", + "psr/log": "For optional PSR-3 debug logging", + "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)", + "thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPMailer\\PHPMailer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-only" + ], + "authors": [ + { + "name": "Marcus Bointon", + "email": "phpmailer@synchromedia.co.uk" + }, + { + "name": "Jim Jagielski", + "email": "jimjag@gmail.com" + }, + { + "name": "Andy Prevost", + "email": "codeworxtech@users.sourceforge.net" + }, + { + "name": "Brent R. Matzelle" + } + ], + "description": "PHPMailer is a full-featured email creation and transfer class for PHP", + "support": { + "issues": "https://github.com/PHPMailer/PHPMailer/issues", + "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.9.1" + }, + "funding": [ + { + "url": "https://github.com/Synchro", + "type": "github" + } + ], + "time": "2023-11-25T22:23:28+00:00" }, { "name": "psr/http-factory", @@ -207,16 +288,16 @@ }, { "name": "psr/http-message", - "version": "1.1", + "version": "2.0", "source": { "type": "git", "url": "https://github.com/php-fig/http-message.git", - "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba" + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/cb6ce4845ce34a8ad9e68117c10ee90a29919eba", - "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", "shasum": "" }, "require": { @@ -225,7 +306,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.1.x-dev" + "dev-master": "2.0.x-dev" } }, "autoload": { @@ -240,7 +321,7 @@ "authors": [ { "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" + "homepage": "https://www.php-fig.org/" } ], "description": "Common interface for HTTP messages", @@ -254,9 +335,9 @@ "response" ], "support": { - "source": "https://github.com/php-fig/http-message/tree/1.1" + "source": "https://github.com/php-fig/http-message/tree/2.0" }, - "time": "2023-04-04T09:50:52+00:00" + "time": "2023-04-04T09:54:51+00:00" }, { "name": "psr/http-server-handler", @@ -379,11 +460,12 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=7.0.0", + "php": ">=7.4.0", "ext-zlib": "*", "ext-json": "*", - "ext-pdo": "*" + "ext-pdo": "*", + "ext-mbstring": "*" }, "platform-dev": [], - "plugin-api-version": "2.2.0" + "plugin-api-version": "2.6.0" } diff --git a/src/Tqdev/PhpCrudApi/Middleware/DbAuthMiddleware.php b/src/Tqdev/PhpCrudApi/Middleware/DbAuthMiddleware.php index fb6aba9f..109edd1a 100644 --- a/src/Tqdev/PhpCrudApi/Middleware/DbAuthMiddleware.php +++ b/src/Tqdev/PhpCrudApi/Middleware/DbAuthMiddleware.php @@ -2,6 +2,9 @@ namespace Tqdev\PhpCrudApi\Middleware; +use PHPMailer\PHPMailer\PHPMailer; +use PHPMailer\PHPMailer\SMTP; +use PHPMailer\PHPMailer\Exception; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -16,11 +19,45 @@ use Tqdev\PhpCrudApi\Record\OrderingInfo; use Tqdev\PhpCrudApi\RequestUtils; +require 'vendor/phpmailer/phpmailer/src/Exception.php'; +require 'vendor/phpmailer/phpmailer/src/PHPMailer.php'; +require 'vendor/phpmailer/phpmailer/src/SMTP.php'; + class DbAuthMiddleware extends Middleware { private $reflection; private $db; private $ordering; + + private function sendConfirmationEmail($to, $token, $smtpSettings) + { + $mail = new PHPMailer(true); + try { + //Server settings + $mail->SMTPDebug = 0; + $mail->isSMTP(); + $mail->Host = $smtpSettings['host']; + $mail->SMTPAuth = true; + $mail->Username = $smtpSettings['username']; + $mail->Password = $smtpSettings['password']; + $mail->SMTPSecure = $smtpSettings['secure']; + $mail->Port = $smtpSettings['port']; + //Recipients + $mail->setFrom($smtpSettings['from'], 'Mailer'); + $mail->addAddress($to); + //Content + $mail->isHTML(true); + $mail->Subject = $smtpSettings['confirmSubject']; + $base_url="https://".$_SERVER['SERVER_NAME'].dirname($_SERVER["REQUEST_URI"].'?').'/'; + $mail->Body = $smtpSettings['confirmTemplate'] . '
Confirm'; + $mail->send(); + return true; + } catch (Exception $e) { + //echo 'Message could not be sent.'; + //echo 'Mailer Error: ' . $mail->ErrorInfo; + return false; + } + } public function __construct(Router $router, Responder $responder, Config $config, string $middleware, ReflectionService $reflection, GenericDB $db) { @@ -29,6 +66,8 @@ public function __construct(Router $router, Responder $responder, Config $config $this->db = $db; $this->ordering = new OrderingInfo(); } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface { @@ -52,11 +91,13 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface } $path = RequestUtils::getPathSegment($request, 1); $method = $request->getMethod(); + $confirmEmail = $this->getProperty('confirmEmail', ''); if ($method == 'POST' && in_array($path, ['login', 'register', 'password'])) { $body = $request->getParsedBody(); $usernameFormFieldName = $this->getProperty('usernameFormField', 'username'); $passwordFormFieldName = $this->getProperty('passwordFormField', 'password'); $newPasswordFormFieldName = $this->getProperty('newPasswordFormField', 'newPassword'); + $emailFormFieldName = $this->getProperty('emailFormField', 'email'); $username = isset($body->$usernameFormFieldName) ? $body->$usernameFormFieldName : ''; $password = isset($body->$passwordFormFieldName) ? $body->$passwordFormFieldName : ''; $newPassword = isset($body->$newPasswordFormFieldName) ? $body->$newPasswordFormFieldName : ''; @@ -75,6 +116,10 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface $pkName = $table->getPk()->getName(); $registerUser = $this->getProperty('registerUser', ''); $loginAfterRegistration = $this->getProperty('loginAfterRegistration', ''); + $emailColumnName = $this->getProperty('emailColumn', 'email'); + $tokenColumnName = $this->getProperty('tokenColumn', 'token'); + $confirmedColumnName = $this->getProperty('confirmedColumn', 'confirmed'); + $emailSettings = $this->getProperty('emailSettings', ''); $condition = new ColumnCondition($usernameColumn, 'eq', $username); $returnedColumns = $this->getProperty('returnedColumns', ''); if (!$returnedColumns) { @@ -99,13 +144,26 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface if (!empty($users)) { return $this->responder->error(ErrorCode::USER_ALREADY_EXIST, $username); } + $email = isset($body->$emailFormFieldName) ? $body->$emailFormFieldName : ''; $data = json_decode($registerUser, true); $data = is_array($data) ? $data : []; $data[$usernameColumnName] = $username; $data[$passwordColumnName] = password_hash($password, PASSWORD_DEFAULT); + $data[$emailColumnName] = $email; + if ($confirmEmail) { + $data[$confirmedColumnName] = 0; + $data[$emailColumnName] = $email; + $data[$tokenColumnName] = bin2hex(random_bytes(40)); + $emailSent = $this->sendConfirmationEmail($data[$emailColumnName], $data[$tokenColumnName], $emailSettings); + } $this->db->createSingle($table, $data); $users = $this->db->selectAll($table, $columnNames, $condition, $columnOrdering, 0, 1); foreach ($users as $user) { + if ($confirmEmail) { + unset($user[$tokenColumnName]); + unset($user[$passwordColumnName]); + return $this->responder->success($user); + } if ($loginAfterRegistration) { if (!headers_sent()) { session_regenerate_id(true); @@ -124,10 +182,16 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface $users = $this->db->selectAll($table, $columnNames, $condition, $columnOrdering, 0, 1); foreach ($users as $user) { if (password_verify($password, $user[$passwordColumnName]) == 1) { + if ($confirmEmail && !$user[$confirmedColumnName]) { + return $this->responder->error(ErrorCode::EMAIL_NOT_CONFIRMED, $username); + } if (!headers_sent()) { session_regenerate_id(true); } unset($user[$passwordColumnName]); + if ($confirmEmail) { + unset($user[$tokenColumnName]); + } $_SESSION['user'] = $user; return $this->responder->success($user); } @@ -148,6 +212,9 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface $users = $this->db->selectAll($table, $userColumns, $condition, $columnOrdering, 0, 1); foreach ($users as $user) { if (password_verify($password, $user[$passwordColumnName]) == 1) { + if ($confirmEmail && !$user[$confirmedColumnName]) { + return $this->responder->error(ErrorCode::EMAIL_NOT_CONFIRMED, $username); + } if (!headers_sent()) { session_regenerate_id(true); } @@ -180,6 +247,31 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface } return $this->responder->error(ErrorCode::AUTHENTICATION_REQUIRED, ''); } + if ($method == 'GET' && $path == 'confirm' && $confirmEmail) { + $tableName = $this->getProperty('usersTable', 'users'); + $table = $this->reflection->getTable($tableName); + $pkName = $table->getPk()->getName(); + $tokenColumnName = $this->getProperty('tokenColumn', 'token'); + $confirmedColumnName = $this->getProperty('confirmedColumn', 'confirmed'); + $passwordColumnName = $this->getProperty('passwordColumn', 'password'); + $userColumns = $table->getColumnNames(); + if (!in_array($pkName, $userColumns)) { + array_push($userColumns, $pkName); + } + $tokenColumn = $table->getColumn($tokenColumnName); + $confirmationToken = RequestUtils::getPathSegment($request, 2); + $tokenCondition = new ColumnCondition($tokenColumn, 'eq', $confirmationToken); + $columnOrdering = $this->ordering->getDefaultColumnOrdering($table); + $users = $this->db->selectAll($table, $userColumns, $tokenCondition, $columnOrdering, 0, 1); + foreach ($users as $user) { + $data = [$confirmedColumnName => 1]; + $this->db->updateSingle($table, $data, $user[$pkName]); + unset($user[$tokenColumnName]); + unset($user[$passwordColumnName]); + $user[$confirmedColumnName] = 1; + return $this->responder->success($user); + } + } if (!isset($_SESSION['user']) || !$_SESSION['user']) { $authenticationMode = $this->getProperty('mode', 'required'); if ($authenticationMode == 'required') { diff --git a/src/Tqdev/PhpCrudApi/Record/ErrorCode.php b/src/Tqdev/PhpCrudApi/Record/ErrorCode.php index 38f947fa..d2571a26 100644 --- a/src/Tqdev/PhpCrudApi/Record/ErrorCode.php +++ b/src/Tqdev/PhpCrudApi/Record/ErrorCode.php @@ -34,6 +34,7 @@ class ErrorCode const USER_ALREADY_EXIST = 1020; const PASSWORD_TOO_SHORT = 1021; const USERNAME_EMPTY = 1022; + const EMAIL_NOT_CONFIRMED = 1023; private $values = [ 0000 => ["Success", ResponseFactory::OK], @@ -60,6 +61,7 @@ class ErrorCode 1020 => ["User '%s' already exists", ResponseFactory::CONFLICT], 1021 => ["Password too short (<%d characters)", ResponseFactory::UNPROCESSABLE_ENTITY], 1022 => ["Username is empty or only whitespaces", ResponseFactory::UNPROCESSABLE_ENTITY], + 1023 => ["Email not confirmed for '%s'", ResponseFactory::FORBIDDEN], 9999 => ["%s", ResponseFactory::INTERNAL_SERVER_ERROR], ];