Skip to content

Commit eaa561e

Browse files
committed
feat: add Authenticators\JWT
1 parent 793b645 commit eaa561e

File tree

10 files changed

+742
-0
lines changed

10 files changed

+742
-0
lines changed
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
<?php
2+
3+
namespace CodeIgniter\Shield\Authentication\Authenticators;
4+
5+
use CodeIgniter\HTTP\IncomingRequest;
6+
use CodeIgniter\I18n\Time;
7+
use CodeIgniter\Shield\Authentication\AuthenticationException;
8+
use CodeIgniter\Shield\Authentication\AuthenticatorInterface;
9+
use CodeIgniter\Shield\Authentication\Authenticators\JWT\Firebase;
10+
use CodeIgniter\Shield\Authentication\Authenticators\JWT\JWTDecoderInterface;
11+
use CodeIgniter\Shield\Entities\User;
12+
use CodeIgniter\Shield\Exceptions\RuntimeException;
13+
use CodeIgniter\Shield\Models\TokenLoginModel;
14+
use CodeIgniter\Shield\Models\UserModel;
15+
use CodeIgniter\Shield\Result;
16+
use InvalidArgumentException;
17+
use stdClass;
18+
19+
/**
20+
* Stateless JWT Authenticator
21+
*/
22+
class JWT implements AuthenticatorInterface
23+
{
24+
/**
25+
* @var string Special ID Type.
26+
* This Authenticator is stateless, so no `auth_identities` record.
27+
*/
28+
public const ID_TYPE_JWT = 'jwt';
29+
30+
/**
31+
* The persistence engine
32+
*/
33+
protected UserModel $provider;
34+
35+
protected ?User $user = null;
36+
protected JWTDecoderInterface $jwtDecoder;
37+
protected TokenLoginModel $loginModel;
38+
protected ?stdClass $payload = null;
39+
40+
public function __construct(UserModel $provider, ?JWTDecoderInterface $jwtDecoder = null)
41+
{
42+
$this->provider = $provider;
43+
$this->jwtDecoder = $jwtDecoder ?? new Firebase();
44+
45+
$this->loginModel = model(TokenLoginModel::class); // @phpstan-ignore-line
46+
}
47+
48+
/**
49+
* Attempts to authenticate a user with the given $credentials.
50+
* Logs the user in with a successful check.
51+
*
52+
* @param array{token?: string} $credentials
53+
*/
54+
public function attempt(array $credentials): Result
55+
{
56+
/** @var IncomingRequest $request */
57+
$request = service('request');
58+
59+
$ipAddress = $request->getIPAddress();
60+
$userAgent = $request->getUserAgent();
61+
62+
$result = $this->check($credentials);
63+
64+
if (! $result->isOK()) {
65+
// Always record a login attempt, whether success or not.
66+
$this->loginModel->recordLoginAttempt(
67+
self::ID_TYPE_JWT,
68+
$credentials['token'] ?? '',
69+
false,
70+
$ipAddress,
71+
$userAgent
72+
);
73+
74+
return $result;
75+
}
76+
77+
$user = $result->extraInfo();
78+
79+
$this->login($user);
80+
81+
$this->loginModel->recordLoginAttempt(
82+
self::ID_TYPE_JWT,
83+
$credentials['token'] ?? '',
84+
true,
85+
$ipAddress,
86+
$userAgent,
87+
$this->user->id
88+
);
89+
90+
return $result;
91+
}
92+
93+
/**
94+
* Checks a user's $credentials to see if they match an
95+
* existing user.
96+
*
97+
* In this case, $credentials has only a single valid value: token,
98+
* which is the plain text token to return.
99+
*
100+
* @param array{token?: string} $credentials
101+
*/
102+
public function check(array $credentials): Result
103+
{
104+
if (! array_key_exists('token', $credentials) || $credentials['token'] === '') {
105+
return new Result([
106+
'success' => false,
107+
'reason' => lang('Auth.noToken'),
108+
]);
109+
}
110+
111+
if (strpos($credentials['token'], 'Bearer') === 0) {
112+
$credentials['token'] = trim(substr($credentials['token'], 6));
113+
}
114+
115+
// Check JWT
116+
try {
117+
$this->payload = $this->decodeJWT($credentials['token']);
118+
} catch (RuntimeException $e) {
119+
return new Result([
120+
'success' => false,
121+
'reason' => $e->getMessage(),
122+
]);
123+
}
124+
125+
$userId = $this->payload->sub ?? null;
126+
127+
if ($userId === null) {
128+
return new Result([
129+
'success' => false,
130+
'reason' => 'Invalid JWT: no user_id',
131+
]);
132+
}
133+
134+
// Find User
135+
$user = $this->provider->findById($userId);
136+
137+
if ($user === null) {
138+
return new Result([
139+
'success' => false,
140+
'reason' => lang('Auth.invalidUser'),
141+
]);
142+
}
143+
144+
return new Result([
145+
'success' => true,
146+
'extraInfo' => $user,
147+
]);
148+
}
149+
150+
/**
151+
* Checks if the user is currently logged in.
152+
* Since AccessToken usage is inherently stateless,
153+
* it runs $this->attempt on each usage.
154+
*/
155+
public function loggedIn(): bool
156+
{
157+
if ($this->user !== null) {
158+
return true;
159+
}
160+
161+
/** @var IncomingRequest $request */
162+
$request = service('request');
163+
164+
return $this->attempt([
165+
'token' => $request->getHeaderLine(config('Auth')->authenticatorHeader['jwt']),
166+
])->isOK();
167+
}
168+
169+
/**
170+
* Logs the given user in by saving them to the class.
171+
*/
172+
public function login(User $user): void
173+
{
174+
$this->user = $user;
175+
}
176+
177+
/**
178+
* Logs a user in based on their ID.
179+
*
180+
* @param int|string $userId
181+
*
182+
* @throws AuthenticationException
183+
*/
184+
public function loginById($userId): void
185+
{
186+
$user = $this->provider->findById($userId);
187+
188+
if ($user === null) {
189+
throw AuthenticationException::forInvalidUser();
190+
}
191+
192+
$this->login($user);
193+
}
194+
195+
/**
196+
* Logs the current user out.
197+
*/
198+
public function logout(): bool
199+
{
200+
$this->user = null;
201+
202+
return true;
203+
}
204+
205+
/**
206+
* Returns the currently logged in user.
207+
*/
208+
public function getUser(): ?User
209+
{
210+
return $this->user;
211+
}
212+
213+
/**
214+
* Updates the user's last active date.
215+
*/
216+
public function recordActiveDate(): void
217+
{
218+
if (! $this->user instanceof User) {
219+
throw new InvalidArgumentException(
220+
__METHOD__ . '() requires logged in user before calling.'
221+
);
222+
}
223+
224+
$this->user->last_active = Time::now();
225+
226+
$this->provider->save($this->user);
227+
}
228+
229+
/**
230+
* Returns payload of the JWT
231+
*/
232+
public function decodeJWT(string $encodedToken): stdClass
233+
{
234+
return $this->jwtDecoder->decode($encodedToken);
235+
}
236+
237+
/**
238+
* Returns payload
239+
*/
240+
public function getPayload(): ?stdClass
241+
{
242+
return $this->payload;
243+
}
244+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
namespace CodeIgniter\Shield\Authentication\Authenticators\JWT;
4+
5+
use CodeIgniter\Shield\Exceptions\RuntimeException;
6+
use DomainException;
7+
use Firebase\JWT\BeforeValidException;
8+
use Firebase\JWT\ExpiredException;
9+
use Firebase\JWT\JWT;
10+
use Firebase\JWT\Key;
11+
use Firebase\JWT\SignatureInvalidException;
12+
use InvalidArgumentException;
13+
use stdClass;
14+
use UnexpectedValueException;
15+
16+
class Firebase implements JWTDecoderInterface
17+
{
18+
/**
19+
* Decode JWT
20+
*
21+
* @return stdClass Payload
22+
*/
23+
public static function decode(string $encodedToken): stdClass
24+
{
25+
$config = setting('Auth.jwtConfig');
26+
27+
$key = $config['secretKey'];
28+
$algorithm = $config['algorithm'];
29+
30+
try {
31+
return JWT::decode($encodedToken, new Key($key, $algorithm));
32+
} catch (BeforeValidException|ExpiredException $e) {
33+
throw new RuntimeException('Expired JWT: ' . $e->getMessage(), 0, $e);
34+
} catch (
35+
InvalidArgumentException|DomainException|UnexpectedValueException
36+
|SignatureInvalidException $e
37+
) {
38+
throw new RuntimeException('Invalid JWT: ' . $e->getMessage(), 0, $e);
39+
}
40+
}
41+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
namespace CodeIgniter\Shield\Authentication\Authenticators\JWT;
4+
5+
use stdClass;
6+
7+
interface JWTDecoderInterface
8+
{
9+
/**
10+
* Decode JWT
11+
*
12+
* @return stdClass Payload
13+
*/
14+
public static function decode(string $encodedToken): stdClass;
15+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
namespace CodeIgniter\Shield\Authentication\TokenGenerator\JWT;
4+
5+
use Firebase\JWT\JWT;
6+
7+
class Firebase implements JWTGeneratorInterface
8+
{
9+
/**
10+
* Issues JWT
11+
*
12+
* @param string $key
13+
*/
14+
public static function generate(array $payload, $key, string $algorithm): string
15+
{
16+
return JWT::encode($payload, $key, $algorithm);
17+
}
18+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
namespace CodeIgniter\Shield\Authentication\TokenGenerator\JWT;
4+
5+
interface JWTGeneratorInterface
6+
{
7+
/**
8+
* Issues JWT
9+
*
10+
* @param string $key The secret key.
11+
*/
12+
public static function generate(array $payload, $key, string $algorithm): string;
13+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
namespace CodeIgniter\Shield\Authentication\TokenGenerator;
4+
5+
use CodeIgniter\I18n\Time;
6+
use CodeIgniter\Shield\Authentication\TokenGenerator\JWT\Firebase;
7+
use CodeIgniter\Shield\Authentication\TokenGenerator\JWT\JWTGeneratorInterface;
8+
use CodeIgniter\Shield\Entities\User;
9+
10+
class JWTGenerator
11+
{
12+
private Time $currentTime;
13+
private JWTGeneratorInterface $jwtGenerator;
14+
15+
public function __construct(?Time $currentTime = null, ?JWTGeneratorInterface $jwtGenerator = null)
16+
{
17+
$this->currentTime = $currentTime ?? new Time();
18+
$this->jwtGenerator = $jwtGenerator ?? new Firebase();
19+
}
20+
21+
/**
22+
* Issues JWT Access Token
23+
*/
24+
public function generateAccessToken(User $user): string
25+
{
26+
$config = setting('Auth.jwtConfig');
27+
28+
$iat = $this->currentTime->getTimestamp();
29+
$exp = $iat + $config['timeToLive'];
30+
31+
$payload = [
32+
'iss' => $config['issuer'], // issuer
33+
'aud' => $config['audience'], // audience
34+
'sub' => (string) $user->id, // subject
35+
'iat' => $iat, // issued at
36+
'exp' => $exp, // expiration time
37+
];
38+
39+
return $this->jwtGenerator->generate($payload, $config['secretKey'], $config['algorithm']);
40+
}
41+
}

0 commit comments

Comments
 (0)