diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 3d7c1213..d64f26ca 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -247,6 +247,24 @@ public function getLogin() return $this->login_strategy->getLogin(); } + public function getProfileLink(LaravelRequest $request) + { + if (!$request->hasValidSignature()) { + abort(403); + } + $user_id = intval($request->query('user_id')); + $current = $this->auth_service->getCurrentUser(); + if ($current && $current->getId() !== $user_id) { + $this->auth_service->logout(); + } + if ($this->auth_service->getCurrentUser()) { + return Redirect::action("UserController@getProfile"); + } + $user = $this->auth_service->getUserById($user_id); + $hint = $user ? $user->getEmail() : null; + return Redirect::action("UserController@getLogin", $hint ? ['login_hint' => $hint] : []); + } + public function cancelLogin() { return $this->login_strategy->cancelLogin(); diff --git a/app/Mail/UserEmailVerificationRequest.php b/app/Mail/UserEmailVerificationRequest.php index aee09c36..5db5c991 100644 --- a/app/Mail/UserEmailVerificationRequest.php +++ b/app/Mail/UserEmailVerificationRequest.php @@ -47,7 +47,7 @@ final class UserEmailVerificationRequest extends Mailable /** * @var string */ - public $bio_link; + public $profile_link; /** * The subject of the message. @@ -66,7 +66,7 @@ public function __construct(User $user, string $verification_link) $this->verification_link = $verification_link; $this->user_email = $user->getEmail(); $this->user_fullname = $user->getFullName(); - $this->bio_link = URL::action("UserController@getLogin"); + $this->profile_link = URL::signedRoute('auth.profile-link', ['user_id' => $user->getId()]); } /** diff --git a/app/Mail/UserEmailVerificationSuccess.php b/app/Mail/UserEmailVerificationSuccess.php index e8e10993..7362ce91 100644 --- a/app/Mail/UserEmailVerificationSuccess.php +++ b/app/Mail/UserEmailVerificationSuccess.php @@ -17,6 +17,7 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\URL; /** * Class UserEmailVerificationSuccess @@ -61,6 +62,11 @@ class UserEmailVerificationSuccess extends Mailable */ public $user_is_complete; + /** + * @var string + */ + public $profile_link; + /** * UserEmailVerificationSuccess constructor. * @param User $user @@ -80,6 +86,7 @@ public function __construct !empty($user->getLastName()) && !empty($user->getCompany()) && !empty($user->getCountry()); + $this->profile_link = URL::signedRoute('auth.profile-link', ['user_id' => $user->getId()]); } /** diff --git a/app/Mail/WelcomeNewUserEmail.php b/app/Mail/WelcomeNewUserEmail.php index 7d255b04..56e584c1 100644 --- a/app/Mail/WelcomeNewUserEmail.php +++ b/app/Mail/WelcomeNewUserEmail.php @@ -42,7 +42,7 @@ class WelcomeNewUserEmail extends Mailable /** * @var string */ - public $bio_link; + public $profile_link; /** * @var string @@ -90,7 +90,7 @@ public function __construct { $this->user_email = $user->getEmail(); $this->user_fullname = $user->getFullName(); - $this->bio_link = URL::action("UserController@getLogin"); + $this->profile_link = URL::signedRoute('auth.profile-link', ['user_id' => $user->getId()]); $this->user_is_complete = !empty($user->getFirstName()) && !empty($user->getLastName()) && !empty($user->getCompany()) && diff --git a/resources/views/emails/auth/email_verification_request.blade.php b/resources/views/emails/auth/email_verification_request.blade.php index ba9eb222..a7953039 100644 --- a/resources/views/emails/auth/email_verification_request.blade.php +++ b/resources/views/emails/auth/email_verification_request.blade.php @@ -19,7 +19,7 @@
- To edit your profile just click here. You may update your photo, add a bio, and other information you wish to share. + To edit your profile just click here. You may update your photo, add a bio, and other information you wish to share.
diff --git a/resources/views/emails/auth/email_verification_request_success.blade.php b/resources/views/emails/auth/email_verification_request_success.blade.php index 4edcc50f..31459e51 100644 --- a/resources/views/emails/auth/email_verification_request_success.blade.php +++ b/resources/views/emails/auth/email_verification_request_success.blade.php @@ -19,7 +19,7 @@ @if(!$user_is_complete) -
You may enter your profile details here.
+
You may enter your profile details here.
@endif diff --git a/resources/views/emails/welcome_new_user_email.blade.php b/resources/views/emails/welcome_new_user_email.blade.php index d503556c..5cdf85ba 100644 --- a/resources/views/emails/welcome_new_user_email.blade.php +++ b/resources/views/emails/welcome_new_user_email.blade.php @@ -27,7 +27,7 @@
- To edit your profile just click here. + To edit your profile just click here. You may update your photo, add a bio, and other information you wish to share.
diff --git a/resources/views/emails/welcome_new_user_email_fn.blade.php b/resources/views/emails/welcome_new_user_email_fn.blade.php index 3e332ead..9ca81dd4 100644 --- a/resources/views/emails/welcome_new_user_email_fn.blade.php +++ b/resources/views/emails/welcome_new_user_email_fn.blade.php @@ -18,7 +18,7 @@ @if(!$user_is_complete) -
If you have not entered your first name, last name, company, and country please do so in your profile now. You may also update your photo, add a bio, and provide other information you wish to share.
+
If you have not entered your first name, last name, company, and country please do so in your profile now. You may also update your photo, add a bio, and provide other information you wish to share.
@endif diff --git a/routes/web.php b/routes/web.php index b49a3547..0a29b386 100644 --- a/routes/web.php +++ b/routes/web.php @@ -69,6 +69,8 @@ Route::post('', ['middleware' => 'csrf', 'uses' => 'Auth\EmailVerificationController@resend']); }); + Route::get('profile-link', 'UserController@getProfileLink')->name('auth.profile-link'); + // password reset routes Route::group(array('prefix' => 'password'), function () { diff --git a/start_local_server.sh b/start_local_server.sh index 0535f4b8..e59bcc1c 100755 --- a/start_local_server.sh +++ b/start_local_server.sh @@ -4,6 +4,7 @@ export DOCKER_SCAN_SUGGEST=false docker compose run --rm app composer install docker compose run --rm app php artisan doctrine:migrations:migrate --no-interaction +docker compose run --rm app php artisan route:clear docker compose run --rm app php artisan db:seed --force docker compose run --rm app php artisan idp:create-super-admin test@test.com 1Qaz2wsx! docker compose run --rm app yarn install diff --git a/tests/ProfileLinkSessionTest.php b/tests/ProfileLinkSessionTest.php new file mode 100644 index 00000000..7b7ec265 --- /dev/null +++ b/tests/ProfileLinkSessionTest.php @@ -0,0 +1,121 @@ +user_a = EntityManager::getRepository(User::class)->findOneBy(['identifier' => 'sebastian.marcet']); + $this->user_b = EntityManager::getRepository(User::class)->findOneBy(['identifier' => '2']); + } + + private function profileLinkFor(User $user): string + { + return URL::signedRoute('auth.profile-link', ['user_id' => $user->getId()]); + } + + /** + * A tampered or forged signature must be rejected immediately with 403. + * This prevents an attacker from crafting links for arbitrary user IDs. + */ + public function testTamperedSignatureReturns403(): void + { + $url = $this->profileLinkFor($this->user_a); + $tampered = preg_replace('/signature=[^&]+/', 'signature=invalid_forged_value', $url); + + $this->call('GET', $tampered); + + $this->assertResponseStatus(403); + } + + /** + * An unauthenticated visitor following a valid link is redirected to the + * login page pre-filled with the email address of the link's owner. + */ + public function testGuestFollowsLinkRedirectsToLoginWithHint(): void + { + $response = $this->call('GET', $this->profileLinkFor($this->user_a)); + + $this->assertResponseStatus(302); + $target = $response->getTargetUrl(); + $this->assertStringContainsString('login', $target); + $this->assertStringContainsString(urlencode($this->user_a->getEmail()), $target); + } + + /** + * The link's owner, already authenticated, is sent straight to their profile + * without having to log in again. + */ + public function testOwnerAlreadyLoggedInRedirectsToProfile(): void + { + $this->be($this->user_a); + + $response = $this->call('GET', $this->profileLinkFor($this->user_a)); + + $this->assertResponseStatus(302); + $this->assertStringContainsString('profile', $response->getTargetUrl()); + } + + /** + * Core regression: user B is logged in and follows user A's profile link. + * Must redirect to the login page (not to user B's profile), pre-filled + * with user A's email so the correct person can authenticate. + */ + public function testDifferentUserLoggedInRedirectsToLoginNotProfile(): void + { + $this->be($this->user_b); + + $response = $this->call('GET', $this->profileLinkFor($this->user_a)); + + $this->assertResponseStatus(302); + $target = $response->getTargetUrl(); + $this->assertStringNotContainsString('profile', $target); + $this->assertStringContainsString('login', $target); + // Hint must point to user A, not user B + $this->assertStringContainsString(urlencode($this->user_a->getEmail()), $target); + $this->assertStringNotContainsString(urlencode($this->user_b->getEmail()), $target); + } + + /** + * After user B follows user A's link, user B's session is invalidated. + * Auth::check() must return false — if it were true, user B's profile + * would still be accessible from the same browser. + */ + public function testDifferentUserSessionIsInvalidatedAfterFollowingLink(): void + { + $this->be($this->user_b); + + $this->call('GET', $this->profileLinkFor($this->user_a)); + + $this->assertFalse(Auth::check(), 'User B must be logged out after following user A\'s profile link'); + } +}