diff --git a/sourcecode/hub/app/Http/Controllers/UserController.php b/sourcecode/hub/app/Http/Controllers/UserController.php index 7c44189919..75a67a0529 100644 --- a/sourcecode/hub/app/Http/Controllers/UserController.php +++ b/sourcecode/hub/app/Http/Controllers/UserController.php @@ -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; @@ -22,6 +24,8 @@ use function app; use function assert; +use function redirect; +use function route; use function to_route; use function view; @@ -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(); @@ -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')); + } } diff --git a/sourcecode/hub/app/Listeners/AddAuthToLtiLaunch.php b/sourcecode/hub/app/Listeners/AddAuthToLtiLaunch.php index c9d9573eb6..f30fe11775 100644 --- a/sourcecode/hub/app/Listeners/AddAuthToLtiLaunch.php +++ b/sourcecode/hub/app/Listeners/AddAuthToLtiLaunch.php @@ -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); } diff --git a/sourcecode/hub/app/Listeners/SendAccountEmails.php b/sourcecode/hub/app/Listeners/SendAccountEmails.php new file mode 100644 index 0000000000..3ec2b804f7 --- /dev/null +++ b/sourcecode/hub/app/Listeners/SendAccountEmails.php @@ -0,0 +1,52 @@ +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)); + } +} diff --git a/sourcecode/hub/app/Mail/EmailChanged.php b/sourcecode/hub/app/Mail/EmailChanged.php new file mode 100644 index 0000000000..c0902b1a33 --- /dev/null +++ b/sourcecode/hub/app/Mail/EmailChanged.php @@ -0,0 +1,34 @@ +oldEmail, $this->name)], + ); + } + + public function content(): Content + { + return new Content(html: 'emails.email-changed'); + } +} diff --git a/sourcecode/hub/app/Mail/ResetPasswordEmail.php b/sourcecode/hub/app/Mail/ResetPasswordEmail.php index cac5fb236b..a8ff277571 100644 --- a/sourcecode/hub/app/Mail/ResetPasswordEmail.php +++ b/sourcecode/hub/app/Mail/ResetPasswordEmail.php @@ -8,6 +8,8 @@ use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; +use function trans; + class ResetPasswordEmail extends Mailable { use Queueable; @@ -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'); } } diff --git a/sourcecode/hub/app/Mail/VerifyEmailAddress.php b/sourcecode/hub/app/Mail/VerifyEmailAddress.php new file mode 100644 index 0000000000..99c532b69f --- /dev/null +++ b/sourcecode/hub/app/Mail/VerifyEmailAddress.php @@ -0,0 +1,38 @@ + 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(), + ]); + } +} diff --git a/sourcecode/hub/app/Models/User.php b/sourcecode/hub/app/Models/User.php index 5ad9cf0846..df31e1f1a9 100644 --- a/sourcecode/hub/app/Models/User.php +++ b/sourcecode/hub/app/Models/User.php @@ -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 { @@ -35,6 +38,7 @@ class User extends Model implements AuthenticatableContract protected $casts = [ 'admin' => 'boolean', 'debug_mode' => 'boolean', + 'email_verified' => 'boolean', ]; protected $fillable = [ @@ -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; @@ -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'; diff --git a/sourcecode/hub/database/factories/UserFactory.php b/sourcecode/hub/database/factories/UserFactory.php index b12984b5f9..af94511392 100644 --- a/sourcecode/hub/database/factories/UserFactory.php +++ b/sourcecode/hub/database/factories/UserFactory.php @@ -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', @@ -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 diff --git a/sourcecode/hub/database/migrations/2025_01_16_080000_add_email_verified_column.php b/sourcecode/hub/database/migrations/2025_01_16_080000_add_email_verified_column.php new file mode 100644 index 0000000000..42500932cc --- /dev/null +++ b/sourcecode/hub/database/migrations/2025_01_16_080000_add_email_verified_column.php @@ -0,0 +1,31 @@ +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'); + }); + } +}; diff --git a/sourcecode/hub/lang/en/email-changed.php b/sourcecode/hub/lang/en/email-changed.php new file mode 100644 index 0000000000..292d58021c --- /dev/null +++ b/sourcecode/hub/lang/en/email-changed.php @@ -0,0 +1,7 @@ + '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.", +]; diff --git a/sourcecode/hub/lang/en/messages.php b/sourcecode/hub/lang/en/messages.php index c08c80c3d1..e0d0d9a8c7 100644 --- a/sourcecode/hub/lang/en/messages.php +++ b/sourcecode/hub/lang/en/messages.php @@ -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', @@ -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', diff --git a/sourcecode/hub/lang/en/verify-email.php b/sourcecode/hub/lang/en/verify-email.php new file mode 100644 index 0000000000..9a0f64896b --- /dev/null +++ b/sourcecode/hub/lang/en/verify-email.php @@ -0,0 +1,6 @@ + '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.', +]; diff --git a/sourcecode/hub/resources/views/components/email-layout.blade.php b/sourcecode/hub/resources/views/components/email-layout.blade.php new file mode 100644 index 0000000000..7fc0512b0a --- /dev/null +++ b/sourcecode/hub/resources/views/components/email-layout.blade.php @@ -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 + - -
-{{ trans('messages.reset-password-email-message') }}
+{{ trans('messages.reset-password-email-summary') }}
{{ trans('messages.reset-password-email-action') }} {{ trans('messages.reset-password') }}
-{{ trans('messages.reset-password-email-note') }}
-{{ trans('messages.reset-password-email-ignore') }}
+{{ trans('messages.reset-password-email-unknown') }}
{{ trans('messages.reset-password-email-thanks', ['site' => config('app.name')]) }}
-