Skip to content

Commit 465dee3

Browse files
committed
Implementing extensions presenter that handles 3rd party tools handshake and authentication.
1 parent f88153b commit 465dee3

File tree

11 files changed

+223
-18
lines changed

11 files changed

+223
-18
lines changed

app/V1Module/presenters/EmailsPresenter.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414

1515
class EmailsPresenter extends BasePresenter
1616
{
17-
1817
/**
1918
* @var EmailLocalizationHelper
2019
* @inject
@@ -57,7 +56,8 @@ public function checkDefault()
5756
* Sends an email with provided subject and message to all ReCodEx users.
5857
* @POST
5958
* @Param(type="post", name="subject", validation="string:1..", description="Subject for the soon to be sent email")
60-
* @Param(type="post", name="message", validation="string:1..", description="Message which will be sent, can be html code")
59+
* @Param(type="post", name="message", validation="string:1..",
60+
* description="Message which will be sent, can be html code")
6161
*/
6262
public function actionDefault()
6363
{
@@ -87,7 +87,8 @@ public function checkSendToSupervisors()
8787
* Sends an email with provided subject and message to all supervisors and superadmins.
8888
* @POST
8989
* @Param(type="post", name="subject", validation="string:1..", description="Subject for the soon to be sent email")
90-
* @Param(type="post", name="message", validation="string:1..", description="Message which will be sent, can be html code")
90+
* @Param(type="post", name="message", validation="string:1..",
91+
* description="Message which will be sent, can be html code")
9192
*/
9293
public function actionSendToSupervisors()
9394
{
@@ -123,7 +124,8 @@ public function checkSendToRegularUsers()
123124
* Sends an email with provided subject and message to all regular users.
124125
* @POST
125126
* @Param(type="post", name="subject", validation="string:1..", description="Subject for the soon to be sent email")
126-
* @Param(type="post", name="message", validation="string:1..", description="Message which will be sent, can be html code")
127+
* @Param(type="post", name="message", validation="string:1..",
128+
* description="Message which will be sent, can be html code")
127129
*/
128130
public function actionSendToRegularUsers()
129131
{
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
<?php
2+
3+
namespace App\V1Module\Presenters;
4+
5+
use App\Exceptions\ForbiddenRequestException;
6+
use App\Exceptions\BadRequestException;
7+
use App\Model\Repository\Instances;
8+
use App\Model\Repository\Users;
9+
use App\Model\View\UserViewFactory;
10+
use App\Helpers\Extensions;
11+
use App\Security\AccessManager;
12+
use App\Security\TokenScope;
13+
14+
/**
15+
* Endpoints handling 3rd party extensions communication.
16+
*/
17+
class ExtensionsPresenter extends BasePresenter
18+
{
19+
/**
20+
* @var Extensions
21+
* @inject
22+
*/
23+
public $extensions;
24+
25+
/**
26+
* @var Instances
27+
* @inject
28+
*/
29+
public $instances;
30+
31+
/**
32+
* @var Users
33+
* @inject
34+
*/
35+
public $users;
36+
37+
/**
38+
* @var AccessManager
39+
* @inject
40+
*/
41+
public $accessManager;
42+
43+
/**
44+
* @var UserViewFactory
45+
* @inject
46+
*/
47+
public $userViewFactory;
48+
49+
public function checkUrl(string $extId, string $instanceId)
50+
{
51+
$user = $this->getCurrentUser();
52+
$extension = $this->extensions->getExtension($extId);
53+
$instance = $this->instances->findOrThrow($instanceId);
54+
if (!$extension || !$extension->isAccessible($instance, $user)) {
55+
throw new ForbiddenRequestException();
56+
}
57+
}
58+
59+
/**
60+
* Return URL refering to the extension with properly injected temporary JWT token.
61+
* @GET
62+
* @Param(type="query", name="locale", required=false, validation="string:2")
63+
*/
64+
public function actionUrl(string $extId, string $instanceId, ?string $locale)
65+
{
66+
$user = $this->getCurrentUser();
67+
$extension = $this->extensions->getExtension($extId);
68+
69+
$token = $this->accessManager->issueToken(
70+
$user,
71+
null,
72+
[TokenScope::EXTENSIONS],
73+
$extension->getUrlTokenExpiration(),
74+
["instance" => $instanceId, "extension" => $extId]
75+
);
76+
77+
if (!$locale) {
78+
$locale = $this->getCurrentUserLocale();
79+
}
80+
81+
$this->sendSuccessResponse($extension->getUrl($token, $locale));
82+
}
83+
84+
public function checkToken(string $extId)
85+
{
86+
/*
87+
* This checker does not employ traditional ACLs for permission checks since it is trvial and it is better
88+
* to keep everything here (in one place). However, this may change in the future should the presenter get
89+
* more complex.
90+
* This action expects to be authenticated by temporary token generated in 'url' action.
91+
*/
92+
93+
// All users within this scope are allowed the operation...
94+
$this->isInScope(TokenScope::EXTENSIONS);
95+
96+
// ...but the token must be also valid...
97+
$token = $this->getAccessToken();
98+
$instanceId = $token->getPayload('instance');
99+
if ($token->getPayload('extension') !== $extId || !$instanceId) {
100+
throw new BadRequestException();
101+
}
102+
103+
// ...and the extension must be accessible by the user.
104+
$user = $this->getCurrentUser();
105+
$extension = $this->extensions->getExtension($extId);
106+
$instance = $this->instances->findOrThrow($instanceId);
107+
if (!$extension || !$extension->isAccessible($instance, $user)) {
108+
throw new ForbiddenRequestException();
109+
}
110+
}
111+
112+
/**
113+
* This endpoint is used by a backend of an extension to get a proper access token
114+
* (from a temp token passed via URL). It also returns details about authenticated user.
115+
* @POST
116+
*/
117+
public function actionToken(string $extId)
118+
{
119+
$user = $this->getCurrentUser();
120+
$extension = $this->extensions->getExtension($extId);
121+
$authUser = $extension->getTokenUserId() ? $this->users->findOrThrow($extension->getTokenUserId()) : $user;
122+
123+
$token = $this->accessManager->issueToken(
124+
$authUser,
125+
null,
126+
$extension->getTokenScopes(),
127+
$extension->getTokenExpiration(),
128+
);
129+
130+
$this->sendSuccessResponse([
131+
"accessToken" => $token,
132+
"user" => $this->userViewFactory->getFullUser($user, false /* do not show really everything */),
133+
]);
134+
}
135+
}

app/V1Module/presenters/InstancesPresenter.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@
44

55
use App\Exceptions\ForbiddenRequestException;
66
use App\Exceptions\NotFoundException;
7-
use App\Model\Entity\Group;
87
use App\Model\Entity\LocalizedGroup;
9-
use App\Model\Entity\User;
108
use App\Model\View\GroupViewFactory;
119
use App\Model\View\InstanceViewFactory;
1210
use App\Model\View\UserViewFactory;

app/V1Module/presenters/LoginPresenter.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
use App\Model\Repository\SecurityEvents;
1616
use App\Model\Repository\Users;
1717
use App\Model\View\UserViewFactory;
18-
use App\Security\AccessToken;
1918
use App\Security\AccessManager;
2019
use App\Security\ACL\IUserPermissions;
2120
use App\Security\CredentialsAuthenticator;
@@ -257,7 +256,7 @@ public function actionIssueRestrictedToken()
257256
$this->sendSuccessResponse(
258257
[
259258
"accessToken" => $this->accessManager->issueToken($user, $effectiveRole, $scopes, $expiration),
260-
"user" => $this->userViewFactory->getFullUser($user)
259+
"user" => $this->userViewFactory->getFullUser($user),
261260
]
262261
);
263262
}

app/V1Module/presenters/base/BasePresenter.php

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -137,19 +137,26 @@ protected function isRequestJson(): bool
137137
}
138138

139139
/**
140-
* @return User
141-
* @throws ForbiddenRequestException
140+
* @return User|null (null if no user is authenticated)
142141
*/
143-
protected function getCurrentUser(): User
142+
protected function getCurrentUserOrNull(): ?User
144143
{
145144
/** @var ?Identity $identity */
146145
$identity = $this->getUser()->getIdentity();
146+
return $identity?->getUserData();
147+
}
147148

148-
if ($identity === null || $identity->getUserData() === null) {
149+
/**
150+
* @return User
151+
* @throws ForbiddenRequestException
152+
*/
153+
protected function getCurrentUser(): User
154+
{
155+
$user = $this->getCurrentUserOrNull();
156+
if ($user === null) {
149157
throw new ForbiddenRequestException();
150158
}
151-
152-
return $identity->getUserData();
159+
return $user;
153160
}
154161

155162
/**

app/V1Module/router/RouterFactory.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ public static function createRouter()
5858
$router[] = self::createWorkerFilesRoutes("$prefix/worker-files");
5959
$router[] = self::createAsyncJobsRoutes("$prefix/async-jobs");
6060
$router[] = self::createPlagiarismRoutes("$prefix/plagiarism");
61+
$router[] = self::createExtensionsRoutes("$prefix/extensions");
6162

6263
return $router;
6364
}
@@ -664,4 +665,12 @@ private static function createPlagiarismRoutes(string $prefix): RouteList
664665
$router[] = new PostRoute("$prefix/<id>/<solutionId>", "Plagiarism:addSimilarities");
665666
return $router;
666667
}
668+
669+
private static function createExtensionsRoutes(string $prefix): RouteList
670+
{
671+
$router = new RouteList();
672+
$router[] = new GetRoute("$prefix/<extId>/<instanceId>", "Extensions:url");
673+
$router[] = new PostRoute("$prefix/<extId>", "Extensions:token");
674+
return $router;
675+
}
667676
}

app/V1Module/security/TokenScope.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,9 @@ class TokenScope
5656
* Usually used in combination with other scopes. Allows refreshing the token.
5757
*/
5858
public const REFRESH = "refresh";
59+
60+
/**
61+
* Scope for handling handshake and auth-exchange with 3rd party extensions. Temp tokens must use this scope.
62+
*/
63+
public const EXTENSIONS = "extensions";
5964
}

app/config/config.local.neon.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,9 @@ parameters:
8181
cs: "Český popisek"
8282
en: "English Caption"
8383
url: "https://extetrnal.domain.com/recodex/extension?token={token}&locale={locale}" # '{token}' and '{locale}' are placeholders
84+
urlTokenExpiration: 60 # [s] how long a temporary url token lasts
8485
token: # generated from tmp tokens passed via URL so the ext. tool can access ReCodEx API
86+
expiration: 86400 # [s] how long a full token lasts
8587
scopes: [ 'master', 'refresh' ] # list of scopes for generated tokens (to be used by the extension)
8688
user: null # user override (ID) for generating tokens (if null, the token will be generated for logged-in user)
8789
instances: [] # array of instances where this extension is enabled (empty array = all)

app/helpers/Emails/EmailVerificationHelper/EmailVerificationHelper.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
use App\Helpers\WebappLinks;
1313
use App\Security\TokenScope;
1414
use Exception;
15-
use Latte;
1615
use Nette\Utils\Arrays;
1716
use App\Model\Entity\User;
1817
use App\Security\AccessToken;

app/helpers/Extensions/ExtensionConfig.php

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,16 @@ class ExtensionConfig
3434
*/
3535
private string $url;
3636

37+
/**
38+
* Expiration time (in seconds) for temporary tokens.
39+
*/
40+
private int $urlTokenExpiration;
41+
42+
/**
43+
* Expiration time (in seconds) for full tokens.
44+
*/
45+
private int $tokenExpiration;
46+
3747
/**
3848
* List of scopes that will be set to (full) access tokens generated after tmp-token verification.
3949
* @var string[]
@@ -81,6 +91,8 @@ public function __construct(array $config)
8191
}
8292

8393
$this->url = Arrays::get($config, "url");
94+
$this->urlTokenExpiration = Arrays::get($config, "urlTokenExpiration", 60);
95+
$this->tokenExpiration = Arrays::get($config, ["token", "expiration"], 86400 /* one day */);
8496
$this->tokenScopes = Arrays::get(
8597
$config,
8698
["token", "scopes"],
@@ -97,6 +109,9 @@ public function getId(): string
97109
return $this->id;
98110
}
99111

112+
/**
113+
* @return string|array Either a universal caption or an array [ locale => localized-caption ]
114+
*/
100115
public function getCaption(): string|array
101116
{
102117
return $this->caption;
@@ -116,6 +131,39 @@ public function getUrl(string $token, string $locale): string
116131
return $url;
117132
}
118133

134+
/**
135+
* @return int expiration in seconds
136+
*/
137+
public function getUrlTokenExpiration(): int
138+
{
139+
return $this->urlTokenExpiration;
140+
}
141+
142+
/**
143+
* @return int expiration in seconds
144+
*/
145+
public function getTokenExpiration(): int
146+
{
147+
return $this->tokenExpiration;
148+
}
149+
150+
/**
151+
* @return string[] list of scopes assigned to a full token
152+
*/
153+
public function getTokenScopes(): array
154+
{
155+
return $this->tokenScopes;
156+
}
157+
158+
/**
159+
* Get ID of a user who should be used as an authority for full tokens.
160+
* @return string|null either user ID (for auth override) or null (current user)
161+
*/
162+
public function getTokenUserId(): ?string
163+
{
164+
return $this->tokenUserId;
165+
}
166+
119167
/**
120168
* Check whether this extension is accessible by given user in given instance.
121169
* @param Instance $instance

0 commit comments

Comments
 (0)