Skip to content
Snippets Groups Projects
Commit bba01959 authored by Marko Ivancic's avatar Marko Ivancic
Browse files

Merge branch 'oidc' into 'main'

Enable revoking OIDC tokens

See merge request !5
parents 70141eda faf9e61c
No related branches found
No related tags found
1 merge request!5Enable revoking OIDC tokens
Pipeline #79947 passed
Showing
with 1105 additions and 58 deletions
......@@ -9,20 +9,24 @@ use SimpleSAML\Module\accounting\Exceptions\InvalidConfigurationException;
use SimpleSAML\Module\accounting\ModuleConfiguration;
use SimpleSAML\Utils\HTTP;
class ModuleRoutes
class Routes
{
public const PATH_ADMIN_CONFIGURATION_STATUS = 'admin/configuration/status';
public const PATH_USER_PERSONAL_DATA = 'user/personal-data';
public const PATH_USER_OIDC_TOKENS = 'user/oidc-tokens';
public const QUERY_REDIRECT_TO_PATH = 'redirectTo';
protected HTTP $sspHttpUtils;
protected Arr $arr;
public function __construct(HTTP $sspHttpUtils = null)
public function __construct(HTTP $sspHttpUtils = null, Arr $arr = null)
{
$this->sspHttpUtils = $sspHttpUtils ?? new HTTP();
$this->arr = $arr ?? new Arr();
}
public function getUrl(string $path, array $parameters = []): string
public function getUrl(string $path, array $queryParameters = [], array $fragmentParameters = []): string
{
try {
$url = $this->sspHttpUtils->getBaseURL() . 'module.php/' . ModuleConfiguration::MODULE_NAME . '/' . $path;
......@@ -34,8 +38,24 @@ class ModuleRoutes
// @codeCoverageIgnoreEnd
}
if (!empty($parameters)) {
$url = $this->sspHttpUtils->addURLParameters($url, $parameters);
if (!empty($queryParameters)) {
$url = $this->sspHttpUtils->addURLParameters($url, $queryParameters);
}
// Let's assume there are no current fragments in the URL. If the fragment array is not associative,
// simply append value(s). Otherwise, create key-value fragment pairs.
if (!empty($fragmentParameters)) {
/** @psalm-suppress MixedArgumentTypeCoercion */
$url .= '#' . implode(
'&',
(
! $this->arr->isAssociative($fragmentParameters) ?
$fragmentParameters :
array_map(function ($key, string $value): string {
return $key . '=' . $value;
}, array_keys($fragmentParameters), $fragmentParameters)
)
);
}
return $url;
......
<?php
declare(strict_types=1);
namespace SimpleSAML\Module\accounting\Helpers;
use SimpleSAML\Module;
class SspModule
{
/**
* @throws \Exception
*/
public function isEnabled(string $moduleName): bool
{
try {
return Module::isModuleEnabled($moduleName);
} catch (\Throwable $exception) {
$message = sprintf('Could not check if module %s is enabled', $moduleName);
throw new Module\accounting\Exceptions\InvalidConfigurationException(
$message,
(int) $exception->getCode(),
$exception
);
}
}
}
......@@ -7,7 +7,7 @@ namespace SimpleSAML\Module\accounting\Http\Controllers\Admin;
use Exception;
use Psr\Log\LoggerInterface;
use SimpleSAML\Configuration as SspConfiguration;
use SimpleSAML\Module\accounting\Helpers\ModuleRoutes;
use SimpleSAML\Module\accounting\Helpers\Routes;
use SimpleSAML\Module\accounting\ModuleConfiguration;
use SimpleSAML\Module\accounting\Services\HelpersManager;
use SimpleSAML\Module\accounting\Stores\Builders\JobsStoreBuilder;
......@@ -119,8 +119,8 @@ class Configuration
'defaultDataTrackerAndProvider' => $defaultDataTrackerAndProvider,
'additionalTrackers' => $additionalTrackers,
'setupNeeded' => $setupNeeded,
'profilePageUri' => $this->helpersManager->getModuleRoutes()
->getUrl(ModuleRoutes::PATH_USER_PERSONAL_DATA),
'profilePageUri' => $this->helpersManager->getRoutes()
->getUrl(Routes::PATH_USER_PERSONAL_DATA),
];
$template = new Template($this->sspConfiguration, 'accounting:admin/configuration/status.twig');
......
......@@ -10,16 +10,28 @@ use SimpleSAML\Configuration as SspConfiguration;
use SimpleSAML\Error\ConfigurationError;
use SimpleSAML\Error\CriticalConfigurationError;
use SimpleSAML\HTTP\RunnableResponse;
use SimpleSAML\Locale\Translate;
use SimpleSAML\Module\accounting\Entities\Authentication\Protocol\Oidc;
use SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider;
use SimpleSAML\Module\accounting\Entities\User;
use SimpleSAML\Module\accounting\Exceptions\Exception;
use SimpleSAML\Module\accounting\Exceptions\InvalidConfigurationException;
use SimpleSAML\Module\accounting\Helpers\Attributes;
use SimpleSAML\Module\accounting\Helpers\Routes;
use SimpleSAML\Module\accounting\ModuleConfiguration;
use SimpleSAML\Module\accounting\ModuleConfiguration\ConnectionType;
use SimpleSAML\Module\accounting\Providers\Builders\AuthenticationDataProviderBuilder;
use SimpleSAML\Module\accounting\Providers\Interfaces\AuthenticationDataProviderInterface;
use SimpleSAML\Module\accounting\Services\AlertsBag;
use SimpleSAML\Module\accounting\Services\CsrfToken;
use SimpleSAML\Module\accounting\Services\HelpersManager;
use SimpleSAML\Module\accounting\Services\MenuManager;
use SimpleSAML\Module\accounting\Services\SspModuleManager;
use SimpleSAML\Session;
use SimpleSAML\XHTML\Template;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
......@@ -36,6 +48,11 @@ class Profile
protected Simple $authSimple;
protected AuthenticationDataProviderBuilder $authenticationDataProviderBuilder;
protected HelpersManager $helpersManager;
protected SspModuleManager $sspModuleManager;
protected User $user;
protected MenuManager $menuManager;
protected CsrfToken $csrfToken;
protected AlertsBag $alertsBag;
/**
* @param ModuleConfiguration $moduleConfiguration
......@@ -45,6 +62,9 @@ class Profile
* @param Simple|null $authSimple
* @param AuthenticationDataProviderBuilder|null $authenticationDataProviderBuilder
* @param HelpersManager|null $helpersManager
* @param SspModuleManager|null $sspModuleManager
* @param CsrfToken|null $csrfToken
* @param AlertsBag|null $alertsBag
*/
public function __construct(
ModuleConfiguration $moduleConfiguration,
......@@ -53,7 +73,10 @@ class Profile
LoggerInterface $logger,
Simple $authSimple = null,
AuthenticationDataProviderBuilder $authenticationDataProviderBuilder = null,
HelpersManager $helpersManager = null
HelpersManager $helpersManager = null,
SspModuleManager $sspModuleManager = null,
CsrfToken $csrfToken = null,
AlertsBag $alertsBag = null
) {
$this->moduleConfiguration = $moduleConfiguration;
$this->sspConfiguration = $sspConfiguration;
......@@ -68,8 +91,14 @@ class Profile
$this->authenticationDataProviderBuilder = $authenticationDataProviderBuilder ??
new AuthenticationDataProviderBuilder($this->moduleConfiguration, $this->logger, $this->helpersManager);
$this->sspModuleManager = $sspModuleManager ?? new SspModuleManager($this->logger, $this->helpersManager);
// Make sure the end user is authenticated.
$this->authSimple->requireAuth();
$this->user = new User($this->authSimple->getAttributes());
$this->menuManager = $this->prepareMenuManager();
$this->csrfToken = $csrfToken ?? new CsrfToken($this->session, $this->helpersManager);
$this->alertsBag = $alertsBag ?? new AlertsBag($this->session);
}
/**
......@@ -85,7 +114,7 @@ class Profile
* @var string $name
* @var string[] $value
*/
foreach ($this->authSimple->getAttributes() as $name => $value) {
foreach ($this->user->getAttributes() as $name => $value) {
// Convert attribute names to user-friendly names.
if (array_key_exists($name, $toNameAttributeMap)) {
$name = (string)$toNameAttributeMap[$name];
......@@ -94,7 +123,7 @@ class Profile
}
$template = $this->resolveTemplate('accounting:user/personal-data.twig');
$template->data = compact('normalizedAttributes');
$template->data += compact('normalizedAttributes');
return $template;
}
......@@ -111,8 +140,48 @@ class Profile
$connectedServiceProviderBag = $authenticationDataProvider->getConnectedServiceProviders($userIdentifier);
$oidc = $this->sspModuleManager->getOidc();
$accessTokensByClient = [];
$refreshTokensByClient = [];
$oidcProtocolDesignation = Oidc::DESIGNATION;
// If oidc module is enabled, gather users access and refresh tokens for particular OIDC service providers.
if ($oidc->isEnabled()) {
// Filter out OIDC service providers and get their entity (client) IDs.
$oidcClientIds = array_map(
function (ConnectedServiceProvider $connectedServiceProvider) {
return $connectedServiceProvider->getServiceProvider()->getEntityId();
},
array_filter(
$connectedServiceProviderBag->getAll(),
function (ConnectedServiceProvider $connectedServiceProvider) {
return $connectedServiceProvider->getServiceProvider()->getProtocol()->getDesignation() ===
Oidc::DESIGNATION;
}
)
);
if (! empty($oidcClientIds)) {
$accessTokensByClient = $this->helpersManager->getArr()->groupByValue(
$oidc->getUsersAccessTokens($userIdentifier, $oidcClientIds),
'client_id'
);
$refreshTokensByClient = $this->helpersManager->getArr()->groupByValue(
$oidc->getUsersRefreshTokens($userIdentifier, $oidcClientIds),
'client_id'
);
}
//die(var_dump($oidcClientIds, $accessTokensByClient, $refreshTokensByClient));
}
$template = $this->resolveTemplate('accounting:user/connected-organizations.twig');
$template->data = compact('connectedServiceProviderBag');
$template->data += compact(
'connectedServiceProviderBag',
'accessTokensByClient',
'refreshTokensByClient',
'oidcProtocolDesignation'
);
return $template;
}
......@@ -135,25 +204,159 @@ class Profile
$activityBag = $authenticationDataProvider->getActivity($userIdentifier, $maxResults, $firstResult);
$template = $this->resolveTemplate('accounting:user/activity.twig');
$template->data = compact('activityBag', 'page', 'maxResults');
$template->data += compact('activityBag', 'page', 'maxResults');
return $template;
}
/**
* @throws Exception|ConfigurationError
*/
public function oidcTokens(): Response
{
$oidc = $this->sspModuleManager->getOidc();
// If oidc module is not enabled, this route should not be called.
if (!$oidc->isEnabled()) {
return new RedirectResponse($this->helpersManager->getRoutes()->getUrl(Routes::PATH_USER_PERSONAL_DATA));
}
$userIdentifier = $this->resolveUserIdentifier();
$accessTokensByClient = $this->helpersManager->getArr()->groupByValue(
$oidc->getUsersAccessTokens($userIdentifier),
'client_id'
);
$refreshTokensByClient = $this->helpersManager->getArr()->groupByValue(
$oidc->getUsersRefreshTokens($userIdentifier),
'client_id'
);
$clientIds = array_unique(array_merge(array_keys($accessTokensByClient), array_keys($refreshTokensByClient)));
$clients = $this->helpersManager->getArr()->groupByValue(
$oidc->getClients($clientIds),
'id'
);
//die(var_dump($accessTokensByClient, $refreshTokensByClient, $clientIds, $clients));
$template = $this->resolveTemplate('accounting:user/oidc-tokens.twig');
$template->data += compact('accessTokensByClient', 'refreshTokensByClient', 'clients');
return $template;
}
public function oidcTokenRevoke(Request $request): Response
{
$oidc = $this->sspModuleManager->getOidc();
// If oidc module is not enabled, this route should not be called.
if (! $oidc->isEnabled()) {
return new RedirectResponse($this->helpersManager->getRoutes()->getUrl(Routes::PATH_USER_PERSONAL_DATA));
}
$redirectTo = (string) $request->query->get(Routes::QUERY_REDIRECT_TO_PATH, Routes::PATH_USER_OIDC_TOKENS);
$response = new RedirectResponse(
$this->helpersManager->getRoutes()->getUrl($redirectTo)
);
if (! $this->csrfToken->validate($request->request->getAlnum('csrf-token'))) {
$this->alertsBag->put(
new AlertsBag\Alert('Could not verify CSRF token.', 'warning')
);
return $response;
}
$validTokenTypes = ['access', 'refresh'];
$tokenType = $request->request->getAlnum('token-type');
if (! in_array($tokenType, $validTokenTypes)) {
$this->alertsBag->put(
new AlertsBag\Alert('Token type not valid.', 'warning')
);
return $response;
}
$tokenId = $request->request->getAlnum('token-id');
$userIdentifier = $this->resolveUserIdentifier();
if ($tokenType === 'access') {
$oidc->revokeUsersAccessToken($userIdentifier, $tokenId);
} elseif ($tokenType === 'refresh') {
$oidc->revokeUsersRefreshToken($userIdentifier, $tokenId);
}
$this->alertsBag->put(
new AlertsBag\Alert('Token revoked successfully.', 'success')
);
return $response;
}
public function oidcTokenRevokeXhr(Request $request): Response
{
$oidc = $this->sspModuleManager->getOidc();
$response = new JsonResponse();
// If oidc module is not enabled, this route should not be called.
if (! $oidc->isEnabled()) {
return new JsonResponse(['status' => 'error', 'message' => 'Not available.'], 404);
}
if (! $this->csrfToken->validate((string) $request->cookies->get(CsrfToken::KEY))) {
$this->appendCsrfCookie($response);
return $response
->setData(['status' => 'error', 'message' => 'CSRF validation failed.'])
->setStatusCode(400);
}
$this->appendCsrfCookie($response);
$validTokenTypes = ['access', 'refresh'];
$tokenType = $request->request->getAlnum('token-type');
if (! in_array($tokenType, $validTokenTypes)) {
return $response
->setData(['status' => 'error', 'message' => 'Token type not valid.'])
->setStatusCode(422);
}
$tokenId = $request->request->getAlnum('token-id');
$userIdentifier = $this->resolveUserIdentifier();
if ($tokenType === 'access') {
$oidc->revokeUsersAccessToken($userIdentifier, $tokenId);
} elseif ($tokenType === 'refresh') {
$oidc->revokeUsersRefreshToken($userIdentifier, $tokenId);
}
return $response
->setData(['status' => 'success', 'message' => 'Token revoked successfully.']);
}
/**
* @throws Exception
*/
protected function resolveUserIdentifier(): string
{
$attributes = $this->authSimple->getAttributes();
$idAttributeName = $this->moduleConfiguration->getUserIdAttributeName();
$userIdAttributeName = $this->moduleConfiguration->getUserIdAttributeName();
$userIdentifier = $this->user->getFirstAttributeValue($userIdAttributeName);
if (empty($attributes[$idAttributeName]) || !is_array($attributes[$idAttributeName])) {
$message = sprintf('No identifier %s present in user attributes.', $idAttributeName);
if (is_null($userIdentifier)) {
$message = sprintf('No identifier %s present in user attributes.', $userIdAttributeName);
throw new Exception($message);
}
return (string)reset($attributes[$idAttributeName]);
return $userIdentifier;
}
/**
......@@ -204,6 +407,64 @@ class Profile
$templateInstance->getLocalization()->addModuleDomain(ModuleConfiguration::MODULE_NAME);
$templateInstance->getLocalization()->addAttributeDomains();
$templateInstance->data = [
'menuManager' => $this->menuManager,
'csrfToken' => $this->csrfToken,
'alertsBag' => $this->alertsBag,
];
// Make CSRF token also available as a cookie, so it can be used for XHR POST requests validation.
$this->appendCsrfCookie($templateInstance);
return $templateInstance;
}
protected function prepareMenuManager(): MenuManager
{
$menuManager = new MenuManager();
$menuManager->addItems(
new MenuManager\MenuItem(
'personal-data',
Translate::noop('Personal Data'),
'css/src/icons/prof-page.svg'
),
new MenuManager\MenuItem(
'connected-organizations',
Translate::noop('Connected Organizations'),
'css/src/icons/conn-orgs.svg'
),
new MenuManager\MenuItem(
'activity',
Translate::noop('Activity'),
'css/src/icons/activity.svg'
)
);
// Depending on other functionalities, add additional menu items.
if ($this->sspModuleManager->getOidc()->isEnabled()) {
$menuManager->addItems(
new MenuManager\MenuItem(
'oidc-tokens',
Translate::noop('Tokens'),
'css/src/icons/activity.svg'
)
);
}
$menuManager->addItems(
new MenuManager\MenuItem(
'logout',
Translate::noop('Log out'),
'css/src/icons/logout.svg'
)
);
return $menuManager;
}
protected function appendCsrfCookie(Response $response): void
{
$response->headers->setCookie(new Cookie(CsrfToken::KEY, $this->csrfToken->get()));
}
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -79,7 +79,7 @@ class JobRunner
$this->cache = $cache ?? $this->resolveCache();
$this->jobRunnerId = $this->helpersManager->getRandom()->getRandomInt();
$this->jobRunnerId = $this->helpersManager->getRandom()->getInt();
$this->state = $state ?? new State($this->jobRunnerId);
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment