-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathAccessManager.php
More file actions
267 lines (237 loc) · 9.22 KB
/
AccessManager.php
File metadata and controls
267 lines (237 loc) · 9.22 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
<?php
namespace App\Security;
use App\Model\Entity\User;
use App\Model\Repository\Users;
use App\Exceptions\InvalidAccessTokenException;
use App\Exceptions\ForbiddenRequestException;
use App\Exceptions\FrontendErrorMappings;
use Nette\Http\IRequest;
use Nette\Http\IResponse;
use Nette\Utils\Strings;
use Nette\Utils\Arrays;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use DomainException;
use UnexpectedValueException;
use InvalidArgumentException;
class AccessManager
{
/** @var Users Users repository */
protected $users;
/** @var string Identification of the issuer of the token */
private $issuer;
/** @var string Identification of the audience of the token */
private $audience;
/** @var string Name of the algorithm currently used for encrypting the signature of the token. */
private $usedAlgorithm;
/** @var string Verification key */
private $verificationKey;
/** @var int Expiration time of newly issued tokens (in seconds) */
private $expiration;
/** @var int Expiration time of newly issued invitation tokens (in seconds) */
private $invitationExpiration;
/** @var string|null Name of the cookie where to look for the token */
private $tokenCookieName;
public function __construct(array $parameters, Users $users)
{
$this->users = $users;
$this->verificationKey = Arrays::get($parameters, "verificationKey");
if (!$this->verificationKey || strlen($this->verificationKey) < 32) {
throw new InvalidArgumentException("AccessManager verification key is not configured or too short");
}
$this->expiration = Arrays::get($parameters, "expiration", 24 * 60 * 60); // one day in seconds
$this->invitationExpiration = Arrays::get($parameters, "invitationExpiration", 24 * 60 * 60); // one day in sec
$this->issuer = Arrays::get($parameters, "issuer", "https://recodex.mff.cuni.cz");
$this->audience = Arrays::get($parameters, "audience", "https://recodex.mff.cuni.cz");
$this->usedAlgorithm = Arrays::get($parameters, "usedAlgorithm", "HS256");
$this->tokenCookieName = Arrays::get($parameters, "tokenCookieName", null);
JWT::$leeway = Arrays::get($parameters, "leeway", 10); // 10 seconds
}
public function getExpiration(): int
{
return $this->expiration;
}
/**
* Parse and validate a JWT token and extract the payload.
* @param string $token The potential JWT token
* @return AccessToken The decoded payload
* @throws ForbiddenRequestException
* @throws InvalidAccessTokenException
*/
public function decodeToken(string $token): AccessToken
{
try {
$decodedToken = JWT::decode($token, new Key($this->verificationKey, $this->usedAlgorithm));
} catch (DomainException $e) {
throw new InvalidAccessTokenException($token, $e);
} catch (UnexpectedValueException $e) {
throw new InvalidAccessTokenException($token, $e);
}
if (!isset($decodedToken->sub)) {
throw new InvalidAccessTokenException($token);
}
return new AccessToken($decodedToken);
}
/**
* Parse and validate a JWT invitation token and extract the payload.
* @param string $token The potential JWT token
* @return InvitationToken The decoded payload wrapped in token class
* @throws ForbiddenRequestException
* @throws InvalidAccessTokenException
*/
public function decodeInvitationToken(string $token): InvitationToken
{
try {
$decodedToken = JWT::decode($token, new Key($this->verificationKey, $this->usedAlgorithm));
} catch (DomainException $e) {
throw new InvalidAccessTokenException($token, $e);
} catch (UnexpectedValueException $e) {
throw new InvalidAccessTokenException($token, $e);
}
return new InvitationToken((array)$decodedToken);
}
/**
* @param AccessToken $token Valid JWT payload
* @return User
* @throws ForbiddenRequestException
*/
public function getUser(AccessToken $token): User
{
/** @var ?User $user */
$user = $this->users->get($token->getUserId());
if (!$user) {
throw new ForbiddenRequestException(
"Forbidden Request - User does not exist",
IResponse::S403_Forbidden,
FrontendErrorMappings::E403_001__USER_NOT_EXIST
);
}
if (!$user->isAllowed()) {
throw new ForbiddenRequestException(
"Forbidden Request - User account was disabled",
IResponse::S403_Forbidden,
FrontendErrorMappings::E403_002__USER_NOT_ALLOWED
);
}
return $user;
}
/**
* Issue a new JWT for the user with optional scopes and optional explicit expiration time.
* @param User $user
* @param string|null $effectiveRole Effective user role for issued token
* @param string[] $scopes Array of scopes
* @param int $exp Expiration of the token in seconds
* @param array $payload
* @return string
* @throws ForbiddenRequestException
*/
public function issueToken(
User $user,
?string $effectiveRole = null,
array $scopes = [],
?int $exp = null,
array $payload = []
) {
if (!$user->isAllowed()) {
throw new ForbiddenRequestException(
"Forbidden Request - User account was disabled",
IResponse::S403_Forbidden,
FrontendErrorMappings::E403_002__USER_NOT_ALLOWED
);
}
if ($exp === null) {
$exp = $this->expiration;
}
$token = new AccessToken(
(object)array_merge(
$payload,
[
"iss" => $this->issuer,
"aud" => $this->audience,
"iat" => time(),
"nbf" => time(),
"exp" => time() + $exp,
"sub" => $user->getId(),
"effrole" => $effectiveRole,
"scopes" => $scopes
]
)
);
return $token->encode($this->verificationKey, $this->usedAlgorithm);
}
public function issueRefreshedToken(AccessToken $token): string
{
return $this->issueToken(
$this->getUser($token),
null,
$token->getScopes(),
$token->getExpirationTime(),
$token->getPayloadData()
);
}
/**
* Create an invitation for a specific user pre-filling the basic user data and optionally
* allowing the user to join selected groups.
* @param string $instanceId
* @param string $email
* @param string $firstName
* @param string $lastName
* @param string $titlesBefore
* @param string $titlesAfter
* @param string[] $groupsIds list of IDs where the user is added after registration
* @param int|null $invitationExpiration token expiration duration override (for testing purposes only)
* @throws InvalidAccessTokenException if the data are not correct
*/
public function issueInvitationToken(
string $instanceId,
string $email,
string $firstName,
string $lastName,
string $titlesBefore = "",
string $titlesAfter = "",
array $groupsIds = [],
?int $invitationExpiration = null,
): string {
$token = InvitationToken::create(
$invitationExpiration ?? $this->invitationExpiration,
$instanceId,
$email,
$firstName,
$lastName,
$titlesBefore,
$titlesAfter,
$groupsIds,
);
return $token->encode($this->verificationKey, $this->usedAlgorithm);
}
/**
* Extract the access token from the request.
* @return string|null The access token parsed from the HTTP request, or null if there is no access token.
*/
public function getGivenAccessToken(IRequest $request)
{
$accessToken = $request->getQuery("access_token");
if ($accessToken !== null && Strings::length($accessToken) > 0) {
return $accessToken; // the token specified in the URL is preferred
}
// if the token is not in the URL, try to find the "Authorization" header with the bearer token
$authorizationHeader = $request->getHeader("Authorization");
if ($authorizationHeader !== null) {
$parts = Strings::split($authorizationHeader, "/ /");
if (count($parts) === 2) {
list($bearer, $accessToken) = $parts;
if ($bearer === "Bearer" && !str_contains($accessToken, " ") && Strings::length($accessToken) > 0) {
return $accessToken;
}
}
}
// finally, try fallback to cookie if configured
if ($this->tokenCookieName !== null) {
$cookieToken = $request->getCookie($this->tokenCookieName);
if ($cookieToken !== null && Strings::length($cookieToken) > 0) {
return $cookieToken; // token found in the cookie
}
}
return null; // there is no access token or it could not be parsed
}
}