BookStack/tests/Auth/Saml2Test.php

296 lines
23 KiB
PHP
Raw Normal View History

<?php namespace Tests;
use BookStack\Auth\Role;
use BookStack\Auth\User;
class Saml2Test extends TestCase
{
public function setUp(): void
{
parent::setUp();
// Set default config for SAML2
config()->set([
'auth.method' => 'saml2',
'auth.defaults.guard' => 'saml2',
'saml2.name' => 'SingleSignOn-Testing',
'saml2.email_attribute' => 'email',
'saml2.display_name_attributes' => ['first_name', 'last_name'],
'saml2.external_id_attribute' => 'uid',
'saml2.user_to_groups' => false,
'saml2.group_attribute' => 'user_groups',
'saml2.remove_from_groups' => false,
'saml2.onelogin_overrides' => null,
'saml2.onelogin.idp.entityId' => 'http://saml.local/saml2/idp/metadata.php',
'saml2.onelogin.idp.singleSignOnService.url' => 'http://saml.local/saml2/idp/SSOService.php',
'saml2.onelogin.idp.singleLogoutService.url' => 'http://saml.local/saml2/idp/SingleLogoutService.php',
'saml2.autoload_from_metadata' => false,
'saml2.onelogin.idp.x509cert' => $this->testCert,
'saml2.onelogin.debug' => false,
]);
}
public function test_metadata_endpoint_displays_xml_as_expected()
{
$req = $this->get('/saml2/metadata');
$req->assertHeader('Content-Type', 'text/xml; charset=UTF-8');
$req->assertSee('md:EntityDescriptor');
$req->assertSee(url('/saml2/acs'));
}
public function test_onelogin_overrides_functions_as_expected()
{
$json = '{"sp": {"assertionConsumerService": {"url": "https://example.com/super-cats"}}, "contactPerson": {"technical": {"givenName": "Barry Scott", "emailAddress": "barry@example.com"}}}';
config()->set(['saml2.onelogin_overrides' => $json]);
$req = $this->get('/saml2/metadata');
$req->assertSee('https://example.com/super-cats');
$req->assertSee('md:ContactPerson');
$req->assertSee('<md:GivenName>Barry Scott</md:GivenName>');
}
public function test_login_option_shows_on_login_page()
{
$req = $this->get('/login');
$req->assertSeeText('SingleSignOn-Testing');
$req->assertElementExists('form[action$="/saml2/login"][method=POST] button');
}
public function test_login()
{
$req = $this->post('/saml2/login');
$redirect = $req->headers->get('location');
$this->assertStringStartsWith('http://saml.local/saml2/idp/SSOService.php', $redirect, 'Login redirects to SSO location');
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', [
'email' => 'user@example.com',
'external_auth_id' => 'user',
'email_confirmed' => true,
'name' => 'Barry Scott'
]);
});
}
public function test_group_role_sync_on_login()
{
config()->set([
'saml2.onelogin.strict' => false,
'saml2.user_to_groups' => true,
'saml2.remove_from_groups' => false,
]);
$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();
$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()
{
config()->set([
'saml2.onelogin.strict' => false,
'saml2.user_to_groups' => true,
'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();
$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'));
});
}
public function test_logout_link_directs_to_saml_path()
{
config()->set([
'saml2.onelogin.strict' => false,
]);
$resp = $this->actingAs($this->getEditor())->get('/');
$resp->assertElementExists('a[href$="/saml2/logout"]');
$resp->assertElementContains('a[href$="/saml2/logout"]', 'Logout');
}
public function test_logout_sls_flow()
{
config()->set([
'saml2.onelogin.strict' => false,
]);
$handleLogoutResponse = function () {
$this->assertTrue($this->isAuthenticated());
$req = $this->get('/saml2/sls');
$req->assertRedirect('/');
$this->assertFalse($this->isAuthenticated());
};
$loginAndStartLogout = function () use ($handleLogoutResponse) {
$this->post('/saml2/acs');
$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);
}
public function test_logout_sls_flow_when_sls_not_configured()
{
config()->set([
'saml2.onelogin.strict' => false,
'saml2.onelogin.idp.singleLogoutService.url' => null,
]);
$loginAndStartLogout = function () {
$this->post('/saml2/acs');
$req = $this->get('/saml2/logout');
$req->assertRedirect('/');
$this->assertFalse($this->isAuthenticated());
};
$this->withPost(['SAMLResponse' => $this->acsPostData], $loginAndStartLogout);
}
public function test_dump_user_details_option_works()
{
config()->set([
'saml2.onelogin.strict' => false,
'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' => [],
]);
});
}
public function test_user_registration_with_existing_email()
{
config()->set([
'saml2.onelogin.strict' => false,
]);
$viewer = $this->getViewer();
$viewer->email = 'user@example.com';
$viewer->save();
$this->withPost(['SAMLResponse' => $this->acsPostData], function () {
$acsPost = $this->post('/saml2/acs');
$acsPost->assertRedirect('/');
$errorMessage = session()->get('error');
$this->assertEquals('Registration unsuccessful since a user already exists with email address "user@example.com"', $errorMessage);
});
}
public function test_saml_routes_are_only_active_if_saml_enabled()
{
config()->set(['auth.method' => 'standard']);
$getRoutes = ['/logout', '/metadata', '/sls'];
foreach ($getRoutes as $route) {
$req = $this->get('/saml2' . $route);
$req->assertRedirect('/');
$error = session()->get('error');
$this->assertStringStartsWith('You do not have permission to access', $error);
session()->flush();
}
$postRoutes = ['/login', '/acs'];
foreach ($postRoutes as $route) {
$req = $this->post('/saml2' . $route);
$req->assertRedirect('/');
$error = session()->get('error');
$this->assertStringStartsWith('You do not have permission to access', $error);
session()->flush();
}
}
protected function withGet(array $options, callable $callback)
{
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 = [];
foreach ($options as $key => $val) {
$original[$key] = $global[$key] ?? null;
$global[$key] = $val;
}
$callback();
foreach ($options as $key => $val) {
$val = $original[$key];
if ($val) {
$global[$key] = $val;
} else {
unset($global[$key]);
}
}
}
/**
* The post data for a callback for single-sign-in.
* Provides the following attributes:
* array:5 [
"uid" => array:1 [
0 => "user"
]
"first_name" => array:1 [
0 => "Barry"
]
"last_name" => array:1 [
0 => "Scott"
]
"email" => array:1 [
0 => "user@example.com"
]
"user_groups" => array:2 [
0 => "member"
1 => "admin"
]
]
*/
protected $acsPostData = 'PHNhbWxwOlJlc3BvbnNlIHhtbG5zOnNhbWxwPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6cHJvdG9jb2wiIHhtbG5zOnNhbWw9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphc3NlcnRpb24iIElEPSJfNGRkNDU2NGRjNzk0MDYxZWYxYmFhMDQ2N2Q3OTAyOGNlZDNjZTU0YmVlIiBWZXJzaW9uPSIyLjAiIElzc3VlSW5zdGFudD0iMjAxOS0xMS0xN1QxNzo1MzozOVoiIERlc3RpbmF0aW9uPSJodHRwOi8vYm9va3N0YWNrLmxvY2FsL3NhbWwyL2FjcyIgSW5SZXNwb25zZVRvPSJPTkVMT0dJTl82YTBmNGYzOTkzMDQwZjE5ODdmZDM3MDY4YjUyOTYyMjlhZDUzNjFjIj48c2FtbDpJc3N1ZXI+aHR0cDovL3NhbWwubG9jYWwvc2FtbDIvaWRwL21ldGFkYXRhLnBocDwvc2FtbDpJc3N1ZXI+PGRzOlNpZ25hdHVyZSB4bWxuczpkcz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnIyI+CiAgPGRzOlNpZ25lZEluZm8+PGRzOkNhbm9uaWNhbGl6YXRpb25NZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiLz4KICAgIDxkczpTaWduYXR1cmVNZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGRzaWctbW9yZSNyc2Etc2hhMjU2Ii8+CiAgPGRzOlJlZmVyZW5jZSBVUkk9IiNfNGRkNDU2NGRjNzk0MDYxZWYxYmFhMDQ2N2Q3OTAyOGNlZDNjZTU0YmVlIj48ZHM6VHJhbnNmb3Jtcz48ZHM6VHJhbnNmb3JtIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnI2VudmVsb3BlZC1zaWduYXR1cmUiLz48ZHM6VHJhbnNmb3JtIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8xMC94bWwtZXhjLWMxNG4jIi8+PC9kczpUcmFuc2Zvcm1zPjxkczpEaWdlc3RNZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGVuYyNzaGEyNTYiLz48ZHM6RGlnZXN0VmFsdWU+dm1oL1M3NU5mK2crZWNESkN6QWJaV0tKVmx1ZzdCZnNDKzlhV05lSXJlUT08L2RzOkRpZ2VzdFZhbHVlPjwvZHM6UmVmZXJlbmNlPjwvZHM6U2lnbmVkSW5mbz48ZHM6U2lnbmF0dXJlVmFsdWU+dnJhZ0tKWHNjVm5UNjJFaEk3bGk4MERUWHNOTGJOc3lwNWZ2QnU4WjFYSEtFUVA3QWpPNkcxcVBwaGpWQ2dRMzd6TldVVTZvUytQeFA3UDlHeG5xL3hKejRUT3lHcHJ5N1RoK2pIcHc0YWVzQTdrTmp6VU51UmU2c1ltWTlrRXh2VjMvTmJRZjROMlM2Y2RhRHIzWFRodllVVDcxYzQwNVVHOFJpQjJaY3liWHIxZU1yWCtXUDBnU2Qrc0F2RExqTjBJc3pVWlVUNThadFpEVE1ya1ZGL0pIbFBFQ04vVW1sYVBBeitTcUJ4c25xTndZK1oxYUt3MnlqeFRlNnUxM09Kb29OOVN1REowNE0rK2F3RlY3NkI4cXEyTzMxa3FBbDJibm1wTGxtTWdRNFEraUlnL3dCc09abTV1clphOWJObDNLVEhtTVBXbFpkbWhsLzgvMy9IT1RxN2thWGs3cnlWRHRLcFlsZ3FUajNhRUpuL0dwM2o4SFp5MUVialRiOTRRT1ZQMG5IQzB1V2hCaE13TjdzVjFrUSsxU2NjUlpUZXJKSGlSVUQvR0srTVg3M0YrbzJVTFRIL1Z6Tm9SM2o4N2hOLzZ1UC9JeG5aM1RudGR1MFZPZS9ucEdVWjBSMG9SWFhwa2JTL2poNWk1ZjU0RXN4eXZ1VEM5NHdKaEM8L2RzOlNpZ25hdHVyZVZhbHVlPgo8ZHM6S2V5SW5mbz48ZHM6WDUwOURhdGE+PGRzOlg1MDlDZXJ0aWZpY2F0ZT5NSUlFYXpDQ0F0T2dBd0lCQWdJVWU3YTA4OENucjRpem1ybkJFbng1cTNIVE12WXdEUVlKS29aSWh2Y05BUUVMQlFBd1JURUxNQWtHQTFVRUJoTUNSMEl4RXpBUkJnTlZCQWdNQ2xOdmJXVXRVM1JoZEdVeElUQWZCZ05WQkFvTUdFbHVkR1Z5Ym1WMElGZHBaR2RwZEhNZ1VIUjVJRXgwWkRBZUZ3MHhPVEV4TVRZeE1qRTNNVFZhRncweU9URXhNVFV4TWpFM01UVmFNRVV4Q3pBSkJnTlZCQVlUQWtkQ01STXdFUVlEVlFRSURBcFRiMjFsTFZOMFlYUmxNU0V3SHdZRFZRUUtEQmhKYm5SbGNtNWxkQ0JYYVdSbmFYUnpJRkIwZVNCTWRHUXdnZ0dpTUEwR0NTcUdTSWIzRFFFQkFRVUFBNElCandBd2dnR0tBb0lCZ1FEekxlOUZmZHlwbFR4SHA0U3VROWdRdFpUM3QrU0RmdkVMNzJwcENmRlp3NytCNXM1Qi9UNzNhWHBvUTNTNTNwR0kxUklXQ2dlMmlDVVEydHptMjdhU05IMGl1OWFKWWNVUVovUklUcWQwYXl5RGtzMU5BMlBUM1RXNnQzbTdLVjVyZTRQME5iK1lEZXV5SGRreitqY010cG44Q21Cb1QwSCtza2hhMGhpcUlOa2prUlBpSHZMSFZHcCt0SFVFQS9JNm1ONGFCL1VFeFNUTHM3OU5zTFVmdGVxcXhlOSt0dmRVYVRveURQcmhQRmpPTnMrOU5LQ2t6SUM2dmN2N0o2QXR1S0c2bkVUK3pCOXlPV2d0R1lRaWZYcVFBMnk1ZEw4MUJCMHE1dU1hQkxTMnBxM2FQUGp6VTJGMytFeXNqeVNXVG5Da2ZrN0M1U3NDWFJ1OFErVTk1dHVucE5md2Y1b2xFNldhczQ4Tk1NK1B3VjdpQ05NUGtOemxscTZQQ2lNK1A4RHJNU2N6elVaWlFVU3Y2ZFN3UENvK1lTVmltRU0wT2czWEpUaU5oUTVBTmxhSW42Nkt3NWdmb0JmdWlYbXlJS2lTRHlBaURZbUZhZjQzOTV3V3dMa1RSK2N3OFdmamFIc3dLWlRvbW4xTVIzT0pzWTJVSjBlUkJZTStZU3NDQXdFQUFhTlRNRkV3SFFZRFZSME9CQllFRkltcDJDWUNHZmNiN3c5MUgvY1NoVENrWHdSL01COEdBMVVkSXdRWU1CYUFGSW1wMkNZQ0dmY2I3dzkxSC9jU2hUQ2tYd1IvTUE4R0ExVWRFd0VCL3dRRk1BTUJBZjh3RFFZSktvWklodmNOQVFFTEJRQURnZ0dCQUErZy9DN3VMOWxuK1crcUJrbkxXODFrb2pZZmxnUEsxSTFNSEl3bk12bC9aVEhYNGRSWEtEcms3S2NVcTFLanFhak5WNjZmMWNha3AwM0lpakJpTzBYaTFnWFVaWUxvQ2lOR1V5eXA5WGxvaUl5OVh3MlBpV25ydzAreVp5dlZzc2JlaFhYWUpsNFJpaEJqQld1bDlSNHdNWUxPVVNKRGUyV3hjVUJoSm54eU5ScytQMHhMU1FYNkIybjZueG9Ea280cDA3czhaS1hRa2VpWjJpd0ZkVHh6UmtHanRoTVV2NzA0bnpzVkdCVDBEQ1B0ZlNhTzVLSlpXMXJDczN5aU10aG5CeHE0cUVET1FKRklsKy9MRDcxS2JCOXZaY1c1SnVhdnpCRm1rS0dOcm8vNkcxSTdlbDQ2SVI0d2lqVHlORkNZVXVEOWR0aWduTm1wV3ROOE9XK3B0aUwvanRUeVNXdWtqeXMwcyt2TG44M0NWdmpCMGRKdFZBSVlPZ1hGZ
protected $sloResponseData = 'fZHRa8IwEMb/lZJ3bdJa04a2MOYYglOY4sNe5JKms9gmpZfC/vxF3ZjC8OXgLvl938ddjtC1vVjZTzu6d429NaiDr641KC5PBRkHIyxgg8JAp1E4JbZPbysRTanoB+ussi25QR4TgKgH11hDguWiIIeawTxOaK1iPYt5XcczHUlJeVRlMklBJjOuM1qDVCTY6wE9WRAv5HHEUS8NOjDOjyjLJoxNGN+xVESpSNgHCRYaXWPAXaijc70IQ2ntyUPqNG2tgjY8Z45CbNFLmt8V7GxBNuuX1eZ1uT7EcZJKAE4TJhXPaMxlVlFffPKKJnXE5ryusoiU+VlMXJIN5Y/feXRn1VR92GkHFTiY9sc+D2+p/HqRrQM34n33bCsd7KEd9eMd4+W32I5KaUQSlleHP9Hwv6uX3w==';
protected $testCert = 'MIIEazCCAtOgAwIBAgIUe7a088Cnr4izmrnBEnx5q3HTMvYwDQYJKoZIhvcNAQELBQAwRTELMAkGA1UEBhMCR0IxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0xOTExMTYxMjE3MTVaFw0yOTExMTUxMjE3MTVaMEUxCzAJBgNVBAYTAkdCMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQDzLe9FfdyplTxHp4SuQ9gQtZT3t+SDfvEL72ppCfFZw7+B5s5B/T73aXpoQ3S53pGI1RIWCge2iCUQ2tzm27aSNH0iu9aJYcUQZ/RITqd0ayyDks1NA2PT3TW6t3m7KV5re4P0Nb+YDeuyHdkz+jcMtpn8CmBoT0H+skha0hiqINkjkRPiHvLHVGp+tHUEA/I6mN4aB/UExSTLs79NsLUfteqqxe9+tvdUaToyDPrhPFjONs+9NKCkzIC6vcv7J6AtuKG6nET+zB9yOWgtGYQifXqQA2y5dL81BB0q5uMaBLS2pq3aPPjzU2F3+EysjySWTnCkfk7C5SsCXRu8Q+U95tunpNfwf5olE6Was48NMM+PwV7iCNMPkNzllq6PCiM+P8DrMSczzUZZQUSv6dSwPCo+YSVimEM0Og3XJTiNhQ5ANlaIn66Kw5gfoBfuiXmyIKiSDyAiDYmFaf4395wWwLkTR+cw8WfjaHswKZTomn1MR3OJsY2UJ0eRBYM+YSsCAwEAAaNTMFEwHQYDVR0OBBYEFImp2CYCGfcb7w91H/cShTCkXwR/MB8GA1UdIwQYMBaAFImp2CYCGfcb7w91H/cShTCkXwR/MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggGBAA+g/C7uL9ln+W+qBknLW81kojYflgPK1I1MHIwnMvl/ZTHX4dRXKDrk7KcUq1KjqajNV66f1cakp03IijBiO0Xi1gXUZYLoCiNGUyyp9XloiIy9Xw2PiWnrw0+yZyvVssbehXXYJl4RihBjBWul9R4wMYLOUSJDe2WxcUBhJnxyNRs+P0xLSQX6B2n6nxoDko4p07s8ZKXQkeiZ2iwFdTxzRkGjthMUv704nzsVGBT0DCPtfSaO5KJZW1rCs3yiMthnBxq4qEDOQJFIl+/LD71KbB9vZcW5JuavzBFmkKGNro/6G1I7el46IR4wijTyNFCYUuD9dtignNmpWtN8OW+ptiL/jtTySWukjys0s+vLn83CVvjB0dJtVAIYOgXFdIuii66gczwwM/LGiOExJn0dTNzsJ/IYhpxL4FBEuP0pskY0o0aUlJ2LS2j+wSQTRKsBgMjyrUrekle2ODStStn3eabjIx0/FHlpFr0jNIm/oMP7kwjtUX4zaNe47QI4Gg==';
}