From 7815b699c039d050222f58d5a68401582a878b81 Mon Sep 17 00:00:00 2001 From: Mike Pierce Date: Sun, 6 Oct 2019 19:05:04 +0200 Subject: [PATCH] 0.2.0 (#9) --- CHANGELOG.md | 58 +++++----------- README.md | 15 +++-- composer.json | 2 +- src/CraftJwtAuth.php | 95 ++++---------------------- src/services/JWT.php | 155 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 196 insertions(+), 129 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 638741f..0e91851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,51 +2,45 @@ All notable changes to this project will be documented in this file, in reverse chronological order by release. -## 0.1.2 - 2019-10-05 +## 0.2.0 - 2019-10-06 ### Added -- Additional instructions to the PR template - -### Changed +- `JWT::getJWTFromRequest()`: Attempts to find a JWT in request headers and return it. -- Nothing. +- `JWT::parseAndVerifyJWT()`: Attempts to parse a JWT and verify that it is signed using the shared secret. -### Deprecated +- `JWT::parseJWT()`: Attempts to parse a JWT string. -- Nothing. +- `JWT::verifyJWT()`: Attempts to verify a JWT token with the shared secret key. -### Removed +- `JWT::getUserByJWT()`: Looks for a Craft user that matches the claimed email in the verified token. -- Deleted some unneeded asset files +- `JWT::createUserByJWT()`: Creates a Craft user based on the claims found in the verified token. -- Cleaned up various files of unneeded cruft +- Roadmap to 1.0 in README -### Fixed +### Changed -- Nothing. +- Refactored authentication logic into service calls -## 0.1.1 - 2019-10-04 +## 0.1.2 - 2019-10-05 ### Added -- Nothing. - -### Changed - -- Changed the name of the package to edenspiekermann/craft-jwt-auth +- Additional instructions to the PR template -### Deprecated +### Removed -- Nothing. +- Deleted some unneeded asset files -### Removed +- Cleaned up various files of unneeded cruft -- Nothing. +## 0.1.1 - 2019-10-04 -### Fixed +### Changed -- Nothing. +- Changed the name of the package to edenspiekermann/craft-jwt-auth ## 0.1.0 - 2019-10-04 @@ -59,19 +53,3 @@ All notable changes to this project will be documented in this file, in reverse - Match a validated JWT to a user account in Craft CMS and login as that user. - Optionally create a new account if no existing account can be found. - -### Changed - -- Nothing. - -### Deprecated - -- Nothing. - -### Removed - -- Nothing. - -### Fixed - -- Nothing. diff --git a/README.md b/README.md index 4a5c40b..3613705 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ If the token is verifiable but a matching user account does NOT exist, but the ` ## Craft JWT Auth Roadmap +### Features + The plugin does or will offer the following features: - [x] Validate incoming requests with a JWT present in the Authentication headers. @@ -66,13 +68,16 @@ The plugin does or will offer the following features: - [x] Optionally create a new account if no existing account can be found. - [ ] Generate a JWT from a user’s account data to enable sharing with other services that implement the same secret key. +### Milestones + While the plugin is already useable, it is by no means finished. Use at your own risk. Some things to do before I'm comfortable taking it to version 1.0.0: -- [ ] Better error and exception handling in general. -- [ ] Better testing for the presence of an actual JWT, rather than some other type of token. -- [ ] Checking for the presence of valid claims and handling if they aren't there. -- [ ] Handle edge case of successful user creation but failed image creation. -- [ ] Add test cases for all of that. +- [ ] `0.2.0` Refactor into more logical set of services and classes. +- [ ] `0.3.0` Better testing for the presence of an actual JWT, rather than some other type of token. +- [ ] `0.3.1` Checking for the presence of valid claims and handling if they aren't there. +- [ ] `0.3.2` Handle edge case of successful user creation but failed image creation. +- [ ] `0.3.3` Better exception handling in general. +- [ ] `0.4.0` Add test cases for all of that. - [ ] Have really smart people review the code for vulnerabilities. - [ ] Other stuff I haven't though of because I haven't done 👆 yet. diff --git a/composer.json b/composer.json index 06c6c5b..6cd5940 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "edenspiekermann/craft-jwt-auth", "description": "Enable authentication to Craft through the use of JSON Web Tokens (JWT)", "type": "craft-plugin", - "version": "0.1.2", + "version": "0.2.0", "keywords": [ "craft", "cms", diff --git a/src/CraftJwtAuth.php b/src/CraftJwtAuth.php index 6971156..879b4d9 100644 --- a/src/CraftJwtAuth.php +++ b/src/CraftJwtAuth.php @@ -16,11 +16,7 @@ use Craft; use craft\base\Plugin; -use craft\elements\User; -use craft\helpers\StringHelper; use craft\web\Application; -use Lcobucci\JWT\Parser; -use Lcobucci\JWT\Signer\Hmac\Sha256; use yii\base\Event; @@ -63,88 +59,21 @@ public function init() self::$plugin = $this; Craft::$app->on(Application::EVENT_INIT, function (Event $event) { - // Get relevant settings - $secretKey = self::$plugin->getSettings()->secretKey; - $autoCreateUser = self::$plugin->getSettings()->autoCreateUser; - $allowPublicRegistration = Craft::$app->getProjectConfig()->get('users.allowPublicRegistration') ?: false; + $token = self::$plugin->jWT->parseAndVerifyJWT(self::$plugin->jWT->getJWTFromRequest()); - // Look for an access token in the settings - $accessToken = Craft::$app->request->headers->get('authorization') ?: Craft::$app->request->headers->get('x-access-token'); + // If the token passes verification... + if ($token) { + // Look for the user + $user = self::$plugin->jWT->getUserByJWT($token); - // If "Bearer " is present, strip it to get the token. - if (StringHelper::startsWith($accessToken, 'Bearer ')) { - $accessToken = StringHelper::substr($accessToken, 7); - } - - - // If we find one, and it looks like a JWT... - if ($accessToken && count(explode('.', $accessToken)) === 3) { - // Attempt to parse the token - $token = (new Parser())->parse((string) $accessToken); - - // Attempt to verify the token - $signer = new Sha256(); - $verify = $token->verify($signer, $secretKey); - - // If the token passes verification... - if ($verify) { - // Derive the username from the subject in the token - $userName = $token->getClaim('sub'); - - // Look for the user - $user = Craft::$app->users->getUserByUsernameOrEmail($userName); - - // If we don't have a user, but we're allowed to create one... - if (!$user && $autoCreateUser && $allowPublicRegistration) { - // Create a new user and populate with claims - $newUser = new User(); - $newUser->username = $userName; - $newUser->email = $token->getClaim('email'); - $newUser->firstName = $token->getClaim('given_name') ?: ''; - $newUser->lastName = $token->getClaim('family_name') ?: ''; - - // Attempt to save the user - $newUserSuccess = Craft::$app->getElements()->saveElement($newUser); - - // If user saved ok... - if ($newUserSuccess) { - // Assign the user to the default public group - Craft::$app->users->assignUserToDefaultGroup($newUser); - - // Look for a picture in the claim - $picture = $token->getClaim('picture'); - - // If there is a picture... - if ($picture) { - // Create a guzzel client - $guzzle = Craft::createGuzzleClient(); - - // Attempt to fetch the image - $imageUpload = $guzzle->get($picture); - - // Derive the file extension from the content type - $ext = self::$plugin->jWT->mime2ext($imageUpload->getHeader('Content-Type')); - - // Make a filename from the username, and add some randomness - $fileName = $userName . StringHelper::randomString() . '.' . $ext; - $tempFile = Craft::$app->path->getTempAssetUploadsPath() . '/' . $fileName; - - // Fetch it again, this time saving it to a temp file - $imageUpload = $guzzle->get($picture, ['save_to' => $tempFile]); - - // Save the tempfile to the user’s account as profile image - Craft::$app->getUsers()->saveUserPhoto($tempFile, $newUser, $fileName); - } - - // Switch our unfound user to our newly created user - $user = $newUser; - } - } + // If we don't have a user, but we're allowed to create one... + if (!$user) { + $user = self::$plugin->jWT->createUserByJWT($token); + } - // Attempt to login as the user we have found or created - if ($user->id) { - Craft::$app->user->loginByUserId($user->id); - } + // Attempt to login as the user we have found or created + if ($user->id) { + Craft::$app->user->loginByUserId($user->id); } } }); diff --git a/src/services/JWT.php b/src/services/JWT.php index d36443b..1bbb9f4 100644 --- a/src/services/JWT.php +++ b/src/services/JWT.php @@ -11,8 +11,15 @@ namespace edenspiekermann\craftjwtauth\services; +use Craft; use craft\base\Component; +use craft\elements\User; +use craft\helpers\StringHelper; use craft\helpers\ArrayHelper; +use edenspiekermann\craftjwtauth\CraftJwtAuth; +use Lcobucci\JWT\Parser; +use Lcobucci\JWT\Signer\Hmac\Sha256; +use Lcobucci\JWT\Token; /** * @author Mike Pierce @@ -24,6 +31,154 @@ class JWT extends Component // Public Methods // ========================================================================= + /* + * @return mixed + */ + public function getJWTFromRequest() + { + // Look for an access token in the settings + $accessToken = Craft::$app->request->headers->get('authorization') ?: Craft::$app->request->headers->get('x-access-token'); + + // If "Bearer " is present, strip it to get the token. + if (StringHelper::startsWith($accessToken, 'Bearer ')) { + $accessToken = StringHelper::substr($accessToken, 7); + } + + // If we find one, and it looks like a JWT... + if ($accessToken) { + return $accessToken; + } + + return null; + } + + /* + * @return mixed + */ + public function parseAndVerifyJWT($accessToken) + { + $token = $this->parseJWT($accessToken); + + if ($token && $this->verifyJWT($token)) { + return $token; + } + + return null; + } + + /* + * @return mixed + */ + public function parseJWT($accessToken) + { + if (count(explode('.', $accessToken)) === 3) { + $token = (new Parser())->parse((string) $accessToken); + + return $token; + } + + return null; + } + + /* + * @return mixed + */ + public function verifyJWT(Token $token) + { + $secretKey = CraftJwtAuth::getInstance()->getSettings()->secretKey; + + // Attempt to verify the token + $verify = $token->verify((new Sha256()), $secretKey); + + return $verify; + } + + /* + * @return mixed + */ + public function getUserByJWT(Token $token) + { + if ($this->verifyJWT($token)) { + // Derive the username from the subject in the token + $email = $token->getClaim('email', ''); + $userName = $token->getClaim('sub', ''); + + // Look for the user with email + $user = Craft::$app->users->getUserByUsernameOrEmail($email ?: $userName); + + return $user; + } + + return null; + } + + /* + * @return mixed + */ + public function createUserByJWT(Token $token) + { + if ($this->verifyJWT($token)) { + // Get relevant settings + $autoCreateUser = CraftJwtAuth::getInstance()->getSettings()->autoCreateUser + && Craft::$app->getProjectConfig()->get('users.allowPublicRegistration') + ?: false; + + if ($autoCreateUser) { + // Create a new user and populate with claims + $user = new User(); + + // Email is a mandatory field + if ($token->hasClaim('email')) { + $email = $token->getClaim('email'); + + // Set username and email + $user->email = $email; + $user->username = $email; + + // These are optional, so pass empty string as the default + $user->firstName = $token->getClaim('given_name', ''); + $user->lastName = $token->getClaim('family_name', ''); + + // Attempt to save the user + $success = Craft::$app->getElements()->saveElement($user); + + // If user saved ok... + if ($success) { + // Assign the user to the default public group + Craft::$app->users->assignUserToDefaultGroup($user); + + // Look for a picture in the claim + $picture = $token->getClaim('picture', ''); + if ($picture) { + // Create a guzzel client + $guzzle = Craft::createGuzzleClient(); + + // Attempt to fetch the image + $imageUpload = $guzzle->get($picture); + + // Derive the file extension from the content type + $ext = self::$plugin->jWT->mime2ext($imageUpload->getHeader('Content-Type')); + + // Make a filename from the username, and add some randomness + $fileName = $user->username . StringHelper::randomString() . '.' . $ext; + $tempFile = Craft::$app->path->getTempAssetUploadsPath() . '/' . $fileName; + + // Fetch it again, this time saving it to a temp file + $imageUpload = $guzzle->get($picture, ['save_to' => $tempFile]); + + // Save the tempfile to the user’s account as profile image + Craft::$app->getUsers()->saveUserPhoto($tempFile, $user, $fileName); + } + + return $user; + } + } + } + } + + return null; + } + /* * @return mixed */