Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add email verification, 'email changed' email notifications to hub #2915

Merged
merged 3 commits into from
Jan 30, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions sourcecode/hub/app/Http/Controllers/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
use App\Http\Requests\StoreUserRequest;
use App\Http\Requests\UpdateUserRequest;
use App\Mail\ResetPasswordEmail;
use App\Mail\VerifyEmailAddress;
use App\Models\User;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
Expand All @@ -22,6 +24,8 @@

use function app;
use function assert;
use function redirect;
use function route;
use function to_route;
use function view;

Expand All @@ -41,6 +45,7 @@ public function store(StoreUserRequest $request): RedirectResponse
$user = new User();
$user->name = $request->validated('name');
$user->email = $request->validated('email');
$user->email_verified = false;
$user->password = Hash::make($request->validated('password'));
$user->save();

Expand Down Expand Up @@ -159,4 +164,35 @@ public function disconnectSocialAccounts(Request $request): RedirectResponse
->route('user.my-account')
->with('alert', trans('messages.alert-account-update'));
}

public function sendVerificationEmail(Mailer $mailer): RedirectResponse
{
$user = $this->getUser();

if ($user->email_verified) {
return redirect()->back();
}

$mailer->send(new VerifyEmailAddress($user));

return redirect()->back()->with('alert', trans('messages.verification-email-sent'));
}

public function verifyEmail(Request $request): RedirectResponse
{
$user = $this->getUser();

if (!$user->checkVerificationDetails(
$request->query->getString('hash'),
$request->query->getInt('time'),
)) {
abort(404);
}

$user->email_verified = true;
$user->save();

return redirect()->route('user.my-account')
->with('alert', trans('messages.your-account-has-been-verified'));
}
}
2 changes: 1 addition & 1 deletion sourcecode/hub/app/Listeners/AddAuthToLtiLaunch.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ private function addUserDetails(LaunchLti $event, User $user): void
->withClaim('lis_person_name_family', $familyName);
}

if ($tool->send_email) {
if ($tool->send_email && $user->email_verified) {
$launch = $launch
->withClaim('lis_person_contact_email_primary', $user->email);
}
Expand Down
52 changes: 52 additions & 0 deletions sourcecode/hub/app/Listeners/SendAccountEmails.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

namespace App\Listeners;

use App\Events\UserSaved;
use App\Mail\EmailChanged;
use App\Mail\VerifyEmailAddress;
use Illuminate\Contracts\Mail\Mailer;

final readonly class SendAccountEmails
{
public function __construct(private Mailer $mailer) {}

public function handleUserSaved(UserSaved $event): void
{
if ($event->user->email_verified) {
// nothing to do
return;
}

if ($event->user->wasRecentlyCreated) {
$this->sendEmailToNewAccount($event);
} elseif ($event->user->wasChanged('email')) {
$this->sendEmailsOnEmailChange($event);
}
}

private function sendEmailToNewAccount(UserSaved $event): void
{
// TODO: should be a welcome email
$this->mailer->send(new VerifyEmailAddress($event->user));
}

private function sendEmailsOnEmailChange(UserSaved $event): void
{
// do not send the notification if the previous email was unverified
if ($event->user->getOriginal('email_verified')) {
$this->mailer->send(
new EmailChanged(
oldEmail: $event->user->getOriginal('email'),
name: $event->user->getOriginal('name'),
),
);
}

// send a verification request to the new address
// TODO: should acknowledge the email change
$this->mailer->send(new VerifyEmailAddress($event->user));
}
}
34 changes: 34 additions & 0 deletions sourcecode/hub/app/Mail/EmailChanged.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace App\Mail;

use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;

final class EmailChanged extends Mailable
{
use Queueable;

public function __construct(
private readonly string $name,
private readonly string $oldEmail,
) {}

public function envelope(): Envelope
{
return new Envelope(
subject: trans('email-changed.subject'),
to: [new Address($this->oldEmail, $this->name)],
);
}

public function content(): Content
{
return new Content(html: 'emails.email-changed');
}
}
7 changes: 5 additions & 2 deletions sourcecode/hub/app/Mail/ResetPasswordEmail.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;

use function trans;

class ResetPasswordEmail extends Mailable
{
use Queueable;
Expand All @@ -17,7 +19,8 @@ public function __construct(public string $resetLink) {}

public function build(): Mailable
{
return $this->view('emails.reset-password')
->subject(trans('messages.reset-password'));
return $this
->subject(trans('messages.reset-password'))
->view('emails.reset-password');
}
}
38 changes: 38 additions & 0 deletions sourcecode/hub/app/Mail/VerifyEmailAddress.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace App\Mail;

use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

final class VerifyEmailAddress extends Mailable
{
use Queueable;
use SerializesModels;

public function __construct(
private readonly User $user,
) {}

public function envelope(): Envelope
{
return new Envelope(
subject: trans('verify-email.subject', ['site' => config('app.name')]),
to: [new Address($this->user->email, $this->user->name)],
);
}

public function content(): Content
{
return new Content(html: 'emails.verify-email', with: [
'verification_link' => $this->user->makeVerificationLink(),
]);
}
}
32 changes: 32 additions & 0 deletions sourcecode/hub/app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
use SensitiveParameter;

use function config;
use function hash_equals;
use function time;
use function url;

class User extends Model implements AuthenticatableContract
{
Expand All @@ -35,6 +38,7 @@ class User extends Model implements AuthenticatableContract
protected $casts = [
'admin' => 'boolean',
'debug_mode' => 'boolean',
'email_verified' => 'boolean',
];

protected $fillable = [
Expand Down Expand Up @@ -67,8 +71,21 @@ class User extends Model implements AuthenticatableContract
'locale' => 'en',
'admin' => false,
'debug_mode' => false,
// If false, emails are sent upon creating the user. Aside from the
// sign-up page, this is probably undesirable, so having it true is the
// safe default.
'email_verified' => true,
];

public function setEmailAttribute(string|null $email): void
{
$this->attributes['email'] = $email;

if ($this->exists && $email !== $this->getOriginal('email')) {
$this->attributes['email_verified'] = false;
}
}

public function getApiKey(): string
{
return $this->id;
Expand Down Expand Up @@ -98,6 +115,21 @@ public function getApiAuthorization(): string
base64_encode($this->getApiKey() . ':' . $this->getApiSecret());
}

public function checkVerificationDetails(string $hash, int $time): bool
{
return hash_equals(hash('sha256', $time . '@' . $this->email), $hash);
}

public function makeVerificationLink(): string
{
$time = time();

return url()->temporarySignedRoute('user.verify-email', 3600, [
'hash' => hash('sha256', $time . '@' . $this->email),
'time' => time(),
]);
}

public function getAuthIdentifierName(): string
{
return 'email';
Expand Down
8 changes: 6 additions & 2 deletions sourcecode/hub/database/factories/UserFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public function definition(): array
return [
'name' => $name,
'email' => $email,
'email_verified' => true,
'password' => Hash::make($this->faker->password),
'admin' => false,
'locale' => 'en',
Expand All @@ -34,9 +35,12 @@ public function name(string $name): static
return $this->state(['name' => $name]);
}

public function withEmail(string $email): static
public function withEmail(string $email, bool $verified = true): static
{
return $this->state(['email' => $email]);
return $this->state([
'email' => $email,
'email_verified' => $verified,
]);
}

public function withPasswordResetToken(): static
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\PostgresConnection;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->boolean('email_verified')->default(false);
});

if (DB::connection() instanceof PostgresConnection) {
DB::update('UPDATE users SET email_verified = true WHERE admin');
} else {
DB::update('UPDATE users SET email_verified = 1 WHERE admin = 1');
}
}

public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('email_verified');
});
}
};
7 changes: 7 additions & 0 deletions sourcecode/hub/lang/en/email-changed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

return [
'subject' => 'Email address changed',
'summary' => 'Someone (hopefully you) changed the email address on your :site account.',
'unrecognised-action' => "If you didn't make this change, please get in touch with the :site administrator.",
];
9 changes: 6 additions & 3 deletions sourcecode/hub/lang/en/messages.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,9 @@
'reset-password' => 'Reset password',
'submit' => 'Submit',
'alert-password-reset' => "You should soon receive a password reset link. If you don't receive one, check that your email was spelt correctly. Contact support if you still don't receive an email",
'reset-password-email-message' => 'You have requested to reset your password',
'reset-password-email-summary' => 'A password reset has been requested for your account.',
'reset-password-email-action' => 'To reset your password, click the following link:',
'reset-password-email-note' => 'If you did not request a password reset, no further action is required',
'reset-password-email-ignore' => 'If you ignore this message, your password will not be changed',
'reset-password-email-unknown' => 'If you did not request a password reset, no further action is required.',
'reset-password-email-thanks' => 'Thank you for using :site',
'alert-password-reset-success' => 'Password has been reset successfully',
'alert-password-reset-invalid-token' => 'Invalid or expired password reset token',
Expand Down Expand Up @@ -238,4 +237,8 @@
'context-added' => 'The context was added.',
'theme-edlib' => 'Edlib light',
'theme-dark' => 'Edlib dark',
'unverified-email-notice' => 'Your email address is unverified. Some functionality will be unavailable.',
'verify-my-email' => 'Verify my email',
'verification-email-sent' => 'A verification email has been sent to the address associated with your account.',
'your-account-has-been-verified' => 'Your account has been verified.',
];
6 changes: 6 additions & 0 deletions sourcecode/hub/lang/en/verify-email.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?php

return [
'subject' => 'Verify your :site account',
'summary' => 'To verify your :site account, click the link below. If you aren\'t already logged in, you must do so for verification to take effect.',
];
17 changes: 17 additions & 0 deletions sourcecode/hub/resources/views/components/email-layout.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{{--
Before changing this, note that some commonly used email clients are stuck in
the stone ages (as of 2025), and don't support the HTML5 doctype or the
<style> element.
--}}
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<title>{{ $title }}</title>
</head>

<body style="font-family: helvetica, arial, sans-serif; line-height: 1.5; max-width: 600px">
{{ $slot }}
</body>
</html>
7 changes: 7 additions & 0 deletions sourcecode/hub/resources/views/emails/email-changed.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<x-email-layout>
<x-slot:title>{{ trans('email-changed.subject') }}</x-slot:title>

<p>{{ trans('email-changed.summary', ['site' => config('app.name')]) }}</p>

<p>{{ trans('email-changed.unrecognised-action', ['site' => config('app.name')]) }}</p>
</x-email-layout>
Loading
Loading