use BookStack\Auth\Access\Saml2Service;
use BookStack\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Cache;
+use Str;
class Saml2Controller extends Controller
{
}
/**
- * Assertion Consumer Service.
- * Processes the SAML response from the IDP.
+ * Assertion Consumer Service start URL. Takes the SAMLResponse from the IDP.
+ * Due to being an external POST request, we likely won't have context of the
+ * current user session due to lax cookies. To work around this we store the
+ * SAMLResponse data and redirect to the processAcs endpoint for the actual
+ * processing of the request with proper context of the user session.
*/
- public function acs()
+ public function startAcs(Request $request)
{
- $requestId = session()->pull('saml2_request_id', null);
+ // Note: This is a bit of a hack to prevent a session being stored
+ // on the response of this request. Within Laravel7+ this could instead
+ // be done via removing the StartSession middleware from the route.
+ config()->set('session.driver', 'array');
- $user = $this->samlService->processAcsResponse($requestId);
- if ($user === null) {
+ $samlResponse = $request->get('SAMLResponse', null);
+
+ if (empty($samlResponse)) {
+ $this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')]));
+ return redirect('/login');
+ }
+
+ $acsId = Str::random(16);
+ $cacheKey = 'saml2_acs:' . $acsId;
+ cache()->set($cacheKey, encrypt($samlResponse), 10);
+
+ return redirect()->guest('/saml2/acs?id=' . $acsId);
+ }
+
+ /**
+ * Assertion Consumer Service process endpoint.
+ * Processes the SAML response from the IDP with context of the current session.
+ * Takes the SAML request from the cache, added by the startAcs method above.
+ */
+ public function processAcs(Request $request)
+ {
+ $acsId = $request->get('id', null);
+ $cacheKey = 'saml2_acs:' . $acsId;
+ $samlResponse = null;
+ try {
+ $samlResponse = decrypt(cache()->pull($cacheKey));
+ } catch (\Exception $exception) {}
+ $requestId = session()->pull('saml2_request_id', 'unset');
+
+ if (empty($acsId) || empty($samlResponse)) {
$this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')]));
+ return redirect('/login');
+ }
+ $user = $this->samlService->processAcsResponse($requestId, $samlResponse);
+ if (is_null($user)) {
+ $this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')]));
return redirect('/login');
}
config()->set(['saml2.onelogin.strict' => false]);
$this->assertFalse($this->isAuthenticated());
- $this->withPost(['SAMLResponse' => $this->acsPostData], function () {
- $acsPost = $this->post('/saml2/acs');
- $acsPost->assertRedirect('/');
- $this->assertTrue($this->isAuthenticated());
- $this->assertDatabaseHas('users', [
- 'external_auth_id' => 'user',
- 'email_confirmed' => false,
- 'name' => 'Barry Scott',
- ]);
- });
+ $acsPost = $this->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+ $redirect = $acsPost->headers->get('Location');
+ $acsId = explode('?id=', $redirect)[1];
+ $this->assertTrue(strlen($acsId) > 12);
+
+ $this->assertStringContainsString('/saml2/acs?id=', $redirect);
+ $this->assertTrue(cache()->has('saml2_acs:' . $acsId));
+
+ $acsGet = $this->get($redirect);
+ $acsGet->assertRedirect('/');
+ $this->assertFalse(cache()->has('saml2_acs:' . $acsId));
+
+ $this->assertTrue($this->isAuthenticated());
+ $this->assertDatabaseHas('users', [
+ 'external_auth_id' => 'user',
+ 'email_confirmed' => false,
+ 'name' => 'Barry Scott',
+ ]);
+ }
+
+ public function test_acs_process_id_randomly_generated()
+ {
+ $acsPost = $this->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+ $redirectA = $acsPost->headers->get('Location');
+
+ $acsPost = $this->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+ $redirectB = $acsPost->headers->get('Location');
+
+ $this->assertFalse($redirectA === $redirectB);
+ }
+
+ public function test_process_acs_endpoint_cant_be_called_with_invalid_id()
+ {
+ $resp = $this->get('/saml2/acs');
+ $resp->assertRedirect('/login');
+ $this->followRedirects($resp)->assertSeeText('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
+
+ $resp = $this->get('/saml2/acs?id=abc123');
+ $resp->assertRedirect('/login');
+ $this->followRedirects($resp)->assertSeeText('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
}
public function test_group_role_sync_on_login()
$memberRole = factory(Role::class)->create(['external_auth_id' => 'member']);
$adminRole = Role::getSystemRole('admin');
- $this->withPost(['SAMLResponse' => $this->acsPostData], function () use ($memberRole, $adminRole) {
- $acsPost = $this->post('/saml2/acs');
- $user = User::query()->where('external_auth_id', '=', 'user')->first();
+ $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+ $user = User::query()->where('external_auth_id', '=', 'user')->first();
- $userRoleIds = $user->roles()->pluck('id');
- $this->assertContains($memberRole->id, $userRoleIds, 'User was assigned to member role');
- $this->assertContains($adminRole->id, $userRoleIds, 'User was assigned to admin role');
- });
+ $userRoleIds = $user->roles()->pluck('id');
+ $this->assertContains($memberRole->id, $userRoleIds, 'User was assigned to member role');
+ $this->assertContains($adminRole->id, $userRoleIds, 'User was assigned to admin role');
}
public function test_group_role_sync_removal_option_works_as_expected()
'saml2.remove_from_groups' => true,
]);
- $this->withPost(['SAMLResponse' => $this->acsPostData], function () {
- $acsPost = $this->post('/saml2/acs');
- $user = User::query()->where('external_auth_id', '=', 'user')->first();
+ $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+ $user = User::query()->where('external_auth_id', '=', 'user')->first();
- $randomRole = factory(Role::class)->create(['external_auth_id' => 'random']);
- $user->attachRole($randomRole);
- $this->assertContains($randomRole->id, $user->roles()->pluck('id'));
+ $randomRole = factory(Role::class)->create(['external_auth_id' => 'random']);
+ $user->attachRole($randomRole);
+ $this->assertContains($randomRole->id, $user->roles()->pluck('id'));
- auth()->logout();
- $acsPost = $this->post('/saml2/acs');
- $this->assertNotContains($randomRole->id, $user->roles()->pluck('id'));
- });
+ auth()->logout();
+ $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+ $this->assertNotContains($randomRole->id, $user->roles()->pluck('id'));
}
public function test_logout_link_directs_to_saml_path()
$this->assertFalse($this->isAuthenticated());
};
- $loginAndStartLogout = function () use ($handleLogoutResponse) {
- $this->post('/saml2/acs');
+ $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
- $req = $this->get('/saml2/logout');
- $redirect = $req->headers->get('location');
- $this->assertStringStartsWith('http://saml.local/saml2/idp/SingleLogoutService.php', $redirect);
- $this->withGet(['SAMLResponse' => $this->sloResponseData], $handleLogoutResponse);
- };
-
- $this->withPost(['SAMLResponse' => $this->acsPostData], $loginAndStartLogout);
+ $req = $this->get('/saml2/logout');
+ $redirect = $req->headers->get('location');
+ $this->assertStringStartsWith('http://saml.local/saml2/idp/SingleLogoutService.php', $redirect);
+ $this->withGet(['SAMLResponse' => $this->sloResponseData], $handleLogoutResponse);
}
public function test_logout_sls_flow_when_sls_not_configured()
'saml2.onelogin.idp.singleLogoutService.url' => null,
]);
- $loginAndStartLogout = function () {
- $this->post('/saml2/acs');
+ $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+ $this->assertTrue($this->isAuthenticated());
- $req = $this->get('/saml2/logout');
- $req->assertRedirect('/');
- $this->assertFalse($this->isAuthenticated());
- };
-
- $this->withPost(['SAMLResponse' => $this->acsPostData], $loginAndStartLogout);
+ $req = $this->get('/saml2/logout');
+ $req->assertRedirect('/');
+ $this->assertFalse($this->isAuthenticated());
}
public function test_dump_user_details_option_works()
'saml2.dump_user_details' => true,
]);
- $this->withPost(['SAMLResponse' => $this->acsPostData], function () {
- $acsPost = $this->post('/saml2/acs');
- $acsPost->assertJsonStructure([
- 'id_from_idp',
- 'attrs_from_idp' => [],
- 'attrs_after_parsing' => [],
- ]);
- });
+ $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+ $acsPost->assertJsonStructure([
+ 'id_from_idp',
+ 'attrs_from_idp' => [],
+ 'attrs_after_parsing' => [],
+ ]);
}
public function test_saml_routes_are_only_active_if_saml_enabled()
'saml2.onelogin.strict' => false,
]);
- $this->withPost(['SAMLResponse' => $this->acsPostData], function () {
- $acsPost = $this->post('/saml2/acs');
- $acsPost->assertRedirect('/login');
- $errorMessage = session()->get('error');
- $this->assertStringContainsString('That email domain does not have access to this application', $errorMessage);
- });
+ $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+ $acsPost->assertSeeText('That email domain does not have access to this application');
+ $this->assertFalse(auth()->check());
}
public function test_group_sync_functions_when_email_confirmation_required()
$memberRole = factory(Role::class)->create(['external_auth_id' => 'member']);
$adminRole = Role::getSystemRole('admin');
- $this->withPost(['SAMLResponse' => $this->acsPostData], function () use ($memberRole, $adminRole) {
- $acsPost = $this->followingRedirects()->post('/saml2/acs');
+ $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
- $this->assertEquals('http://localhost/register/confirm', url()->current());
- $acsPost->assertSee('Please check your email and click the confirmation button to access BookStack.');
- /** @var User $user */
- $user = User::query()->where('external_auth_id', '=', 'user')->first();
+ $this->assertEquals('http://localhost/register/confirm', url()->current());
+ $acsPost->assertSee('Please check your email and click the confirmation button to access BookStack.');
+ /** @var User $user */
+ $user = User::query()->where('external_auth_id', '=', 'user')->first();
- $userRoleIds = $user->roles()->pluck('id');
- $this->assertContains($memberRole->id, $userRoleIds, 'User was assigned to member role');
- $this->assertContains($adminRole->id, $userRoleIds, 'User was assigned to admin role');
- $this->assertFalse(boolval($user->email_confirmed), 'User email remains unconfirmed');
- });
+ $userRoleIds = $user->roles()->pluck('id');
+ $this->assertContains($memberRole->id, $userRoleIds, 'User was assigned to member role');
+ $this->assertContains($adminRole->id, $userRoleIds, 'User was assigned to admin role');
+ $this->assertFalse(boolval($user->email_confirmed), 'User email remains unconfirmed');
$this->assertNull(auth()->user());
$homeGet = $this->get('/');
'name' => 'Barry Scott',
]);
- $this->withPost(['SAMLResponse' => $this->acsPostData], function () {
- $acsPost = $this->post('/saml2/acs');
- $acsPost->assertRedirect('/login');
- $this->assertFalse($this->isAuthenticated());
- $this->assertDatabaseHas('users', [
- 'external_auth_id' => 'old_system_user_id',
- ]);
-
- $loginGet = $this->get('/login');
- $loginGet->assertSee('A user with the email
[email protected] already exists but with different credentials');
- });
+ $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+ $this->assertFalse($this->isAuthenticated());
+ $this->assertDatabaseHas('users', [
+ 'external_auth_id' => 'old_system_user_id',
+ ]);
+
+ $acsPost->assertSee('A user with the email
[email protected] already exists but with different credentials');
}
public function test_login_request_contains_expected_default_authncontext()
return $this->withGlobal($_GET, $options, $callback);
}
- protected function withPost(array $options, callable $callback)
- {
- return $this->withGlobal($_POST, $options, $callback);
- }
-
protected function withGlobal(array &$global, array $options, callable $callback)
{
$original = [];