Merge pull request #3616 from BookStackApp/oidc_group_sync
Added OIDC group sync functionality
This commit is contained in:
		
						commit
						401c156687
					
				| 
						 | 
					@ -263,7 +263,11 @@ OIDC_ISSUER_DISCOVER=false
 | 
				
			||||||
OIDC_PUBLIC_KEY=null
 | 
					OIDC_PUBLIC_KEY=null
 | 
				
			||||||
OIDC_AUTH_ENDPOINT=null
 | 
					OIDC_AUTH_ENDPOINT=null
 | 
				
			||||||
OIDC_TOKEN_ENDPOINT=null
 | 
					OIDC_TOKEN_ENDPOINT=null
 | 
				
			||||||
 | 
					OIDC_ADDITIONAL_SCOPES=null
 | 
				
			||||||
OIDC_DUMP_USER_DETAILS=false
 | 
					OIDC_DUMP_USER_DETAILS=false
 | 
				
			||||||
 | 
					OIDC_USER_TO_GROUPS=false
 | 
				
			||||||
 | 
					OIDC_GROUP_ATTRIBUTE=groups
 | 
				
			||||||
 | 
					OIDC_REMOVE_FROM_GROUPS=false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Disable default third-party services such as Gravatar and Draw.IO
 | 
					# Disable default third-party services such as Gravatar and Draw.IO
 | 
				
			||||||
# Service-specific options will override this option
 | 
					# Service-specific options will override this option
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -30,6 +30,11 @@ class OidcOAuthProvider extends AbstractProvider
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    protected $tokenEndpoint;
 | 
					    protected $tokenEndpoint;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Scopes to use for the OIDC authorization call
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    protected array $scopes = ['openid', 'profile', 'email'];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Returns the base URL for authorizing a client.
 | 
					     * Returns the base URL for authorizing a client.
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
| 
						 | 
					@ -54,6 +59,15 @@ class OidcOAuthProvider extends AbstractProvider
 | 
				
			||||||
        return '';
 | 
					        return '';
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Add an additional scope to this provider upon the default.
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    public function addScope(string $scope): void
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        $this->scopes[] = $scope;
 | 
				
			||||||
 | 
					        $this->scopes = array_unique($this->scopes);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Returns the default scopes used by this provider.
 | 
					     * Returns the default scopes used by this provider.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
| 
						 | 
					@ -62,7 +76,7 @@ class OidcOAuthProvider extends AbstractProvider
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    protected function getDefaultScopes(): array
 | 
					    protected function getDefaultScopes(): array
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        return ['openid', 'profile', 'email'];
 | 
					        return $this->scopes;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,6 +2,8 @@
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace BookStack\Auth\Access\Oidc;
 | 
					namespace BookStack\Auth\Access\Oidc;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					use BookStack\Auth\Access\GroupSyncService;
 | 
				
			||||||
 | 
					use Illuminate\Support\Arr;
 | 
				
			||||||
use function auth;
 | 
					use function auth;
 | 
				
			||||||
use BookStack\Auth\Access\LoginService;
 | 
					use BookStack\Auth\Access\LoginService;
 | 
				
			||||||
use BookStack\Auth\Access\RegistrationService;
 | 
					use BookStack\Auth\Access\RegistrationService;
 | 
				
			||||||
| 
						 | 
					@ -26,15 +28,22 @@ class OidcService
 | 
				
			||||||
    protected RegistrationService $registrationService;
 | 
					    protected RegistrationService $registrationService;
 | 
				
			||||||
    protected LoginService $loginService;
 | 
					    protected LoginService $loginService;
 | 
				
			||||||
    protected HttpClient $httpClient;
 | 
					    protected HttpClient $httpClient;
 | 
				
			||||||
 | 
					    protected GroupSyncService $groupService;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * OpenIdService constructor.
 | 
					     * OpenIdService constructor.
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    public function __construct(RegistrationService $registrationService, LoginService $loginService, HttpClient $httpClient)
 | 
					    public function __construct(
 | 
				
			||||||
 | 
					        RegistrationService $registrationService,
 | 
				
			||||||
 | 
					        LoginService        $loginService,
 | 
				
			||||||
 | 
					        HttpClient          $httpClient,
 | 
				
			||||||
 | 
					        GroupSyncService    $groupService
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        $this->registrationService = $registrationService;
 | 
					        $this->registrationService = $registrationService;
 | 
				
			||||||
        $this->loginService = $loginService;
 | 
					        $this->loginService = $loginService;
 | 
				
			||||||
        $this->httpClient = $httpClient;
 | 
					        $this->httpClient = $httpClient;
 | 
				
			||||||
 | 
					        $this->groupService = $groupService;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
| 
						 | 
					@ -117,10 +126,31 @@ class OidcService
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
 | 
					    protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        return new OidcOAuthProvider($settings->arrayForProvider(), [
 | 
					        $provider = new OidcOAuthProvider($settings->arrayForProvider(), [
 | 
				
			||||||
            'httpClient'     => $this->httpClient,
 | 
					            'httpClient'     => $this->httpClient,
 | 
				
			||||||
            'optionProvider' => new HttpBasicAuthOptionProvider(),
 | 
					            'optionProvider' => new HttpBasicAuthOptionProvider(),
 | 
				
			||||||
        ]);
 | 
					        ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        foreach ($this->getAdditionalScopes() as $scope) {
 | 
				
			||||||
 | 
					            $provider->addScope($scope);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return $provider;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Get any user-defined addition/custom scopes to apply to the authentication request.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @return string[]
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    protected function getAdditionalScopes(): array
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        $scopeConfig = $this->config()['additional_scopes'] ?: '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        $scopeArr = explode(',', $scopeConfig);
 | 
				
			||||||
 | 
					        $scopeArr = array_map(fn(string $scope) => trim($scope), $scopeArr);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return array_filter($scopeArr);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
| 
						 | 
					@ -145,10 +175,32 @@ class OidcService
 | 
				
			||||||
        return implode(' ', $displayName);
 | 
					        return implode(' ', $displayName);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Extract the assigned groups from the id token.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * @return string[]
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    protected function getUserGroups(OidcIdToken $token): array
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        $groupsAttr = $this->config()['group_attribute'];
 | 
				
			||||||
 | 
					        if (empty($groupsAttr)) {
 | 
				
			||||||
 | 
					            return [];
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        $groupsList = Arr::get($token->getAllClaims(), $groupsAttr);
 | 
				
			||||||
 | 
					        if (!is_array($groupsList)) {
 | 
				
			||||||
 | 
					            return [];
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return array_values(array_filter($groupsList, function($val) {
 | 
				
			||||||
 | 
					            return is_string($val);
 | 
				
			||||||
 | 
					        }));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Extract the details of a user from an ID token.
 | 
					     * Extract the details of a user from an ID token.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
     * @return array{name: string, email: string, external_id: string}
 | 
					     * @return array{name: string, email: string, external_id: string, groups: string[]}
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    protected function getUserDetails(OidcIdToken $token): array
 | 
					    protected function getUserDetails(OidcIdToken $token): array
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
| 
						 | 
					@ -158,6 +210,7 @@ class OidcService
 | 
				
			||||||
            'external_id' => $id,
 | 
					            'external_id' => $id,
 | 
				
			||||||
            'email'       => $token->getClaim('email'),
 | 
					            'email'       => $token->getClaim('email'),
 | 
				
			||||||
            'name'        => $this->getUserDisplayName($token, $id),
 | 
					            'name'        => $this->getUserDisplayName($token, $id),
 | 
				
			||||||
 | 
					            'groups'      => $this->getUserGroups($token),
 | 
				
			||||||
        ];
 | 
					        ];
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -209,6 +262,12 @@ class OidcService
 | 
				
			||||||
            throw new OidcException($exception->getMessage());
 | 
					            throw new OidcException($exception->getMessage());
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if ($this->shouldSyncGroups()) {
 | 
				
			||||||
 | 
					            $groups = $userDetails['groups'];
 | 
				
			||||||
 | 
					            $detachExisting = $this->config()['remove_from_groups'];
 | 
				
			||||||
 | 
					            $this->groupService->syncUserWithFoundGroups($user, $groups, $detachExisting);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        $this->loginService->login($user, 'oidc');
 | 
					        $this->loginService->login($user, 'oidc');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return $user;
 | 
					        return $user;
 | 
				
			||||||
| 
						 | 
					@ -221,4 +280,12 @@ class OidcService
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        return config('oidc');
 | 
					        return config('oidc');
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Check if groups should be synced.
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    protected function shouldSyncGroups(): bool
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        return $this->config()['user_to_groups'] !== false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -32,4 +32,16 @@ return [
 | 
				
			||||||
    // OAuth2 endpoints.
 | 
					    // OAuth2 endpoints.
 | 
				
			||||||
    'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null),
 | 
					    'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null),
 | 
				
			||||||
    'token_endpoint'         => env('OIDC_TOKEN_ENDPOINT', null),
 | 
					    'token_endpoint'         => env('OIDC_TOKEN_ENDPOINT', null),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Add extra scopes, upon those required, to the OIDC authentication request
 | 
				
			||||||
 | 
					    // Multiple values can be provided comma seperated.
 | 
				
			||||||
 | 
					    'additional_scopes' => env('OIDC_ADDITIONAL_SCOPES', null),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Group sync options
 | 
				
			||||||
 | 
					    // Enable syncing, upon login, of OIDC groups to BookStack roles
 | 
				
			||||||
 | 
					    'user_to_groups' => env('OIDC_USER_TO_GROUPS', false),
 | 
				
			||||||
 | 
					    // Attribute, within a OIDC ID token, to find group names within
 | 
				
			||||||
 | 
					    'group_attribute' => env('OIDC_GROUP_ATTRIBUTE', 'groups'),
 | 
				
			||||||
 | 
					    // When syncing groups, remove any groups that no longer match. Otherwise sync only adds new groups.
 | 
				
			||||||
 | 
					    'remove_from_groups' => env('OIDC_REMOVE_FROM_GROUPS', false),
 | 
				
			||||||
];
 | 
					];
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -3,6 +3,7 @@
 | 
				
			||||||
namespace Tests\Auth;
 | 
					namespace Tests\Auth;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
use BookStack\Actions\ActivityType;
 | 
					use BookStack\Actions\ActivityType;
 | 
				
			||||||
 | 
					use BookStack\Auth\Role;
 | 
				
			||||||
use BookStack\Auth\User;
 | 
					use BookStack\Auth\User;
 | 
				
			||||||
use GuzzleHttp\Psr7\Request;
 | 
					use GuzzleHttp\Psr7\Request;
 | 
				
			||||||
use GuzzleHttp\Psr7\Response;
 | 
					use GuzzleHttp\Psr7\Response;
 | 
				
			||||||
| 
						 | 
					@ -37,6 +38,10 @@ class OidcTest extends TestCase
 | 
				
			||||||
            'oidc.token_endpoint'         => 'https://oidc.local/token',
 | 
					            'oidc.token_endpoint'         => 'https://oidc.local/token',
 | 
				
			||||||
            'oidc.discover'               => false,
 | 
					            'oidc.discover'               => false,
 | 
				
			||||||
            'oidc.dump_user_details'      => false,
 | 
					            'oidc.dump_user_details'      => false,
 | 
				
			||||||
 | 
					            'oidc.additional_scopes'      => '',
 | 
				
			||||||
 | 
					            'oidc.user_to_groups'         => false,
 | 
				
			||||||
 | 
					            'oidc.group_attribute'        => 'group',
 | 
				
			||||||
 | 
					            'oidc.remove_from_groups'     => false,
 | 
				
			||||||
        ]);
 | 
					        ]);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -159,6 +164,17 @@ class OidcTest extends TestCase
 | 
				
			||||||
        $this->assertActivityExists(ActivityType::AUTH_LOGIN, null, "oidc; ({$user->id}) Barry Scott");
 | 
					        $this->assertActivityExists(ActivityType::AUTH_LOGIN, null, "oidc; ({$user->id}) Barry Scott");
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public function test_login_uses_custom_additional_scopes_if_defined()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        config()->set([
 | 
				
			||||||
 | 
					            'oidc.additional_scopes' => 'groups, badgers',
 | 
				
			||||||
 | 
					        ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        $redirect = $this->post('/oidc/login')->headers->get('location');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        $this->assertStringContainsString('scope=openid%20profile%20email%20groups%20badgers', $redirect);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public function test_callback_fails_if_no_state_present_or_matching()
 | 
					    public function test_callback_fails_if_no_state_present_or_matching()
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124');
 | 
					        $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124');
 | 
				
			||||||
| 
						 | 
					@ -344,6 +360,59 @@ class OidcTest extends TestCase
 | 
				
			||||||
        $this->assertTrue(auth()->check());
 | 
					        $this->assertTrue(auth()->check());
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public function test_login_group_sync()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        config()->set([
 | 
				
			||||||
 | 
					            'oidc.user_to_groups'     => true,
 | 
				
			||||||
 | 
					            'oidc.group_attribute'    => 'groups',
 | 
				
			||||||
 | 
					            'oidc.remove_from_groups' => false,
 | 
				
			||||||
 | 
					        ]);
 | 
				
			||||||
 | 
					        $roleA = Role::factory()->create(['display_name' => 'Wizards']);
 | 
				
			||||||
 | 
					        $roleB = Role::factory()->create(['display_name' => 'ZooFolks', 'external_auth_id' => 'zookeepers']);
 | 
				
			||||||
 | 
					        $roleC = Role::factory()->create(['display_name' => 'Another Role']);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        $resp = $this->runLogin([
 | 
				
			||||||
 | 
					            'email'  => 'benny@example.com',
 | 
				
			||||||
 | 
					            'sub'    => 'benny1010101',
 | 
				
			||||||
 | 
					            'groups' => ['Wizards', 'Zookeepers']
 | 
				
			||||||
 | 
					        ]);
 | 
				
			||||||
 | 
					        $resp->assertRedirect('/');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        /** @var User $user */
 | 
				
			||||||
 | 
					        $user = User::query()->where('email', '=', 'benny@example.com')->first();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        $this->assertTrue($user->hasRole($roleA->id));
 | 
				
			||||||
 | 
					        $this->assertTrue($user->hasRole($roleB->id));
 | 
				
			||||||
 | 
					        $this->assertFalse($user->hasRole($roleC->id));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public function test_login_group_sync_with_nested_groups_in_token()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        config()->set([
 | 
				
			||||||
 | 
					            'oidc.user_to_groups'     => true,
 | 
				
			||||||
 | 
					            'oidc.group_attribute'    => 'my.custom.groups.attr',
 | 
				
			||||||
 | 
					            'oidc.remove_from_groups' => false,
 | 
				
			||||||
 | 
					        ]);
 | 
				
			||||||
 | 
					        $roleA = Role::factory()->create(['display_name' => 'Wizards']);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        $resp = $this->runLogin([
 | 
				
			||||||
 | 
					            'email'  => 'benny@example.com',
 | 
				
			||||||
 | 
					            'sub'    => 'benny1010101',
 | 
				
			||||||
 | 
					            'my' => [
 | 
				
			||||||
 | 
					                'custom' => [
 | 
				
			||||||
 | 
					                    'groups' => [
 | 
				
			||||||
 | 
					                        'attr' => ['Wizards']
 | 
				
			||||||
 | 
					                    ]
 | 
				
			||||||
 | 
					                ]
 | 
				
			||||||
 | 
					            ]
 | 
				
			||||||
 | 
					        ]);
 | 
				
			||||||
 | 
					        $resp->assertRedirect('/');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        /** @var User $user */
 | 
				
			||||||
 | 
					        $user = User::query()->where('email', '=', 'benny@example.com')->first();
 | 
				
			||||||
 | 
					        $this->assertTrue($user->hasRole($roleA->id));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    protected function withAutodiscovery()
 | 
					    protected function withAutodiscovery()
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        config()->set([
 | 
					        config()->set([
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
		Reference in New Issue