Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions app/Http/Controllers/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions app/Mail/UserEmailVerificationRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ final class UserEmailVerificationRequest extends Mailable
/**
* @var string
*/
public $bio_link;
public $profile_link;

/**
* The subject of the message.
Expand All @@ -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()]);
}

/**
Expand Down
7 changes: 7 additions & 0 deletions app/Mail/UserEmailVerificationSuccess.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -61,6 +62,11 @@ class UserEmailVerificationSuccess extends Mailable
*/
public $user_is_complete;

/**
* @var string
*/
public $profile_link;

/**
* UserEmailVerificationSuccess constructor.
* @param User $user
Expand All @@ -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()]);
}

/**
Expand Down
4 changes: 2 additions & 2 deletions app/Mail/WelcomeNewUserEmail.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class WelcomeNewUserEmail extends Mailable
/**
* @var string
*/
public $bio_link;
public $profile_link;

/**
* @var string
Expand Down Expand Up @@ -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()]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add expiration to the signed profile link for security.

The signed URL is generated without an expiration time, which means it remains valid indefinitely. If the email is forwarded, leaked, or the user's mailbox is compromised at any point in the future, this link could be used for unauthorized profile access.

Laravel's URL::signedRoute() accepts an optional third parameter for expiration. Consider adding a reasonable lifetime (e.g., 7-30 days) to limit the window of exposure.

🔒 Proposed fix to add expiration
-        $this->profile_link = URL::signedRoute('auth.profile-link', ['user_id' => $user->getId()]);
+        $this->profile_link = URL::signedRoute('auth.profile-link', ['user_id' => $user->getId()], now()->addDays(30));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$this->profile_link = URL::signedRoute('auth.profile-link', ['user_id' => $user->getId()]);
$this->profile_link = URL::signedRoute('auth.profile-link', ['user_id' => $user->getId()], now()->addDays(30));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/Mail/WelcomeNewUserEmail.php` at line 93, The signed profile link in
WelcomeNewUserEmail is generated without an expiration at the
$this->profile_link assignment using URL::signedRoute('auth.profile-link',
['user_id' => $user->getId()]); add an expiration argument (e.g., a Carbon
timestamp like Carbon::now()->addDays(14)) as the third parameter to
URL::signedRoute to limit validity (choose 7–30 days per policy) and add the
Carbon import if missing; ensure the route verification logic still accepts
signed URLs with an expiration.

$this->user_is_complete = !empty($user->getFirstName()) &&
!empty($user->getLastName()) &&
!empty($user->getCompany()) &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;padding-right:16px;padding-left:25px;word-break:break-word;">
<div style="font-family:open Sans Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:center;color:#000000;">
To edit your profile just click <a href="{!! $bio_link !!}" target="_blank">here</a>. You may update your photo, add a bio, and other information you wish to share.
To edit your profile just click <a href="{!! $profile_link !!}" target="_blank">here</a>. You may update your photo, add a bio, and other information you wish to share.
</div>
</td>
</tr>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
@if(!$user_is_complete)
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;padding-right:16px;padding-left:25px;word-break:break-word;">
<div style="font-family:open Sans Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:center;color:#000000;">You may enter your profile details <a href="{!! URL::action("UserController@getLogin") !!}" target="_blank">here</a>.</div>
<div style="font-family:open Sans Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:center;color:#000000;">You may enter your profile details <a href="{!! $profile_link !!}" target="_blank">here</a>.</div>
</td>
</tr>
@endif
Expand Down
2 changes: 1 addition & 1 deletion resources/views/emails/welcome_new_user_email.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;padding-right:16px;padding-left:25px;word-break:break-word;">
<div style="font-family:open Sans Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:center;color:#000000;">
To edit your profile just click <a href="{!! URL::action("UserController@getLogin") !!}">here</a>.
To edit your profile just click <a href="{!! $profile_link !!}">here</a>.
You may update your photo, add a bio, and other information you wish to share.
</div>
</td>
Expand Down
2 changes: 1 addition & 1 deletion resources/views/emails/welcome_new_user_email_fn.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
@if(!$user_is_complete)
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;padding-right:16px;padding-left:25px;word-break:break-word;">
<div style="font-family:open Sans Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:center;color:#000000;">If you have not entered your first name, last name, company, and country please <a href="{!! URL::action("UserController@getLogin") !!}" target="_blank">do so in your profile now</a>. You may also update your photo, add a bio, and provide other information you wish to share.</div>
<div style="font-family:open Sans Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:center;color:#000000;">If you have not entered your first name, last name, company, and country please <a href="{!! $profile_link !!}" target="_blank">do so in your profile now</a>. You may also update your photo, add a bio, and provide other information you wish to share.</div>
</td>
</tr>
@endif
Expand Down
2 changes: 2 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down
1 change: 1 addition & 0 deletions start_local_server.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
121 changes: 121 additions & 0 deletions tests/ProfileLinkSessionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php namespace Tests;
/**
* Copyright 2026 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

use Auth\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Facades\URL;
use LaravelDoctrine\ORM\Facades\EntityManager;

/**
* Class ProfileLinkSessionTest
*
* Regression tests for the session-overlap bug: clicking a profile link from
* an email while a different user is already logged in must NOT land on the
* currently-authenticated user's profile.
*/
final class ProfileLinkSessionTest extends BrowserKitTestCase
{
private User $user_a;
private User $user_b;

protected function prepareForTests(): void
{
parent::prepareForTests();
Session::start();
$this->user_a = EntityManager::getRepository(User::class)->findOneBy(['identifier' => 'sebastian.marcet']);
$this->user_b = EntityManager::getRepository(User::class)->findOneBy(['identifier' => '2']);
Comment on lines +37 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add explicit fixture existence assertions before typed-property assignment (Line 37, Line 38).

findOneBy(...) may return null; assigning that to User-typed properties can fail with a low-signal TypeError. Add explicit assertions so failures are actionable.

Suggested patch
-        $this->user_a = EntityManager::getRepository(User::class)->findOneBy(['identifier' => 'sebastian.marcet']);
-        $this->user_b = EntityManager::getRepository(User::class)->findOneBy(['identifier' => '2']);
+        $userA = EntityManager::getRepository(User::class)->findOneBy(['identifier' => 'sebastian.marcet']);
+        $userB = EntityManager::getRepository(User::class)->findOneBy(['identifier' => '2']);
+
+        $this->assertNotNull($userA, 'Expected fixture user "sebastian.marcet" to exist');
+        $this->assertNotNull($userB, 'Expected fixture user with identifier "2" to exist');
+
+        $this->user_a = $userA;
+        $this->user_b = $userB;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$this->user_a = EntityManager::getRepository(User::class)->findOneBy(['identifier' => 'sebastian.marcet']);
$this->user_b = EntityManager::getRepository(User::class)->findOneBy(['identifier' => '2']);
$userA = EntityManager::getRepository(User::class)->findOneBy(['identifier' => 'sebastian.marcet']);
$userB = EntityManager::getRepository(User::class)->findOneBy(['identifier' => '2']);
$this->assertNotNull($userA, 'Expected fixture user "sebastian.marcet" to exist');
$this->assertNotNull($userB, 'Expected fixture user with identifier "2" to exist');
$this->user_a = $userA;
$this->user_b = $userB;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/ProfileLinkSessionTest.php` around lines 37 - 38, The test assigns
results of EntityManager::getRepository(User::class)->findOneBy(...) directly to
the typed properties $this->user_a and $this->user_b which can be null; add
explicit assertions (e.g. assertNotNull or similar) immediately after each
findOneBy call to verify the fixture exists before assigning to the User-typed
properties, and keep the subsequent assignment only after the assertion so
failures are clear and actionable.

}

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