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');
+ }
+}