diff --git a/database/migrations/2023_07_14_150736_add_verified_email_column.php b/database/migrations/2023_07_14_150736_add_verified_email_column.php
new file mode 100644
index 00000000..b4b60d11
--- /dev/null
+++ b/database/migrations/2023_07_14_150736_add_verified_email_column.php
@@ -0,0 +1,28 @@
+timestamp('email_verified_at')->nullable()->default(null);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('cms_user', function (Blueprint $table) {
+ $table->dropColumn('email_verified_at');
+ });
+ }
+};
diff --git a/lang/en/auth.php b/lang/en/auth.php
index 6598e2c0..c0a5b903 100644
--- a/lang/en/auth.php
+++ b/lang/en/auth.php
@@ -16,5 +16,12 @@
'failed' => 'These credentials do not match our records.',
'password' => 'The provided password is incorrect.',
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
-
+ 'verify_email' => 'Please verify your email address.',
+ 'verify_email_header' => 'Login to',
+ 'verify_email_link' => 'Click the button to verify your email.',
+ 'verify_email_button' => 'Verify email',
+ 'verify_email_resend' => 'Resend verification email',
+ 'verify_email_link_sent' => 'A fresh verification link has been sent to your email address.',
+ 'verify_wrong_email' => 'Click here if this message was not send by you.',
+ 'block_account_message' => 'The account has been blocked.',
];
diff --git a/lang/nl/auth.php b/lang/nl/auth.php
index ca7623b6..bd9a1839 100644
--- a/lang/nl/auth.php
+++ b/lang/nl/auth.php
@@ -15,5 +15,12 @@
'failed' => 'Deze combinatie van e-mailadres en wachtwoord is niet geldig.',
'throttle' => 'Te veel mislukte loginpogingen. Probeer het over :seconds seconden nogmaals.',
'password' => 'Het opgegeven wachtwoord is onjuist.',
-
+ 'verify_email' => 'Verifieer uw e-mailadres.',
+ 'verify_email_header' => 'Inloggen bij',
+ 'verify_email_button' => 'Bevestig e-mailadres',
+ 'verify_email_link' => 'Klik op onderstaande link om je e-mailadres te bevestigen.',
+ 'verify_email_resend' => 'Verificatiemail opnieuw verzenden',
+ 'verify_email_link_sent' => 'Er is een nieuwe verificatielink naar jouw e-mailadres verzonden.',
+ 'verify_wrong_email' => 'Klik hier als dit bericht niet van jou komt.',
+ 'block_account_message' => 'Account succesvol geblokkeerd.',
];
diff --git a/resources/views/emails/verify-email.blade.php b/resources/views/emails/verify-email.blade.php
new file mode 100644
index 00000000..381df735
--- /dev/null
+++ b/resources/views/emails/verify-email.blade.php
@@ -0,0 +1,15 @@
+{{-- blade-formatter-disable --}}
+
+{{ config('app.name') }}
+
+[{{__('siteboss::auth.verify_wrong_email')}}
]({{$blockUrl}})
+
Dit is gegaan doormiddel van de blokkeer link in de verificatie email. +
+ +@endcomponent \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index 6c380f83..ec160faa 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,6 +2,9 @@ use Illuminate\Support\Facades\Route; use NotFound\Framework\Http\Controllers\AboutController; +use NotFound\Framework\Http\Controllers\Auth\EmailVerificationNotificationController; +use NotFound\Framework\Http\Controllers\Auth\EmailVerificationPromptController; +use NotFound\Framework\Http\Controllers\Auth\VerifyEmailController; use NotFound\Framework\Http\Controllers\ContentBlocks\ContentBlockController; use NotFound\Framework\Http\Controllers\Forms\DataController; use NotFound\Framework\Http\Controllers\Forms\DownloadController; @@ -23,10 +26,17 @@ | is assigned the "api" middleware group. Enjoy building your API! | */ - Route::prefix(config('siteboss.api_prefix'))->group(function () { - // Unauthenticated routes Route::prefix('api')->group(function () { + Route::get('email/verify', [EmailVerificationPromptController::class, '__invoke']) + ->middleware('auth') + ->name('verification.notice'); + + Route::get('email/verify/{id}/{hash}', [VerifyEmailController::class, '__invoke']) + ->middleware(['signed', 'throttle:6,1']) + ->name('verification.verify'); + + // Unauthenticated routes Route::namespace('Forms')->group(function () { Route::post('forms/{form:id}/{langurl}', [DataController::class, 'create'])->middleware(ProtectAgainstSpam::class)->name('formbuilder.post'); Route::get('fields/{id}', [FieldController::class, 'readOneJson']); @@ -35,13 +45,17 @@ }); }); + Route::post('{locale}/email/verification-notification', [EmailVerificationNotificationController::class, '__invoke']) + ->middleware(['throttle:6,1', 'auth', 'set-forget-locale']) + ->name('verification.send'); + Route::get('{locale}/oidc', [InfoController::class, 'oidc'])->where('name', '[A-Za-z]{2}'); // Settings for the login page Route::get('settings', [InfoController::class, 'settings']); // Authenticated routes - Route::group(['middleware' => ['auth:openid', 'api']], function () { + Route::group(['middleware' => ['auth:openid', 'api', 'verified']], function () { // Language for messages (not the language used for storing data) Route::group(['prefix' => '/{locale}', 'middleware' => 'set-forget-locale'], function () { Route::get('info', [InfoController::class, 'index']); diff --git a/src/Auth/Middleware/EnsureEmailIsVerified.php b/src/Auth/Middleware/EnsureEmailIsVerified.php new file mode 100644 index 00000000..923cf976 --- /dev/null +++ b/src/Auth/Middleware/EnsureEmailIsVerified.php @@ -0,0 +1,44 @@ +user() || + ($request->user() instanceof MustVerifyEmail && + ! $request->user()->hasVerifiedEmail())) { + return [ + 'result' => 'error', + 'message' => __('siteboss::auth.verify_email'), + 'buttonText' => __('siteboss::auth.verify_email_resend'), + 'link' => route('verification.send', ['locale' => app()->getLocale()]), + ]; + } + + return $next($request); + } +} diff --git a/src/FrameworkServiceProvider.php b/src/FrameworkServiceProvider.php index feafe3af..72266caa 100644 --- a/src/FrameworkServiceProvider.php +++ b/src/FrameworkServiceProvider.php @@ -2,8 +2,12 @@ namespace NotFound\Framework; +use Illuminate\Auth\Notifications\VerifyEmail; +use Illuminate\Notifications\Messages\MailMessage; +use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Blade; use Illuminate\Support\ServiceProvider; +use NotFound\Framework\Models\Lang; use NotFound\Framework\View\Components\Forms\Form; class FrameworkServiceProvider extends ServiceProvider @@ -29,6 +33,18 @@ public function boot(): void __DIR__.'/Providers/AuthServiceProvider.php' => app_path('Providers/AuthServiceProvider.php'), __DIR__.'/../database/seeders/DatabaseSeeder.php' => database_path('seeders/DatabaseSeeder.php'), ], 'siteboss-framework'); + + VerifyEmail::toMailUsing(function (object $notifiable, string $url) { + + // todo: get value from users current lang; + App::setLocale(Lang::current()->url); + + $blockUrl = $url.'&block=1'; + + return (new MailMessage) + ->subject(__('siteboss::auth.verify_email_button').' '.config('app.name')) + ->markdown('siteboss::emails.verify-email', ['url' => $url, 'blockUrl' => $blockUrl]); + }); } public function register(): void diff --git a/src/Http/Controllers/Auth/EmailVerificationNotificationController.php b/src/Http/Controllers/Auth/EmailVerificationNotificationController.php new file mode 100644 index 00000000..a737f63f --- /dev/null +++ b/src/Http/Controllers/Auth/EmailVerificationNotificationController.php @@ -0,0 +1,16 @@ +user()->sendEmailVerificationNotification(); + + return ['status' => 'ok', 'message' => __('siteboss::auth.verify_email_link_sent')]; + } +} diff --git a/src/Http/Controllers/Auth/EmailVerificationPromptController.php b/src/Http/Controllers/Auth/EmailVerificationPromptController.php new file mode 100644 index 00000000..1ce28277 --- /dev/null +++ b/src/Http/Controllers/Auth/EmailVerificationPromptController.php @@ -0,0 +1,22 @@ +user()->hasVerifiedEmail() + ? redirect()->intended(RouteServiceProvider::HOME) + : view('siteboss::auth.verify-email'); + } +} diff --git a/src/Http/Controllers/Auth/VerifyEmailController.php b/src/Http/Controllers/Auth/VerifyEmailController.php new file mode 100644 index 00000000..d263e74b --- /dev/null +++ b/src/Http/Controllers/Auth/VerifyEmailController.php @@ -0,0 +1,48 @@ +route('id')); + + if ($request->query('block')) { + $user->enabled = 0; + $user->email_verified_at = null; + $user->save(); + + Mail::to(env('SB_ADMIN_EMAIL'))->send(new AccountBlocked($user)); + + return ['status' => 'ok', 'message' => __('siteboss::auth.block_account_message')]; + } + + if (! $user) { + throw new AuthorizationException; + } + + if (! hash_equals((string) $request->route('hash'), sha1($user->getEmailForVerification())) || ! $user->enabled) { + throw new AuthorizationException; + } + + if ($user->markEmailAsVerified()) { + event(new Verified($user)); + } + + return redirect('/siteboss')->with('verified', true); + } +} diff --git a/src/Http/Controllers/InfoController.php b/src/Http/Controllers/InfoController.php index eba75480..d46319d3 100644 --- a/src/Http/Controllers/InfoController.php +++ b/src/Http/Controllers/InfoController.php @@ -117,12 +117,14 @@ private function menu() if ($menuitem->level !== 0) { $lastKey = array_key_last($orderedMenu); - if (! $orderedMenu[$lastKey]->submenu) { - $orderedMenu[$lastKey]->submenu = []; - } + if ($lastKey !== null) { + if (! $orderedMenu[$lastKey]->submenu) { + $orderedMenu[$lastKey]->submenu = []; + } - $orderedMenu[$lastKey]->path = ''; - $orderedMenu[$lastKey]->submenu[] = $menuObj; + $orderedMenu[$lastKey]->path = ''; + $orderedMenu[$lastKey]->submenu[] = $menuObj; + } } else { $orderedMenu[] = $menuObj; } diff --git a/src/Mail/Admin/AccountBlocked.php b/src/Mail/Admin/AccountBlocked.php new file mode 100644 index 00000000..34dfd70f --- /dev/null +++ b/src/Mail/Admin/AccountBlocked.php @@ -0,0 +1,33 @@ +markdown('siteboss::mail.admin.account-blocked') + ->subject('CMS: Account geblokkeerd') + ->with([ + 'user' => $this->user, + ]); + } +} diff --git a/src/Models/CmsUser.php b/src/Models/CmsUser.php index fc05b4aa..5ba2b61f 100644 --- a/src/Models/CmsUser.php +++ b/src/Models/CmsUser.php @@ -2,6 +2,7 @@ namespace NotFound\Framework\Models; +use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User; @@ -58,7 +59,7 @@ * * @mixin \Eloquent */ -class CmsUser extends User +class CmsUser extends User implements MustVerifyEmail { use HasFactory, Notifiable, SoftDeletes; @@ -90,6 +91,7 @@ class CmsUser extends User protected $casts = [ 'properties' => 'object', 'preferences' => 'object', + 'email_verified_at' => 'datetime', ]; public function groups() @@ -210,4 +212,9 @@ public function hasLocalRole(string $role): bool return $roles->contains($role); } + + public function getEmailVerifiedAttribute(): bool + { + return $this->email_verified_at ? true : false; + } } diff --git a/src/Providers/Auth/OpenIDUserProvider.php b/src/Providers/Auth/OpenIDUserProvider.php index feb4fb00..bc355c64 100644 --- a/src/Providers/Auth/OpenIDUserProvider.php +++ b/src/Providers/Auth/OpenIDUserProvider.php @@ -69,6 +69,16 @@ public function retrieveByToken($identifier, $token) $model = $this->retrieveById($identifier); // Sub exists in the database if ($model) { + $emailInToken = $this->getEmailFromToken($token); + + if ($model->email !== $emailInToken) { + $model->email = $emailInToken; + $model->email_verified_at = null; + $model->save(); + + $model->sendEmailVerificationNotification(); + } + return $model; } @@ -78,7 +88,7 @@ public function retrieveByToken($identifier, $token) $model = $this->retrieveByEmail($emailInToken); if ($model) { - if ($model->enabled !== 1) { + if ($model->enabled !== 1 && $model->sub !== null) { return; } $model->sub = $identifier; @@ -92,14 +102,14 @@ public function retrieveByToken($identifier, $token) // Create user $user = new $this->model(); - if (config('openid.create_user_with_email')) { - $user->email = $this->getEmailFromToken($token); - } + $user->email = $this->getEmailFromToken($token); $user->sub = $token->sub; $user->enabled = 1; $user->save(); + $user->sendEmailVerificationNotification(); + return $user; } diff --git a/src/Services/Auth/AbstractTokenDecoder.php b/src/Services/Auth/AbstractTokenDecoder.php index 3e6076a3..2443babb 100644 --- a/src/Services/Auth/AbstractTokenDecoder.php +++ b/src/Services/Auth/AbstractTokenDecoder.php @@ -15,14 +15,6 @@ abstract class AbstractTokenDecoder public function __construct($token) { $this->token = $token; - $this->tokenParts = explode('.', $token); - - // Ensure there's exactly 3 parts in the token - if (count($this->tokenParts) !== 3) { - throw new \Exception('Invalid token format.'); - - return false; - } $this->getOpenIdConfiguration(); diff --git a/src/Services/Auth/LocalTokenDecoder.php b/src/Services/Auth/LocalTokenDecoder.php index 9c19cdae..d7abb773 100644 --- a/src/Services/Auth/LocalTokenDecoder.php +++ b/src/Services/Auth/LocalTokenDecoder.php @@ -14,6 +14,10 @@ class LocalTokenDecoder extends AbstractTokenDecoder protected function decodeToken(): void { + if (count(explode('.', $this->token)) !== 3) { + throw new \Exception('Invalid token format.'); + } + $keys = $this->parseJwtVerificationKeys(); $this->decodedToken = JWT::decode($this->token, $keys); @@ -25,7 +29,7 @@ protected function decodeToken(): void protected function verifyToken(): void { // Validate the ID token claims: - if ($this->decodedToken->iss != config('openid.issuer')) { + if (! in_array($this->decodedToken->iss, explode(',', config('openid.issuer')))) { throw OpenIDException::invalidIssuer($this->decodedToken->iss, config('openid.issuer')); } diff --git a/src/Services/Auth/RemoteTokenDecoder.php b/src/Services/Auth/RemoteTokenDecoder.php index 49288141..326d9d5f 100644 --- a/src/Services/Auth/RemoteTokenDecoder.php +++ b/src/Services/Auth/RemoteTokenDecoder.php @@ -2,7 +2,6 @@ namespace NotFound\Framework\Services\Auth; -use Firebase\JWT\JWT; use Illuminate\Support\Facades\Http; use NotFound\Framework\Exceptions\OpenID\OpenIDException; @@ -12,19 +11,28 @@ class RemoteTokenDecoder extends AbstractTokenDecoder protected function decodeToken(): void { - $this->decodedToken = json_decode(JWT::urlsafeB64Decode($this->tokenParts[1])); + $this->decodedToken = $this->getDecodedToken(); } protected function verifyToken(): void { - // Validate the ID token claims: - if ($this->decodedToken->iss != config('openid.issuer')) { - throw OpenIDException::invalidIssuer($this->decodedToken->iss, config('openid.issuer')); + $idToken = $_COOKIE['auth__id_token_oidc']; + $decoder = new LocalTokenDecoder($idToken); + $decodedIdToken = $decoder->getDecodedToken(); + + // Check if tokens exist + if (! $decodedIdToken || ! $this->decodedToken) { + throw OpenIDException::invalidVerification($this->decodedToken->email, config('openid.client_id')); //TODO change exception + } + + // Validate idToken has same sub and email as token + if ($this->decodedToken->sub != $decodedIdToken->sub && $this->decodedToken->email != $decodedIdToken->email) { + throw OpenIDException::invalidVerification($this->decodedToken->email, config('openid.client_id')); //TODO change exception } // Validate the AppId claims: - if ($this->decodedToken->appid != config('openid.client_id')) { - throw OpenIDException::invalidVerification($this->decodedToken->appid, config('openid.client_id')); + if ($decodedIdToken->aud != config('openid.client_id')) { + throw OpenIDException::invalidVerification($this->decodedToken->aud, config('openid.client_id')); } }