Skip to content

Commit

Permalink
Merge pull request #54 from daniel-de-wit/feature/signed-email-verifi…
Browse files Browse the repository at this point in the history
…cation-url

Support signed email verification urls
  • Loading branch information
wimski committed Jun 21, 2021
2 parents 35b004f + 9215f2c commit f7374a5
Show file tree
Hide file tree
Showing 15 changed files with 918 additions and 43 deletions.
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ mutation Register {

When registering a user in combination with the `MustVerifyEmail` contract you can optionally define the url for email verification.
Both `__ID__` and `__HASH__` will be replaced with the proper values.
When `use_signed_email_verification_url` is enabled in the configuration, the placeholders `__EXPIRES__` and `__SIGNATURE__` will be replaced.

```graphql
mutation Register {
Expand All @@ -180,6 +181,7 @@ mutation Register {
password_confirmation: "secret"
verification_url: {
url: "https://my-front-end.com/verify-email?id=__ID__&token=__HASH__"
# Signed: url: "https://my-front-end.com/verify-email?id=__ID__&token=__HASH__&expires=__EXPIRES__&signature=__SIGNATURE__"
}
}) {
token
Expand All @@ -202,6 +204,22 @@ mutation VerifyEmail {
}
```

When `use_signed_email_verification_url` is enabled in the configuration, the input requires two additional fields.

```graphql
mutation VerifyEmail {
verifyEmail(input: {
id: "1"
hash: "af269947ed80d4a7bc3f78a6dfd05ec369373f9d"
expires: 1619775828
signature: "e923636f1093c414aab39f846e9d7a372beefa7b628b28179197e539c56aa0f0"
}) {
name
email
}
}
```

### Forgot Password

Sends a reset password notification.
Expand Down
15 changes: 15 additions & 0 deletions config/lighthouse-sanctum.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,19 @@
|
*/
'provider' => 'users',

/*
|--------------------------------------------------------------------------
| Use signed email verification URL
|--------------------------------------------------------------------------
|
| Whether or not to sign the email verification URL
| like the standard Laravel implementation does.
| If set to `true`, additional `expires` and `signature` parameters
| will be added to the URL. When verifying the email through the API
| both those fields are required as well.
| It defaults to `false` for backwards compatibility.
|
*/
'use_signed_email_verification_url' => false,
];
7 changes: 7 additions & 0 deletions graphql/sanctum.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ enum EmailVerificationStatus {
input VerifyEmailInput {
id: ID!
hash: String!
expires: Int
signature: String
}

input RegisterInput {
Expand All @@ -63,6 +65,11 @@ The url used to verify the email address.
Use __ID__ and __HASH__ to inject values.
e.g; `https://my-front-end.com/verify-email?id=__ID__&hash=__HASH__`
If the API uses signed email verification urls
you must also use __EXPIRES__ and __SIGNATURE__
e.g; `https://my-front-end.com/verify-email?id=__ID__&hash=__HASH__&expires=__EXPIRES__&signature=__SIGNATURE__`
"""
input VerificationUrlInput {
url: String! @rules(apply: ["url"])
Expand Down
16 changes: 15 additions & 1 deletion src/Contracts/Services/EmailVerificationServiceInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

namespace DanielDeWit\LighthouseSanctum\Contracts\Services;

use Illuminate\Auth\AuthenticationException;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Nuwave\Lighthouse\Exceptions\AuthenticationException;

interface EmailVerificationServiceInterface
{
Expand All @@ -17,4 +17,18 @@ public function setVerificationUrl(string $url): void;
* @throws AuthenticationException
*/
public function verify(MustVerifyEmail $user, string $hash): void;

/**
* @param MustVerifyEmail $user
* @param string $hash
* @param int $expires
* @param string $signature
* @throws AuthenticationException
*/
public function verifySigned(MustVerifyEmail $user, string $hash, int $expires, string $signature): void;

/**
* @throws AuthenticationException
*/
public function throwAuthenticationException(): void;
}
20 changes: 20 additions & 0 deletions src/Contracts/Services/SignatureServiceInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace DanielDeWit\LighthouseSanctum\Contracts\Services;

interface SignatureServiceInterface
{
/**
* @param array<string, mixed> $params
* @return string
*/
public function generate(array $params): string;

/**
* @param array<string, mixed> $params
* @param string $signature
*/
public function verify(array $params, string $signature): void;
}
44 changes: 40 additions & 4 deletions src/GraphQL/Mutations/VerifyEmail.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@
use DanielDeWit\LighthouseSanctum\Contracts\Services\EmailVerificationServiceInterface;
use DanielDeWit\LighthouseSanctum\Traits\CreatesUserProvider;
use Exception;
use GraphQL\Type\Definition\ResolveInfo;
use Illuminate\Auth\AuthManager;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Contracts\Config\Repository as Config;
use Nuwave\Lighthouse\Exceptions\AuthenticationException;
use Illuminate\Contracts\Validation\Factory as ValidationFactory;
use Nuwave\Lighthouse\Exceptions\ValidationException;
use Nuwave\Lighthouse\Support\Contracts\GraphQLContext;
use RuntimeException;

class VerifyEmail
Expand All @@ -19,39 +22,55 @@ class VerifyEmail

protected AuthManager $authManager;
protected Config $config;
protected ValidationFactory $validationFactory;
protected EmailVerificationServiceInterface $emailVerificationService;

public function __construct(
AuthManager $authManager,
Config $config,
ValidationFactory $validationFactory,
EmailVerificationServiceInterface $emailVerificationService
) {
$this->authManager = $authManager;
$this->config = $config;
$this->validationFactory = $validationFactory;
$this->emailVerificationService = $emailVerificationService;
}

/**
* @param mixed $_
* @param array<string, string|int> $args
* @param GraphQLContext $context
* @param ResolveInfo $resolveInfo
* @return array<string, string>
* @throws Exception
*/
public function __invoke($_, array $args): array
public function __invoke($_, array $args, GraphQLContext $context, ResolveInfo $resolveInfo): array
{
$userProvider = $this->createUserProvider();

$user = $userProvider->retrieveById($args['id']);

if (! $user) {
throw new AuthenticationException('The provided id and hash are incorrect.');
$this->emailVerificationService->throwAuthenticationException();
}

if (! $user instanceof MustVerifyEmail) {
throw new RuntimeException('User must implement "' . MustVerifyEmail::class . '".');
}

$this->emailVerificationService->verify($user, (string) $args['hash']);
if ($this->config->get('lighthouse-sanctum.use_signed_email_verification_url') === true) {
$this->validateRequiredSignedArguments($args, implode('.', $resolveInfo->path));

$this->emailVerificationService->verifySigned(
$user,
(string) $args['hash'],
(int) $args['expires'],
(string) $args['signature'],
);
} else {
$this->emailVerificationService->verify($user, (string) $args['hash']);
}

$user->markEmailAsVerified();

Expand All @@ -60,6 +79,23 @@ public function __invoke($_, array $args): array
];
}

/**
* @param array<string, string|int> $args
* @param string $path
* @throws ValidationException
*/
protected function validateRequiredSignedArguments(array $args, string $path): void
{
$validator = $this->validationFactory->make($args, [
'expires' => ['required'],
'signature' => ['required'],
]);

if ($validator->fails()) {
throw new ValidationException("Validation failed for the field [$path].", $validator);
}
}

protected function getAuthManager(): AuthManager
{
return $this->authManager;
Expand Down
22 changes: 21 additions & 1 deletion src/Providers/LighthouseSanctumServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,36 @@

use DanielDeWit\LighthouseSanctum\Contracts\Services\EmailVerificationServiceInterface;
use DanielDeWit\LighthouseSanctum\Contracts\Services\ResetPasswordServiceInterface;
use DanielDeWit\LighthouseSanctum\Contracts\Services\SignatureServiceInterface;
use DanielDeWit\LighthouseSanctum\Services\EmailVerificationService;
use DanielDeWit\LighthouseSanctum\Services\ResetPasswordService;
use DanielDeWit\LighthouseSanctum\Services\SignatureService;
use Illuminate\Contracts\Config\Repository as Config;
use Illuminate\Contracts\Container\Container;
use Illuminate\Support\ServiceProvider;

class LighthouseSanctumServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(EmailVerificationServiceInterface::class, EmailVerificationService::class);
$this->app->singleton(ResetPasswordServiceInterface::class, ResetPasswordService::class);

$this->app->singleton(SignatureServiceInterface::class, function (Container $container) {
/** @var Config $config */
$config = $container->make(Config::class);

return new SignatureService($config->get('app.key'));
});

$this->app->singleton(EmailVerificationServiceInterface::class, function (Container $container) {
/** @var Config $config */
$config = $container->make(Config::class);

return new EmailVerificationService(
$container->make(SignatureServiceInterface::class),
$config->get('auth.verification.expire', 60),
);
});
}

public function boot(): void
Expand Down
97 changes: 91 additions & 6 deletions src/Services/EmailVerificationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,38 @@

namespace DanielDeWit\LighthouseSanctum\Services;

use Carbon\Carbon;
use DanielDeWit\LighthouseSanctum\Contracts\Services\EmailVerificationServiceInterface;
use DanielDeWit\LighthouseSanctum\Contracts\Services\SignatureServiceInterface;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Routing\Exceptions\InvalidSignatureException;
use Nuwave\Lighthouse\Exceptions\AuthenticationException;
use RuntimeException;

class EmailVerificationService implements EmailVerificationServiceInterface
{
protected SignatureServiceInterface $signatureService;
protected int $expiresIn;

public function __construct(SignatureServiceInterface $signatureService, int $expiresIn)
{
$this->signatureService = $signatureService;
$this->expiresIn = $expiresIn;
}

public function setVerificationUrl(string $url): void
{
VerifyEmail::createUrlUsing(function ($notifiable) use ($url) {
VerifyEmail::createUrlUsing(function ($user) use ($url) {
$parameters = $this->createUrlParameters($user);

return str_replace([
'__ID__',
'__HASH__',
], [
$notifiable->getKey(),
$this->createHash($notifiable),
], $url);
'__EXPIRES__',
'__SIGNATURE__',
], $parameters, $url);
});
}

Expand All @@ -32,12 +47,82 @@ public function setVerificationUrl(string $url): void
public function verify(MustVerifyEmail $user, string $hash): void
{
if (! hash_equals($hash, $this->createHash($user))) {
throw new AuthenticationException('The provided id and hash are incorrect.');
$this->throwAuthenticationException();
}
}

/**
* @param MustVerifyEmail $user
* @param string $hash
* @param int $expires
* @param string $signature
* @throws AuthenticationException
*/
public function verifySigned(MustVerifyEmail $user, string $hash, int $expires, string $signature): void
{
$this->verify($user, $hash);

if ($expires < Carbon::now()->getTimestamp()) {
$this->throwAuthenticationException();
}

try {
$this->signatureService->verify([
'id' => $this->getModelFromUser($user)->getKey(),
'hash' => $hash,
'expires' => $expires,
], $signature);
} catch (InvalidSignatureException $exception) {
$this->throwAuthenticationException();
}
}

/**
* @throws AuthenticationException
*/
public function throwAuthenticationException(): void
{
throw new AuthenticationException('The provided input is incorrect.');
}

/**
* @param MustVerifyEmail $user
* @return mixed[]
*/
protected function createUrlParameters(MustVerifyEmail $user): array
{
$parameters = [
'id' => $this->getModelFromUser($user)->getKey(),
'hash' => $this->createHash($user),
'expires' => $this->createExpires(),
];

$signature = $this->signatureService->generate($parameters);

$values = array_values($parameters);
$values[] = $signature;

return $values;
}

protected function createHash(MustVerifyEmail $user): string
{
return sha1($user->getEmailForVerification());
}

protected function createExpires(): int
{
return Carbon::now()
->addMinutes($this->expiresIn)
->getTimestamp();
}

protected function getModelFromUser(MustVerifyEmail $user): Model
{
if (! $user instanceof Model) {
throw new RuntimeException('The class implementing "' . MustVerifyEmail::class . '" must extend "' . Model::class . '".');
}

return $user;
}
}
Loading

0 comments on commit f7374a5

Please sign in to comment.