Skip to content

Commit b8e9e9a

Browse files
committed
Added JwtAutoProvisionAuth behavior for controller and module
1 parent 9bbc103 commit b8e9e9a

File tree

2 files changed

+293
-1
lines changed

2 files changed

+293
-1
lines changed

README.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ class User extends \Da\User\Model\User {
261261
*/
262262
public static function findIdentityByAccessToken($token, $type = null)
263263
{
264+
// use dmstr\usuario\keycloak\behaviors\JwtAutoProvisionAuth if you want to auto creat the user. Module must be configured. See section `JwtAutoProvisionAuth`
264265
if ($type === JwtHttpBearerAuth::class) {
265266
/** @var Plain $jwtToken */
266267
$jwtToken = Yii::$app->jwt->getParser()->parse((string)$token);
@@ -454,6 +455,32 @@ $authCollectionComponent$
454455
$tokenParam$
455456
<br>Parameter used to extract the Token used for role checking, defaults to 'access_token'
456457

458+
## JwtAutoProvisionAuth
459+
460+
`JwtAutoProvisionAuth` is an authentication filter that automatically creates user accounts when someone logs in with a valid JWT token from auth client. If a user with the token's email doesn't exist in the
461+
system, it creates a new user account and links it to the Keycloak identity; if the user already exists, it just connects the auth client account to the existing user. This allows seamless user onboarding
462+
where people can access the application immediately using their auth client credentials without manual account creation.
463+
464+
It is auto configured to use a jwt component named `jwt` and a auth client named `keycloak`
465+
466+
Example usage in a `yii\rest\Controller` or `yii\base\Module`
467+
468+
```php
469+
use dmstr\usuario\keycloak\behaviors\JwtAutoProvisionAuth
470+
471+
public function behaviors(): array
472+
{
473+
$behaviors = parent::behaviors();
474+
$behaviors['authenticator'] = [
475+
'class' => JwtAutoProvisionAuth::class,
476+
'jwt' => 'jwt', // your JWT component ID
477+
'authClientId' => 'keycloak', // your auth client ID
478+
'debug' => false
479+
];
480+
return $behaviors;
481+
}
482+
```
483+
457484
### Configuration
458485

459486
The parameters mentioned above can be configured like this
@@ -469,4 +496,4 @@ use dmstr\usuario\keycloak\auth\TokenRoleRule;
469496
]
470497
]
471498
]
472-
```
499+
```
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
<?php
2+
3+
namespace dmstr\usuario\keycloak\behaviors;
4+
5+
use bizley\jwt\JwtTools;
6+
use Da\User\Event\UserEvent;
7+
use Da\User\Model\SocialNetworkAccount;
8+
use Da\User\Model\User;
9+
use Da\User\Traits\ContainerAwareTrait;
10+
use Exception;
11+
use Yii;
12+
use yii\authclient\ClientInterface;
13+
use yii\authclient\Collection;
14+
use yii\base\InvalidArgumentException;
15+
use yii\base\InvalidConfigException;
16+
use yii\db\Exception as DbException;
17+
use yii\di\Instance;
18+
use yii\filters\auth\HttpBearerAuth;
19+
use yii\helpers\Json;
20+
use yii\web\BadRequestHttpException;
21+
use yii\web\IdentityInterface;
22+
use yii\web\UnauthorizedHttpException;
23+
use yii\web\UnprocessableEntityHttpException;
24+
25+
class JwtAutoProvisionAuth extends HttpBearerAuth
26+
{
27+
use ContainerAwareTrait;
28+
29+
/**
30+
* component id of the jwt component
31+
*/
32+
public JwtTools|string $jwt = 'jwt';
33+
34+
/**
35+
* ID of the auth client
36+
*/
37+
public string $authClientId = 'keycloak';
38+
39+
/**
40+
* component id of the auth client collection component
41+
*/
42+
public string|Collection $authClientCollection = 'authClientCollection';
43+
44+
private ClientInterface $_authClient;
45+
46+
/**
47+
* enable or disable debug logging messages
48+
*/
49+
public bool $debug = false;
50+
51+
/**
52+
* @throws \yii\base\InvalidConfigException If configuration is not correct
53+
*/
54+
public function init()
55+
{
56+
parent::init();
57+
58+
$this->authClientCollection = Instance::ensure($this->authClientCollection, Collection::class);
59+
60+
try {
61+
$this->_authClient = $this->authClientCollection->getClient($this->authClientId);
62+
} catch (InvalidArgumentException) {
63+
throw new InvalidConfigException('authClientId does not exist');
64+
}
65+
66+
if (is_string($this->jwt)) {
67+
$this->jwt = Instance::ensure($this->jwt, JwtTools::class);
68+
}
69+
70+
if (!$this->jwt instanceof JwtTools) {
71+
throw new InvalidConfigException('jwt must be instance of ' . JwtTools::class);
72+
}
73+
}
74+
75+
/**
76+
* @throws \yii\web\BadRequestHttpException
77+
* @throws \yii\web\UnauthorizedHttpException
78+
* @throws \yii\web\UnprocessableEntityHttpException
79+
* @return \yii\web\IdentityInterface|null
80+
*/
81+
public function authenticate($user, $request, $response)
82+
{
83+
$authHeader = $request->getHeaders()->get($this->header);
84+
85+
// Header is not set
86+
if ($authHeader === null) {
87+
throw new BadRequestHttpException(Yii::t('usuario-keycloak', '{header} header is not set', [
88+
'header' => $this->header
89+
]));
90+
}
91+
92+
// Header value does not match bearer pattern
93+
if (preg_match((string)$this->pattern, $authHeader, $matches) === false) {
94+
throw new BadRequestHttpException(Yii::t('usuario-keycloak', 'Token is not set'));
95+
}
96+
97+
// JWT in string form
98+
$authHeaderValue = $matches[1] ?? null;
99+
100+
$this->logDebug($authHeaderValue);
101+
102+
if (!is_string($authHeaderValue)) {
103+
throw new BadRequestHttpException(Yii::t('usuario-keycloak', 'Token is invalid'));
104+
}
105+
106+
// Check if the token is valid
107+
try {
108+
Yii::$app->jwt->assert($authHeaderValue);
109+
} catch (Exception $exception) {
110+
$this->logException($exception);
111+
throw new UnauthorizedHttpException(Yii::t('usuario-keycloak', 'Token constraint failed'));
112+
}
113+
114+
$existingIdentity = $user->loginByAccessToken($authHeaderValue, get_class($this));
115+
116+
// Does the identity exist? Good.
117+
if ($existingIdentity instanceof IdentityInterface) {
118+
$this->logInfo('Logging in existing user #' . $existingIdentity->getId());
119+
return $existingIdentity;
120+
}
121+
122+
$newIdentity = $this->createOrConnectUserFromToken($authHeaderValue);
123+
124+
// There was an error creating a new user?
125+
if ($newIdentity === null) {
126+
throw new UnprocessableEntityHttpException(Yii::t('usuario-keycloak', 'Unable to process the request'));
127+
}
128+
129+
// try again with newly created user
130+
$this->logInfo('Logging in new user #' . $newIdentity->getId());
131+
return $user->loginByAccessToken($authHeaderValue, get_class($this));
132+
}
133+
134+
protected function getAuthClient(): ClientInterface
135+
{
136+
return $this->_authClient;
137+
}
138+
139+
protected function logException(Exception $exception): void
140+
{
141+
if (Yii::$app->hasModule('audit')) {
142+
Yii::$app->getModule('audit')->exception($exception);
143+
} else {
144+
Yii::error($exception->getMessage());
145+
}
146+
}
147+
148+
protected function logInfo(mixed $data): void
149+
{
150+
if (Yii::$app->hasModule('audit')) {
151+
Yii::$app->getModule('audit')->data('info', $data);
152+
} else {
153+
Yii::info($data);
154+
}
155+
}
156+
157+
protected function logError(string $message): void
158+
{
159+
if (Yii::$app->hasModule('audit')) {
160+
Yii::$app->getModule('audit')->errorMessage($message);
161+
} else {
162+
Yii::error($message);
163+
}
164+
}
165+
166+
protected function logDebug(string $message): void
167+
{
168+
if ($this->debug) {
169+
if (Yii::$app->hasModule('audit')) {
170+
Yii::$app->getModule('audit')->data('debug', $message);
171+
} else {
172+
Yii::debug($message);
173+
}
174+
}
175+
}
176+
177+
protected function createOrConnectUserFromToken(string $jwt): IdentityInterface|null
178+
{
179+
// token should be valid at this point so there should be no error here except if the token expired in the last few milliseconds
180+
$token = $this->jwt->getParser()->parse($jwt);
181+
182+
$claims = $token->claims();
183+
184+
$email = $claims->get('email');
185+
186+
// Check if a user with email form claim exists so we can connect it
187+
$user = $this->make(User::class)::findOne(['email' => $email]);
188+
189+
$transaction = $this->make(User::class)::getDb()->beginTransaction();
190+
191+
if ($user === null) {
192+
// create user
193+
$this->logInfo('Creating new user based of given jwt');
194+
/** @var User $user */
195+
$user = $this->make(User::class, [], [
196+
'scenario' => 'connect',
197+
'username' => $claims->get('preferred_username', $claims->get('sub')),
198+
'email' => $email, // Must be present in the token
199+
'password_hash' => 'x', // field is required.
200+
'confirmed_at' => time()
201+
]);
202+
203+
/** @var UserEvent $event */
204+
$event = $this->make(UserEvent::class, [$user]);
205+
206+
$user->trigger(UserEvent::EVENT_BEFORE_REGISTER, $event);
207+
if (!$user->save()) {
208+
$transaction->rollBack();
209+
$this->logError('Error creating user');
210+
$this->logInfo($user->getErrors());
211+
return null;
212+
}
213+
$isNewUser = true;
214+
$this->logInfo('User created');
215+
} else {
216+
$isNewUser = false;
217+
$this->logInfo('User does already exist');
218+
}
219+
220+
$this->logInfo('Going to connect social network account');
221+
222+
// create and attach social account
223+
/** @var SocialNetworkAccount $socialNetworkAccount */
224+
$socialNetworkAccount = $this->make(SocialNetworkAccount::class, [], [
225+
'provider' => $this->getAuthClient()->getId(),
226+
'client_id' => $claims->get('sub'),
227+
'data' => Json::encode($claims->all()),
228+
'user_id' => $user->id,
229+
'username' => $user->username,
230+
'email' => $user->email
231+
]);
232+
233+
// No events for social network account here because in the original connect service the event is triggered on the controller and not on the model
234+
235+
// we need to wrap this in a try-catch block as there are no rules in this model...
236+
try {
237+
if (!$socialNetworkAccount->save()) {
238+
$transaction->rollBack();
239+
$this->logError('Error connect social network account');
240+
$this->logInfo($socialNetworkAccount->getErrors());
241+
return null;
242+
}
243+
} catch (DbException $exception) {
244+
$this->logError('Error creating social network account');
245+
$this->logException($exception);
246+
return null;
247+
}
248+
249+
$this->logInfo('Connected social network account to user');
250+
251+
try {
252+
$transaction->commit();
253+
// trigger this but only transaction is successful and is new user
254+
if ($isNewUser) {
255+
/** @var UserEvent $event */
256+
$event = $this->make(UserEvent::class, [$user]);
257+
$user->trigger(UserEvent::EVENT_AFTER_REGISTER, $event);
258+
}
259+
return $user;
260+
} catch (DbException $exception) {
261+
$this->logException($exception);
262+
}
263+
return null;
264+
}
265+
}

0 commit comments

Comments
 (0)