Skip to content

Commit

Permalink
Add email verification, 'email changed' email notifications to hub (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
emmachughes authored Jan 30, 2025
1 parent 6b00c0b commit add81ea
Show file tree
Hide file tree
Showing 20 changed files with 380 additions and 91 deletions.
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,6 +237,10 @@
'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.',
'attach-context-to-contents' => 'Attach context to contents',
'attach-context-to-contents-warning' => 'This adds a context to all contents',
'start-job' => 'Start job',
Expand Down
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>
Loading

0 comments on commit add81ea

Please sign in to comment.