Skip to content

Commit 45af3cb

Browse files
committed
Implement login token authentication to use access URL chooser - refs BT#22639
1 parent 2440f95 commit 45af3cb

File tree

8 files changed

+218
-38
lines changed

8 files changed

+218
-38
lines changed

assets/vue/components/accessurl/AccessUrlChooser.vue

Lines changed: 5 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,16 @@
11
<script setup>
2-
import { ref } from "vue"
32
import { useI18n } from "vue-i18n"
43
import Dialog from "primevue/dialog"
5-
import { findUserActivePortals } from "../../services/accessurlService"
6-
import { useSecurityStore } from "../../store/securityStore"
7-
import { useNotification } from "../../composables/notification"
8-
import BaseAppLink from "../basecomponents/BaseAppLink.vue"
4+
import { useAccessUrlChooser } from "../../composables/accessurl/accessUrlChooser"
95
10-
const securityStore = useSecurityStore()
116
const { t } = useI18n()
12-
const { showErrorNotification } = useNotification()
137
14-
const isLoading = ref(true)
15-
const accessUrls = ref([])
16-
17-
if (securityStore.showAccessUrlChooser) {
18-
findUserActivePortals(securityStore.user["@id"])
19-
.then((items) => {
20-
accessUrls.value = items
21-
22-
if (1 === items.length) {
23-
window.location.href = items[0].url
24-
}
25-
})
26-
.catch((error) => showErrorNotification(error))
27-
.finally(() => {
28-
if (1 !== accessUrls.value.length) {
29-
isLoading.value = false
30-
}
31-
})
32-
}
8+
const { visible, isLoading, accessUrls, doRedirectToPortal } = useAccessUrlChooser()
339
</script>
3410

3511
<template>
3612
<Dialog
37-
v-model:visible="securityStore.showAccessUrlChooser"
13+
v-model:visible="visible"
3814
:modal="true"
3915
:closable="false"
4016
:header="t('Access URL')"
@@ -52,8 +28,9 @@ if (securityStore.showAccessUrlChooser) {
5228
v-for="accessUrl in accessUrls"
5329
:key="accessUrl.id"
5430
class="text-center"
31+
@click="doRedirectToPortal(accessUrl.url)"
5532
>
56-
<BaseAppLink :url="accessUrl.url">{{ accessUrl.url }}</BaseAppLink>
33+
{{ accessUrl.url }}
5734
<p
5835
v-if="accessUrl.description"
5936
v-text="accessUrl.description"
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { findUserActivePortals } from "../../services/accessurlService"
2+
import { useSecurityStore } from "../../store/securityStore"
3+
import { computed, ref } from "vue"
4+
import { useNotification } from "../notification"
5+
import securityService from "../../services/securityService"
6+
7+
export function useAccessUrlChooser() {
8+
const securityStore = useSecurityStore()
9+
10+
const { showErrorNotification } = useNotification()
11+
12+
const visible = computed(() => securityStore.showAccessUrlChooser)
13+
const isLoading = ref(true)
14+
const accessUrls = ref([])
15+
16+
async function init() {
17+
if (!securityStore.showAccessUrlChooser) {
18+
return
19+
}
20+
21+
try {
22+
const items = await findUserActivePortals(securityStore.user["@id"])
23+
24+
accessUrls.value = items
25+
26+
if (1 === items.length) {
27+
await doRedirectToPortal(items[0].url)
28+
}
29+
} catch (error) {
30+
showErrorNotification(error)
31+
} finally {
32+
if (1 !== accessUrls.value.length) {
33+
isLoading.value = false
34+
}
35+
}
36+
}
37+
38+
async function doRedirectToPortal(url) {
39+
try {
40+
await securityService.loginTokenCheck(url, await securityService.loginTokenRequest())
41+
42+
window.location.href = url
43+
} catch (error) {
44+
showErrorNotification(error)
45+
}
46+
}
47+
48+
init().then(() => {})
49+
50+
return {
51+
visible,
52+
isLoading,
53+
accessUrls,
54+
doRedirectToPortal,
55+
}
56+
}

assets/vue/services/baseService.js

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,18 +43,21 @@ export default {
4343
* @param {string} endpoint
4444
* @param {Object} [params={}]
4545
* @param {boolean} [addContentType=false]
46+
* @param {Object} [additionalHeaders={}]
47+
* @param {Object} [options={}]
4648
* @returns {Promise<Object>}
4749
*/
48-
async post(endpoint, params = {}, addContentType = false) {
49-
const config = addContentType
50-
? {
51-
headers: {
52-
"Content-Type": "application/json",
53-
},
54-
}
55-
: {}
56-
57-
const { data } = await api.post(endpoint, params, config)
50+
async post(endpoint, params = {}, addContentType = false, additionalHeaders = {}, options = {}) {
51+
const headers = {}
52+
53+
if (addContentType) {
54+
headers["Content-Type"] = "application/json"
55+
}
56+
57+
const { data } = await api.post(endpoint, params, {
58+
headers: { ...headers, ...additionalHeaders },
59+
...options,
60+
})
5861

5962
return data
6063
},

assets/vue/services/securityService.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,35 @@ async function checkSession() {
2828
return await baseService.get("/check-session")
2929
}
3030

31+
/**
32+
* @returns {Promise<string>}
33+
*/
34+
async function loginTokenRequest() {
35+
const { token } = await baseService.get(`/login/token/request`)
36+
37+
return token
38+
}
39+
40+
/**
41+
* @param {string} portalUrl
42+
* @param {string} token
43+
* @returns {Promise<void>}
44+
*/
45+
async function loginTokenCheck(portalUrl, token) {
46+
portalUrl = portalUrl.endsWith("/") ? portalUrl.slice(0, -1) : portalUrl
47+
48+
await baseService.post(
49+
`${portalUrl}/login/token/check`,
50+
{},
51+
false,
52+
{ Authorization: `Bearer ${token}` },
53+
{ withCredentials: true },
54+
)
55+
}
56+
3157
export default {
3258
login,
3359
checkSession,
60+
loginTokenRequest,
61+
loginTokenCheck,
3462
}

config/packages/nelmio_cors.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ nelmio_cors:
66
allow_headers: ['Content-Type', 'Authorization', 'Preload', 'Fields']
77
expose_headers: ['Link']
88
max_age: 3600
9+
allow_credentials: true
910
paths:
1011
'^/': null

config/packages/security.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,11 +115,13 @@ security:
115115
# password_path: security.credentials.password
116116

117117
custom_authenticators:
118+
- Chamilo\CoreBundle\Security\Authenticator\LoginTokenAuthenticator
118119
- Chamilo\CoreBundle\Security\Authenticator\OAuth2\GenericAuthenticator
119120
- Chamilo\CoreBundle\Security\Authenticator\OAuth2\FacebookAuthenticator
120121
- Chamilo\CoreBundle\Security\Authenticator\OAuth2\KeycloakAuthenticator
121122
- Chamilo\CoreBundle\Security\Authenticator\OAuth2\AzureAuthenticator
122123

123124
access_control:
125+
- { path: ^/login/token/check, roles: PUBLIC_ACCESS }
124126
- {path: ^/login, roles: PUBLIC_ACCESS}
125127
- {path: ^/api/authentication_token, roles: PUBLIC_ACCESS}

src/CoreBundle/Controller/SecurityController.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@
1818
use Chamilo\CoreBundle\Repository\Node\AccessUrlRepository;
1919
use Chamilo\CoreBundle\Repository\Node\CourseRepository;
2020
use Chamilo\CoreBundle\Repository\TrackELoginRecordRepository;
21+
use Chamilo\CoreBundle\Security\Authenticator\LoginTokenAuthenticator;
2122
use Chamilo\CoreBundle\Settings\SettingsManager;
2223
use DateTime;
2324
use DateTimeImmutable;
2425
use Doctrine\ORM\EntityManagerInterface;
2526
use Exception;
27+
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
2628
use OTPHP\TOTP;
2729
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
2830
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -32,6 +34,8 @@
3234
use Symfony\Component\Routing\RouterInterface;
3335
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
3436
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
37+
use Symfony\Component\Security\Core\User\UserProviderInterface;
38+
use Symfony\Component\Security\Http\Authentication\UserAuthenticatorInterface;
3539
use Symfony\Component\Serializer\SerializerInterface;
3640
use Symfony\Contracts\Translation\TranslatorInterface;
3741

@@ -187,6 +191,26 @@ public function checkSession(): JsonResponse
187191
throw $this->createAccessDeniedException();
188192
}
189193

194+
#[Route('/login/token/request', name: 'login_token_request', methods: ['GET'])]
195+
public function loginTokenRequest(JWTTokenManagerInterface $jwtManager): JsonResponse
196+
{
197+
$user = $this->userHelper->getCurrent();
198+
199+
if (!$user) {
200+
throw $this->createAccessDeniedException();
201+
}
202+
203+
return new JsonResponse([
204+
'token' => $jwtManager->create($user),
205+
]);
206+
}
207+
208+
#[Route('/login/token/check', name: 'login_token_check', methods: ['POST'])]
209+
public function loginTokenCheck(): Response
210+
{
211+
return new Response(null, Response::HTTP_NO_CONTENT);
212+
}
213+
190214
/**
191215
* Validates the provided TOTP code for the given user.
192216
*
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<?php
2+
3+
/* For licensing terms, see /license.txt */
4+
5+
declare(strict_types=1);
6+
7+
namespace Chamilo\CoreBundle\Security\Authenticator;
8+
9+
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
10+
use Symfony\Component\HttpFoundation\Request;
11+
use Symfony\Component\HttpFoundation\Response;
12+
use Symfony\Component\Routing\RouterInterface;
13+
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
14+
use Symfony\Component\Security\Core\Exception\AuthenticationException;
15+
use Symfony\Component\Security\Core\User\UserProviderInterface;
16+
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
17+
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
18+
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
19+
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
20+
21+
class LoginTokenAuthenticator extends AbstractAuthenticator
22+
{
23+
public function __construct(
24+
protected readonly UserProviderInterface $userProvider,
25+
protected readonly RouterInterface $router,
26+
protected readonly JWTTokenManagerInterface $jwtManager,
27+
) {}
28+
29+
/**
30+
* @inheritDoc
31+
*/
32+
public function supports(Request $request): ?bool
33+
{
34+
return $request->attributes->get('_route') === 'login_token_check'
35+
&& $request->headers->has('Authorization')
36+
;
37+
}
38+
39+
/**
40+
* @inheritDoc
41+
*/
42+
public function authenticate(Request $request): Passport
43+
{
44+
$authHeader = $request->headers->get('Authorization');
45+
46+
if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) {
47+
throw new AuthenticationException('Missing token.');
48+
}
49+
50+
$jwt = substr($authHeader, 7);
51+
52+
try {
53+
$payload = $this->jwtManager->parse($jwt);
54+
$username = $payload['username'] ?? $payload['sub'] ?? null;
55+
56+
if (!$username) {
57+
throw new AuthenticationException('Token does not contain a username.');
58+
}
59+
60+
} catch (\Exception $e) {
61+
throw new AuthenticationException('Invalid JWT token: '.$e->getMessage());
62+
}
63+
64+
return new SelfValidatingPassport(
65+
new UserBadge(
66+
$username,
67+
fn(string $username) => $this->userProvider->loadUserByIdentifier($username)
68+
)
69+
);
70+
}
71+
72+
/**
73+
* @inheritDoc
74+
*/
75+
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
76+
{
77+
return null;
78+
}
79+
80+
/**
81+
* @inheritDoc
82+
*/
83+
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
84+
{
85+
$message = strtr($exception->getMessage(), $exception->getMessageData());
86+
87+
return new Response($message, Response::HTTP_FORBIDDEN);
88+
}
89+
}

0 commit comments

Comments
 (0)