Skip to content

Commit

Permalink
Merge 46935a5 into 59fc548
Browse files Browse the repository at this point in the history
  • Loading branch information
wimski committed May 3, 2021
2 parents 59fc548 + 46935a5 commit e6ba68f
Show file tree
Hide file tree
Showing 11 changed files with 485 additions and 46 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
17 changes: 16 additions & 1 deletion src/Contracts/Services/EmailVerificationServiceInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@

namespace DanielDeWit\LighthouseSanctum\Contracts\Services;

use Illuminate\Auth\AuthenticationException;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Model;
use Nuwave\Lighthouse\Exceptions\AuthenticationException;

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

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

/**
* @throws AuthenticationException
*/
public function throwAuthenticationException(): 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
89 changes: 83 additions & 6 deletions src/Services/EmailVerificationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,34 @@

namespace DanielDeWit\LighthouseSanctum\Services;

use Carbon\Carbon;
use DanielDeWit\LighthouseSanctum\Contracts\Services\EmailVerificationServiceInterface;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Contracts\Config\Repository as Config;
use Illuminate\Database\Eloquent\Model;
use Nuwave\Lighthouse\Exceptions\AuthenticationException;

class EmailVerificationService implements EmailVerificationServiceInterface
{
protected Config $config;

public function __construct(Config $config)
{
$this->config = $config;
}

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 +43,78 @@ 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|Model $user
* @param string $hash
* @param int $expires
* @param string $signature
* @throws AuthenticationException
*/
public function verifySigned($user, string $hash, int $expires, string $signature): void
{
$this->verify($user, $hash);

$controlSignature = $this->createSignature([
'id' => $user->getKey(),
'hash' => $hash,
'expires' => $expires,
]);

if (! hash_equals($controlSignature, $signature)) {
$this->throwAuthenticationException();
}
}

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

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

$signature = $this->createSignature($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->config->get('auth.verification.expire', 60))
->getTimestamp();
}

/**
* @param array<string, int|string> $values
* @return string
*/
protected function createSignature(array $values): string
{
return hash_hmac('sha256', serialize($values), $this->config->get('app.key'));
}
}

0 comments on commit e6ba68f

Please sign in to comment.