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

Show tokens on connected organizations page

parent f883970d
Branches
Tags v1.2.3
1 merge request!5Enable revoking OIDC tokens
...@@ -24,6 +24,11 @@ accounting-user-oidc-token-revoke: ...@@ -24,6 +24,11 @@ accounting-user-oidc-token-revoke:
controller: SimpleSAML\Module\accounting\Http\Controllers\User\Profile::oidcTokenRevoke controller: SimpleSAML\Module\accounting\Http\Controllers\User\Profile::oidcTokenRevoke
methods: POST methods: POST
accounting-user-oidc-token-revoke-xhr:
path: /user/oidc-tokens/revoke-xhr
controller: SimpleSAML\Module\accounting\Http\Controllers\User\Profile::oidcTokenRevokeXhr
methods: POST
accounting-user-logout: accounting-user-logout:
path: /user/logout path: /user/logout
controller: SimpleSAML\Module\accounting\Http\Controllers\User\Profile::logout controller: SimpleSAML\Module\accounting\Http\Controllers\User\Profile::logout
......
...@@ -31,4 +31,10 @@ class Arr ...@@ -31,4 +31,10 @@ class Arr
return $carry; return $carry;
}, []); }, []);
} }
public function isAssociative(array $array): bool
{
$keys = array_keys($array);
return $keys !== array_keys($keys);
}
} }
...@@ -15,15 +15,18 @@ class Routes ...@@ -15,15 +15,18 @@ class Routes
public const PATH_USER_PERSONAL_DATA = 'user/personal-data'; public const PATH_USER_PERSONAL_DATA = 'user/personal-data';
public const PATH_USER_OIDC_TOKENS = 'user/oidc-tokens'; public const PATH_USER_OIDC_TOKENS = 'user/oidc-tokens';
public const QUERY_REDIRECT_TO_PATH = 'redirectTo';
protected HTTP $sspHttpUtils; 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->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 { try {
$url = $this->sspHttpUtils->getBaseURL() . 'module.php/' . ModuleConfiguration::MODULE_NAME . '/' . $path; $url = $this->sspHttpUtils->getBaseURL() . 'module.php/' . ModuleConfiguration::MODULE_NAME . '/' . $path;
...@@ -35,8 +38,24 @@ class Routes ...@@ -35,8 +38,24 @@ class Routes
// @codeCoverageIgnoreEnd // @codeCoverageIgnoreEnd
} }
if (!empty($parameters)) { if (!empty($queryParameters)) {
$url = $this->sspHttpUtils->addURLParameters($url, $parameters); $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; return $url;
......
...@@ -29,6 +29,8 @@ use SimpleSAML\Module\accounting\Services\MenuManager; ...@@ -29,6 +29,8 @@ use SimpleSAML\Module\accounting\Services\MenuManager;
use SimpleSAML\Module\accounting\Services\SspModuleManager; use SimpleSAML\Module\accounting\Services\SspModuleManager;
use SimpleSAML\Session; use SimpleSAML\Session;
use SimpleSAML\XHTML\Template; use SimpleSAML\XHTML\Template;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
...@@ -137,11 +139,9 @@ class Profile ...@@ -137,11 +139,9 @@ class Profile
$connectedServiceProviderBag = $authenticationDataProvider->getConnectedServiceProviders($userIdentifier); $connectedServiceProviderBag = $authenticationDataProvider->getConnectedServiceProviders($userIdentifier);
//die(var_dump(current($connectedServiceProviderBag->getAll())->getServiceProvider()->getProtocol()));
$oidc = $this->sspModuleManager->getOidc(); $oidc = $this->sspModuleManager->getOidc();
$oidcAccessTokensByClient = []; $accessTokensByClient = [];
$oidcRefreshTokensByClient = []; $refreshTokensByClient = [];
$oidcProtocolDesignation = Oidc::DESIGNATION; $oidcProtocolDesignation = Oidc::DESIGNATION;
// If oidc module is enabled, gather users access and refresh tokens for particular OIDC service providers. // If oidc module is enabled, gather users access and refresh tokens for particular OIDC service providers.
...@@ -161,24 +161,24 @@ class Profile ...@@ -161,24 +161,24 @@ class Profile
); );
if (! empty($oidcClientIds)) { if (! empty($oidcClientIds)) {
$oidcAccessTokensByClient = $this->helpersManager->getArr()->groupByValue( $accessTokensByClient = $this->helpersManager->getArr()->groupByValue(
$oidc->getUsersAccessTokens($userIdentifier, $oidcClientIds), $oidc->getUsersAccessTokens($userIdentifier, $oidcClientIds),
'client_id' 'client_id'
); );
$oidcRefreshTokensByClient = $this->helpersManager->getArr()->groupByValue( $refreshTokensByClient = $this->helpersManager->getArr()->groupByValue(
$oidc->getUsersRefreshTokens($userIdentifier, $oidcClientIds), $oidc->getUsersRefreshTokens($userIdentifier, $oidcClientIds),
'client_id' 'client_id'
); );
} }
//die(var_dump($oidcClientIds, $oidcAccessTokensByClient, $oidcRefreshTokensByClient)); //die(var_dump($oidcClientIds, $accessTokensByClient, $refreshTokensByClient));
} }
$template = $this->resolveTemplate('accounting:user/connected-organizations.twig'); $template = $this->resolveTemplate('accounting:user/connected-organizations.twig');
$template->data += compact( $template->data += compact(
'connectedServiceProviderBag', 'connectedServiceProviderBag',
'oidcAccessTokensByClient', 'accessTokensByClient',
'oidcRefreshTokensByClient', 'refreshTokensByClient',
'oidcProtocolDesignation' 'oidcProtocolDesignation'
); );
...@@ -255,8 +255,10 @@ class Profile ...@@ -255,8 +255,10 @@ class Profile
return new RedirectResponse($this->helpersManager->getRoutes()->getUrl(Routes::PATH_USER_PERSONAL_DATA)); 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( $response = new RedirectResponse(
$this->helpersManager->getRoutes()->getUrl(Routes::PATH_USER_OIDC_TOKENS) $this->helpersManager->getRoutes()->getUrl($redirectTo)
); );
if (! $this->csrfToken->validate($request->request->getAlnum('csrf-token'))) { if (! $this->csrfToken->validate($request->request->getAlnum('csrf-token'))) {
...@@ -295,6 +297,51 @@ class Profile ...@@ -295,6 +297,51 @@ class Profile
return $response; 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 * @throws Exception
*/ */
...@@ -365,6 +412,9 @@ class Profile ...@@ -365,6 +412,9 @@ class Profile
'alertsBag' => $this->alertsBag, '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; return $templateInstance;
} }
...@@ -395,7 +445,7 @@ class Profile ...@@ -395,7 +445,7 @@ class Profile
$menuManager->addItems( $menuManager->addItems(
new MenuManager\MenuItem( new MenuManager\MenuItem(
'oidc-tokens', 'oidc-tokens',
Translate::noop('OIDC Tokens'), Translate::noop('Tokens'),
'css/src/icons/activity.svg' 'css/src/icons/activity.svg'
) )
); );
...@@ -411,4 +461,9 @@ class Profile ...@@ -411,4 +461,9 @@ class Profile
return $menuManager; return $menuManager;
} }
protected function appendCsrfCookie(Response $response): void
{
$response->headers->setCookie(new Cookie(CsrfToken::KEY, $this->csrfToken->get()));
}
} }
...@@ -11,7 +11,7 @@ class CsrfToken ...@@ -11,7 +11,7 @@ class CsrfToken
{ {
protected Session $session; protected Session $session;
public const SESSION_KEY = 'csrf-token'; public const KEY = 'ssp-' . ModuleConfiguration::MODULE_NAME . '-csrf-token';
protected HelpersManager $helpersManager; protected HelpersManager $helpersManager;
public function __construct( public function __construct(
...@@ -30,14 +30,14 @@ class CsrfToken ...@@ -30,14 +30,14 @@ class CsrfToken
{ {
$this->session->setData( $this->session->setData(
ModuleConfiguration::MODULE_NAME, ModuleConfiguration::MODULE_NAME,
self::SESSION_KEY, self::KEY,
$token ?? $this->helpersManager->getRandom()->getString() $token ?? $this->helpersManager->getRandom()->getString()
); );
} }
public function get(): ?string public function get(): ?string
{ {
$token = $this->session->getData(ModuleConfiguration::MODULE_NAME, self::SESSION_KEY); $token = $this->session->getData(ModuleConfiguration::MODULE_NAME, self::KEY);
if ($token !== null) { if ($token !== null) {
return (string) $token; return (string) $token;
......
...@@ -37,5 +37,24 @@ ...@@ -37,5 +37,24 @@
{% endtrans %} {% endtrans %}
</div> </div>
</footer> </footer>
<script>
const accounting = {
alert: function (message) {
// TODO implement nice alerts.
alert(message);
},
removeElement: function (element, speed = 500) {
const seconds = speed / 1000;
element.style.transition = "opacity " + seconds + "s ease";
element.style.opacity = 0;
setTimeout(function() {
element.parentNode.removeChild(element);
}, speed);
}
};
</script>
{% block tail %}{% endblock %}
</body> </body>
</html> </html>
\ No newline at end of file
{# @var connectedServiceProviderBag \SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider\Bag #} {# @var connectedServiceProviderBag \SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider\Bag #}
{# @var connectedServiceProvider \SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider #} {# @var connectedServiceProvider \SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider #}
{# @var oidcAccessTokensByClient array #} {# @var accessTokensByClient array #}
{# @var oidcRefreshTokensByClient array #} {# @var refreshTokensByClient array #}
{% extends "@accounting/base.twig" %} {% extends "@accounting/base.twig" %}
...@@ -19,7 +19,7 @@ ...@@ -19,7 +19,7 @@
</tr> </tr>
{% for connectedServiceProvider in connectedServiceProviderBag.getAll %} {% for connectedServiceProvider in connectedServiceProviderBag.getAll %}
<tr> <tr id="connected-service-provider-{{ loop.index }}">
<td>{{ connectedServiceProvider.getServiceProvider.getName|e }}</td> <td>{{ connectedServiceProvider.getServiceProvider.getName|e }}</td>
<td>{{ connectedServiceProvider.getNumberOfAuthentications|e }}</td> <td>{{ connectedServiceProvider.getNumberOfAuthentications|e }}</td>
<td>{{ connectedServiceProvider.getLastAuthenticationAt|date() }}</td> <td>{{ connectedServiceProvider.getLastAuthenticationAt|date() }}</td>
...@@ -30,7 +30,7 @@ ...@@ -30,7 +30,7 @@
<label class="dropdown-label" for="dropdown-toggle-{{ loop.index }}"> <label class="dropdown-label" for="dropdown-toggle-{{ loop.index }}">
<img src="{{ asset('css/src/icons/dropdown.svg', 'accounting') }}" alt="Dropdown icon"> <img src="{{ asset('css/src/icons/dropdown.svg', 'accounting') }}" alt="Dropdown icon">
</label> </label>
<div class="dropdown-box"> <div class="dropdown-box" >
<strong>{{ 'Service details'|trans }}</strong> <strong>{{ 'Service details'|trans }}</strong>
<ul> <ul>
<li>{{ 'Entity ID'|trans }}: {{ connectedServiceProvider.getServiceProvider.getEntityId|e }}</li> <li>{{ 'Entity ID'|trans }}: {{ connectedServiceProvider.getServiceProvider.getEntityId|e }}</li>
...@@ -48,7 +48,47 @@ ...@@ -48,7 +48,47 @@
</ul> </ul>
{% if connectedServiceProvider.serviceProvider.protocol.designation is same as oidcProtocolDesignation %} {% if connectedServiceProvider.serviceProvider.protocol.designation is same as oidcProtocolDesignation %}
todo list tokens {% if accessTokensByClient is not empty and attribute(accessTokensByClient, connectedServiceProvider.serviceProvider.entityId) %}
<strong>{{ 'Access Tokens'|trans }}</strong>
<table>
{% for accessToken in attribute(accessTokensByClient, connectedServiceProvider.serviceProvider.entityId) %}
<tr>
<td>
{{ accessToken.id }}<br>
{{ 'Expires at'|trans }} {{ accessToken.expires_at|date }}
</td>
<td>
<form class="token-revoke-form" action="oidc-tokens/revoke-xhr">
<input type="hidden" name="token-type" value="access">
<input type="hidden" name="token-id" value="{{ accessToken.id }}">
<input type="submit" value="Revoke">
</form>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% if refreshTokensByClient is not empty and attribute(refreshTokensByClient, connectedServiceProvider.serviceProvider.entityId) %}
<strong>{{ 'Refresh Tokens'|trans }}</strong>
<table>
{% for refreshToken in attribute(refreshTokensByClient, connectedServiceProvider.serviceProvider.entityId) %}
<tr>
<td>
{{ refreshToken.id }}<br>
{{ 'Expires at'|trans }} {{ refreshToken.expires_at|date }}
</td>
<td>
<form class="token-revoke-form" action="oidc-tokens/revoke-xhr">
<input type="hidden" name="token-type" value="refresh">
<input type="hidden" name="token-id" value="{{ refreshToken.id }}">
<input type="submit" value="Revoke">
</form>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% endif %} {% endif %}
</div> </div>
</td> </td>
...@@ -61,3 +101,45 @@ ...@@ -61,3 +101,45 @@
<!-- end of repeating item --> <!-- end of repeating item -->
</table> </table>
{% endblock %} {% endblock %}
{% block tail %}
<script>
(function () {
document.querySelectorAll('.token-revoke-form').forEach(
element => element.addEventListener('submit', revokeToken)
);
function revokeToken(event) {
event.preventDefault();
const form = event.target;
const submitButton = event.submitter;
const formData = new FormData(form);
if (! confirm('{{ 'Are you sure?'|trans }}')) {
return;
}
submitButton.disabled = true;
const request = new XMLHttpRequest();
request.open('POST', form.action);
request.onload = function () {
if (this.status === 200) {
accounting.removeElement(form.closest('tr'));
} else {
const response = JSON.parse(this.responseText);
console.log(response);
accounting.alert('{{ 'Oups, there was an error while trying to revoke token.'|trans }}');
}
};
request.send(formData);
}
})();
</script>
{% endblock %}
\ No newline at end of file
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment