diff --git a/README.md b/README.md index 3b148ebdd..73b4f360c 100644 --- a/README.md +++ b/README.md @@ -15,20 +15,29 @@ The primary goals for Shield are: ## Authentication Methods -Shield provides two primary methods of authentication out of the box: +Shield provides two primary methods **Session-based** and **Personal Access Codes** +of authentication out of the box. -**Session-based** +It also provides **JSON Web Tokens** authentication. + +### Session-based This is your typical email/username/password system you see everywhere. It includes a secure "remember me" functionality. This can be used for standard web applications, as well as for single page applications. Includes full controllers and basic views for all standard functionality, like registration, login, forgot password, etc. -**Personal Access Codes** +### Personal Access Codes These are much like the access codes that GitHub uses, where they are unique to a single user, and a single user can have more than one. This can be used for API authentication of third-party users, and even for allowing access for a mobile application that you build. +### JSON Web Tokens + +JWT or JSON Web Token is a compact and self-contained way of securely transmitting +information between parties as a JSON object. It is commonly used for authentication +and authorization purposes in web applications. + ## Some Important Features * Session-based authentication (traditional email/password with remember me) diff --git a/admin/RELEASE.md b/admin/RELEASE.md new file mode 100644 index 000000000..27f73b24a --- /dev/null +++ b/admin/RELEASE.md @@ -0,0 +1,89 @@ +# Release Process + +> Documentation guide based on the releases of `1.0.0-beta.5` on March 17, 2023. +> +> -kenjis + +## Changelog + +When generating the changelog each Pull Request to be included must have one of +the following [labels](https://github.com/codeigniter4/shield/labels): +- **bug** ... PRs that fix bugs +- **enhancement** ... PRs to improve existing functionalities +- **new feature** ... PRs for new features +- **refactor** ... PRs to refactor +- **lang** ... PRs for new/update language + +PRs with breaking changes must have the following additional label: +- **breaking change** ... PRs that may break existing functionalities + +### Check Generated Changelog + +This process is checking only. Do not create a release. + +To auto-generate, navigate to the +[Releases](https://github.com/codeigniter4/shield/releases) page, +click the "Draft a new release" button. + +* Tag: "v1.0.0-beta.5" (Create new tag) +* Target: develop + +Click the "Generate release notes" button. + +Check the resulting content. If there are items in the *Others* section which +should be included in the changelog, add a label to the PR and regenerate +the changelog. + +## Preparation + +* Clone **codeigniter4/shield** and resolve any necessary PRs + ```console + git clone git@github.com:codeigniter4/shield.git + ``` +* Merge any Security Advisory PRs in private forks + +## Process + +> **Note** Most changes that need noting in the User Guide and docs should have +> been included with their PR, so this process assumes you will not be +> generating much new content. + +* Create a new branch `release-1.x.x` +* Update **src/Auth.php** with the new version number: + `const SHIELD_VERSION = '1.x.x';` +* Commit the changes with "Prep for 1.x.x release" and push to origin +* Create a new PR from `release-1.x.x` to `develop`: + * Title: "Prep for 1.x.x release" + * Description: "Updates version references for `1.x.x`." (plus checklist) +* Let all tests run, then review and merge the PR +* Create a new PR from `develop` to `master`: + * Title: "1.x.x Ready code" + * Description: blank +* Merge the PR +* Create a new Release: + * Version: "v1.x.x" + * Target: master + * Title: "v1.x.x" + * Click the "Generate release notes" button + * Remove "### Others (Only for checking. Remove this category)" section + * Check "Create a discussion for this release" + * Click the "Publish release" button +* Watch for the "docs" action and verify that the user guide updated: + * [docs](https://github.com/codeigniter4/shield/actions/workflows/docs.yml) +* Fast-forward `develop` branch to catch the merge commit from `master` + (note: pushing to develop is restricted to administrators): + ```console + git fetch origin + git checkout develop + git merge origin/develop + git merge origin/master + git push origin HEAD # Only administrators can push to the protected branch. + ``` +* Publish any Security Advisories that were resolved from private forks + (note: publishing is restricted to administrators) +* Announce the release on the forums and Slack channel + (note: this forum is restricted to administrators): + * Make a new topic in the "News & Discussion" forums: + https://forum.codeigniter.com/forum-2.html + * The content is somewhat organic, but should include any major features and + changes as well as a link to the User Guide's changelog diff --git a/bin/update-en-comments b/bin/update-en-comments new file mode 100755 index 000000000..12d3c4f60 --- /dev/null +++ b/bin/update-en-comments @@ -0,0 +1,107 @@ +#!/usr/bin/env php + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +require __DIR__ . '/../vendor/codeigniter4/framework/system/Test/bootstrap.php'; + +use CodeIgniter\CLI\CLI; + +helper('filesystem'); + +if ($argc !== 2) { + CLI::error('Please specify a locale.'); + + exit(1); +} + +$locale = $argv[1]; + +$langDir = realpath(__DIR__ . '/../src/Language/' . $locale); + +if (! is_dir($langDir)) { + CLI::error('No such directory: "' . $langDir . '"'); + + exit(1); +} + +$enDir = realpath(__DIR__ . '/../src/Language/en'); + +if (! is_dir($enDir)) { + CLI::error('No "Language/en" directory. Please run "composer update".'); + + exit(1); +} + +$files = get_filenames( + $langDir, + true, + false, + false +); + +$enFiles = get_filenames( + $enDir, + true, + false, + false +); + +foreach ($enFiles as $enFile) { + $temp = $langDir . '/' . substr($enFile, strlen($enDir) + 1); + $langFile = realpath($temp) ?: $temp; + + if (! is_file($langFile)) { + CLI::error('No such file: "' . $langFile . '"'); + + continue; + } + + $enFileLines = file($enFile); + + $items = []; + + $pattern = '/(.*)\'([a-zA-Z0-9_]+?)\'(\s*=>\s*)([\'"].+[\'"]),/u'; + + foreach ($enFileLines as $line) { + if (preg_match($pattern, $line, $matches)) { + $items[] = [$matches[2] => $matches[4]]; + } + } + + $langFileLines = file($langFile); + + $newLangFile = ''; + + $itemNo = 0; + + foreach ($langFileLines as $line) { + // Remove en value comment. + if (preg_match('!(.*,)(\s*//.*)$!u', $line, $matches)) { + $line = $matches[1] . "\n"; + } + + if (preg_match($pattern, $line, $matches) === 0) { + $newLangFile .= $line; + } else { + $indent = $matches[1]; + $key = $matches[2]; + $arrow = $matches[3]; + $value = $matches[4]; + + $newLangFile .= $indent . "'" . $key . "'" . $arrow . $value + . ', // ' . $items[$itemNo][array_key_first($items[$itemNo])] . "\n"; + $itemNo++; + } + } + + file_put_contents($langFile, $newLangFile); + CLI::write('Updated: ' . $langFile); +} diff --git a/composer.json b/composer.json index 8fb7cb750..42ee9f2d5 100644 --- a/composer.json +++ b/composer.json @@ -25,13 +25,15 @@ "codeigniter4/devkit": "^1.0", "codeigniter4/framework": "^4.2.7", "mikey179/vfsstream": "^1.6.7", - "mockery/mockery": "^1.0" + "mockery/mockery": "^1.0", + "firebase/php-jwt": "^6.4" }, "provide": { "codeigniter4/authentication-implementation": "1.0" }, "suggest": { - "ext-curl": "Required to use the password validation rule via PwnedValidator class." + "ext-curl": "Required to use the password validation rule via PwnedValidator class.", + "ext-openssl": "Required to use the JWT Authenticator." }, "minimum-stability": "dev", "prefer-stable": true, diff --git a/docs/addons/jwt.md b/docs/addons/jwt.md new file mode 100644 index 000000000..efcc54c8c --- /dev/null +++ b/docs/addons/jwt.md @@ -0,0 +1,356 @@ +# JWT Authentication + +> **Note** +> Shield now supports only JWS (Singed JWT). JWE (Encrypted JWT) is not supported. + +## What is JWT? + +JWT or JSON Web Token is a compact and self-contained way of securely transmitting +information between parties as a JSON object. It is commonly used for authentication +and authorization purposes in web applications. + +For example, when a user logs in to a web application, the server generates a JWT +token and sends it to the client. The client then includes this token in the header +of subsequent requests to the server. The server verifies the authenticity of the +token and grants access to protected resources accordingly. + +If you are not familiar with JWT, we recommend that you check out +[Introduction to JSON Web Tokens](https://jwt.io/introduction) before continuing. + +## Setup + +To use JWT Authentication, you need additional setup and configuration. + +### Manual Setup + +1. Install "firebase/php-jwt" via Composer. + + ```console + composer require firebase/php-jwt:^6.4 + ``` + +2. Copy the **AuthJWT.php** from **vendor/codeigniter4/shield/src/Config/** into your project's config folder and update the namespace to `Config`. You will also need to have these classes extend the original classes. See the example below. + + ```php + // new file - app/Config/AuthJWT.php + AccessTokens::class, + 'session' => Session::class, + 'jwt' => JWT::class, + ]; + ``` + + If you want to use JWT Authenticator in Authentication Chain, add `jwt`: + ```php + public array $authenticationChain = [ + 'session', + 'tokens', + 'jwt' + ]; + ``` + +## Configuration + +Configure **app/Config/AuthJWT.php** for your needs. + +### Set the Default Claims + +> **Note** +> A payload contains the actual data being transmitted, such as user ID, role, +> or expiration time. Items in a payload is called *claims*. + +Set the default payload items to the property `$defaultClaims`. + +E.g.: +```php + public array $defaultClaims = [ + 'iss' => 'https://codeigniter.com/', + ]; +``` + +The default claims will be included in all tokens issued by Shield. + +### Set Secret Key + +Set your secret key in the `$keys` property, or set it in your `.env` file. + +E.g.: +```dotenv +authjwt.keys.default.0.secret = 8XBFsF6HThIa7OV/bSynahEch+WbKrGcuiJVYPiwqPE= +``` + +It needs at least 256 bits random string. The length of the secret depends on the +algorithm we use. The default one is `HS256`, so to ensure that the hash value is +secure and not easily guessable, the secret key should be at least as long as the +hash function's output - 256 bits (32 bytes). You can get a secure random string +with the following command: + +```console +php -r 'echo base64_encode(random_bytes(32));' +``` + +> **Note** +> The secret key is used for signing and validating tokens. + +## Issuing JWTs + +To use JWT Authentication, you need a controller that issues JWTs. + +Here is a sample controller. When a client posts valid credentials (email/password), +it returns a new JWT. + +```php +// app/Config/Routes.php +$routes->post('auth/jwt', '\App\Controllers\Auth\LoginController::jwtLogin'); +``` + +```php +// app/Controllers/Auth/LoginController.php +declare(strict_types=1); + +namespace App\Controllers\Auth; + +use App\Controllers\BaseController; +use CodeIgniter\API\ResponseTrait; +use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\Shield\Authentication\Authenticators\Session; +use CodeIgniter\Shield\Authentication\JWTManager; +use CodeIgniter\Shield\Authentication\Passwords; +use CodeIgniter\Shield\Config\AuthSession; + +class LoginController extends BaseController +{ + use ResponseTrait; + + /** + * Authenticate Existing User and Issue JWT. + */ + public function jwtLogin(): ResponseInterface + { + // Get the validation rules + $rules = $this->getValidationRules(); + + // Validate credentials + if (! $this->validateData($this->request->getJSON(true), $rules)) { + return $this->fail( + ['errors' => $this->validator->getErrors()], + $this->codes['unauthorized'] + ); + } + + // Get the credentials for login + $credentials = $this->request->getJsonVar(setting('Auth.validFields')); + $credentials = array_filter($credentials); + $credentials['password'] = $this->request->getJsonVar('password'); + + /** @var Session $authenticator */ + $authenticator = auth('session')->getAuthenticator(); + + // Check the credentials + $result = $authenticator->check($credentials); + + // Credentials mismatch. + if (! $result->isOK()) { + // @TODO Record a failed login attempt + + return $this->failUnauthorized($result->reason()); + } + + // Credentials match. + // @TODO Record a successful login attempt + + $user = $result->extraInfo(); + + /** @var JWTManager $manager */ + $manager = service('jwtmanager'); + + // Generate JWT and return to client + $jwt = $manager->generateToken($user); + + return $this->respond([ + 'access_token' => $jwt, + ]); + } + + /** + * Returns the rules that should be used for validation. + * + * @return array|string>> + * @phpstan-return array>> + */ + protected function getValidationRules(): array + { + return setting('Validation.login') ?? [ + 'email' => [ + 'label' => 'Auth.email', + 'rules' => config(AuthSession::class)->emailValidationRules, + ], + 'password' => [ + 'label' => 'Auth.password', + 'rules' => 'required|' . Passwords::getMaxLenghtRule(), + 'errors' => [ + 'max_byte' => 'Auth.errorPasswordTooLongBytes', + ], + ], + ]; + } +} +``` + +You could send a request with the existing user's credentials by curl like this: + +```console +curl --location 'http://localhost:8080/auth/jwt' \ +--header 'Content-Type: application/json' \ +--data-raw '{"email" : "admin@example.jp" , "password" : "passw0rd!"}' +``` + +When making all future requests to the API, the client should send the JWT in +the `Authorization` header as a `Bearer` token. + +You could send a request with the `Authorization` header by curl like this: + +```console +curl --location --request GET 'http://localhost:8080/api/users' \ +--header 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTaGllbGQgVGVzdCBBcHAiLCJzdWIiOiIxIiwiaWF0IjoxNjgxODA1OTMwLCJleHAiOjE2ODE4MDk1MzB9.DGpOmRPOBe45whVtEOSt53qJTw_CpH0V8oMoI_gm2XI' +``` + +## Protecting Routes + +The first way to specify which routes are protected is to use the `jwt` controller +filter. + +For example, to ensure it protects all routes under the `/api` route group, you +would use the `$filters` setting on **app/Config/Filters.php**. + +```php +public $filters = [ + 'jwt' => ['before' => ['api', 'api/*']], +]; +``` + +You can also specify the filter should run on one or more routes within the routes +file itself: + +```php +$routes->group('api', ['filter' => 'jwt'], static function ($routes) { + // ... +}); +$routes->get('users', 'UserController::list', ['filter' => 'jwt']); +``` + +When the filter runs, it checks the `Authorization` header for a `Bearer` value +that has the JWT. It then validates the token. If the token is valid, it can +determine the correct user, which will then be available through an `auth()->user()` +call. + +## Method References + +### Generating Signed JWTs + +#### JWT to a Specific User + +JWTs are created through the `JWTManager::generateToken()` method. +This takes a User object to give to the token as the first argument. +It can also take optional additional claims array, time to live in seconds, +a key group (an array key) in the `Config\AuthJWT::$keys`, and additional header +array: + +```php +public function generateToken( + User $user, + array $claims = [], + ?int $ttl = null, + $keyset = 'default', + ?array $headers = null +): string +``` + +The following code generates a JWT to the user. + +```php +use CodeIgniter\Shield\Authentication\JWTManager; + +/** @var JWTManager $manager */ +$manager = service('jwtmanager'); + +$user = auth()->user(); +$claims = [ + 'email' => $user->email, +]; +$jwt = $manager->generateToken($user, $claims); +``` + +It sets the `Config\AuthJWT::$defaultClaims` to the token, and adds +the `'email'` claim and the user ID in the `"sub"` (subject) claim. +It also sets `"iat"` (Issued At) and `"exp"` (Expiration Time) claims automatically +if you don't specify. + +#### Arbitrary JWT + +You can generate arbitrary JWT with the ``JWTManager::issue()`` method. + +It takes a JWT claims array, and can take time to live in seconds, a key group +(an array key) in the `Config\AuthJWT::$keys`, and additional header array: + +```php +public function issue( + array $claims, + ?int $ttl = null, + $keyset = 'default', + ?array $headers = null +): string +``` + +The following code generates a JWT. + +```php +use CodeIgniter\Shield\Authentication\JWTManager; + +/** @var JWTManager $manager */ +$manager = service('jwtmanager'); + +$payload = [ + 'user_id' => '1', + 'email' => 'admin@example.jp', +]; +$jwt = $manager->issue($payload, DAY); +``` + +It uses the `secret` and `alg` in the `Config\AuthJWT::$keys['default']`. + +It sets the `Config\AuthJWT::$defaultClaims` to the token, and sets +`"iat"` (Issued At) and `"exp"` (Expiration Time) claims automatically even if +you don't pass them. diff --git a/docs/authentication.md b/docs/authentication.md index 9d5f505c5..3b177d3dc 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -50,7 +50,7 @@ public $defaultAuthenticator = 'session'; ## Auth Helper The auth functionality is designed to be used with the `auth_helper` that comes with Shield. This -helper method provides the `auth()` command which returns a convenient interface to the most frequently +helper method provides the `auth()` function which returns a convenient interface to the most frequently used functionality within the auth libraries. ```php @@ -61,6 +61,9 @@ auth()->user(); auth()->id(); // or user_id(); + +// get the User Provider (UserModel by default) +auth()->getProvider(); ``` > **Note** diff --git a/docs/authorization.md b/docs/authorization.md index 9976a7211..16ac5a797 100644 --- a/docs/authorization.md +++ b/docs/authorization.md @@ -80,12 +80,20 @@ public $permissions = [ ## Assigning Permissions to Groups In order to grant any permissions to a group, they must have the permission assigned to the group, within the `AuthGroups` -config file, under the `$matrix` property. The matrix is an associative array with the group name as the key, +config file, under the `$matrix` property. + +> **Note** This defines **group-level permissons**. + +The matrix is an associative array with the group name as the key, and an array of permissions that should be applied to that group. ```php public $matrix = [ - 'admin' => ['admin.access', 'users.create', 'users.edit', 'users.delete', 'beta.access'], + 'admin' => [ + 'admin.access', + 'users.create', 'users.edit', 'users.delete', + 'beta.access' + ], ]; ``` @@ -104,8 +112,8 @@ The `Authorizable` trait on the `User` entity provides the following methods to #### can() Allows you to check if a user is permitted to do a specific action. The only argument is the permission string. Returns -boolean `true`/`false`. Will check the user's direct permissions first, and then check against all of the user's groups -permissions to determine if they are allowed. +boolean `true`/`false`. Will check the user's direct permissions (**user-level permissions**) first, and then check against all of the user's groups +permissions (**group-level permissions**) to determine if they are allowed. ```php if ($user->can('users.create')) { @@ -133,6 +141,10 @@ if (! $user->hasPermission('users.create')) { } ``` +> **Note** This method checks only **user-level permissions**, and does not check +> group-level permissions. If you want to check if the user can do something, +> use the `$user->can()` method instead. + #### Authorizing via Routes You can restrict access to a route or route group through a @@ -168,7 +180,7 @@ override the group, so it's possible that a user can perform an action that thei #### addPermission() -Adds one or more permissions to the user. If a permission doesn't exist, a `CodeIgniter\Shield\Authorization\AuthorizationException` +Adds one or more **user-level** permissions to the user. If a permission doesn't exist, a `CodeIgniter\Shield\Authorization\AuthorizationException` is thrown. ```php @@ -177,7 +189,7 @@ $user->addPermission('users.create', 'users.edit'); #### removePermission() -Removes one or more permissions from a user. If a permission doesn't exist, a `CodeIgniter\Shield\Authorization\AuthorizationException` +Removes one or more **user-level** permissions from a user. If a permission doesn't exist, a `CodeIgniter\Shield\Authorization\AuthorizationException` is thrown. ```php @@ -186,7 +198,7 @@ $user->removePermission('users.delete'); #### syncPermissions() -Updates the user's permissions to only include the permissions in the given list. Any existing permissions on that user +Updates the user's **user-level** permissions to only include the permissions in the given list. Any existing permissions on that user not in this list will be removed. ```php @@ -195,12 +207,14 @@ $user->syncPermissions('admin.access', 'beta.access'); #### getPermissions() -Returns all permissions this user has assigned directly to them. +Returns all **user-level** permissions this user has assigned directly to them. ```php $user->getPermissions(); ``` +> **Note** This method does not return **group-level permissions**. + ## Managing User Groups #### addGroup() diff --git a/docs/guides/mobile_apps.md b/docs/guides/mobile_apps.md index bece95b2a..237eeeafd 100644 --- a/docs/guides/mobile_apps.md +++ b/docs/guides/mobile_apps.md @@ -32,16 +32,25 @@ class LoginController extends BaseController 'label' => 'Auth.password', 'rules' => 'required', ], + 'device_name' => [ + 'label' => 'Device Name', + 'rules' => 'required|string', + ], ]; - if (! $this->validate($rules)) { + if (! $this->validateData($this->request->getPost(), $rules)) { return $this->response ->setJSON(['errors' => $this->validator->getErrors()]) - ->setStatusCode(422); + ->setStatusCode(401); } + // Get the credentials for login + $credentials = $this->request->getPost(setting('Auth.validFields')); + $credentials = array_filter($credentials); + $credentials['password'] = $this->request->getPost('password'); + // Attempt to login - $result = auth()->attempt($this->request->getPost(setting('Auth.validFields'))); + $result = auth()->attempt($credentials); if (! $result->isOK()) { return $this->response ->setJSON(['error' => $result->reason()]) diff --git a/docs/index.md b/docs/index.md index a0fa90810..8d1103e7a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,6 +13,11 @@ * [Banning Users](banning_users.md) ## Guides + * [Protecting an API with Access Tokens](guides/api_tokens.md) * [Mobile Authentication with Access Tokens](guides/mobile_apps.md) * [How to Strengthen the Password](guides/strengthen_password.md) + +## Addons + +* [JWT Authentication](addons/jwt.md) diff --git a/docs/install.md b/docs/install.md index b37202eed..74f5b3af4 100644 --- a/docs/install.md +++ b/docs/install.md @@ -108,12 +108,14 @@ Require it with an explicit version constraint allowing its desired stability. There are a few setup items to do before you can start using Shield in your project. -1. Copy the **Auth.php** and **AuthGroups.php** from **vendor/codeigniter4/shield/src/Config/** into your project's config folder and update the namespace to `Config`. You will also need to have these classes extend the original classes. See the example below. These files contain all of the settings, group, and permission information for your application and will need to be modified to meet the needs of your site. +1. Copy the **Auth.php** and **AuthGroups.php** from **vendor/codeigniter4/shield/src/Config/** into your project's config folder and update the namespace to `Config`. You will also need to have these classes extend the original classes. See the example below. These files contain all the settings, group, and permission information for your application and will need to be modified to meet the needs of your site. ```php // new file - app/Config/Auth.php \CodeIgniter\Shield\Filters\SessionAuth::class, - 'tokens' => \CodeIgniter\Shield\Filters\TokenAuth::class, - 'chain' => \CodeIgniter\Shield\Filters\ChainAuth::class, - 'auth-rates' => \CodeIgniter\Shield\Filters\AuthRates::class, - 'group' => \CodeIgniter\Shield\Filters\GroupFilter::class, - 'permission' => \CodeIgniter\Shield\Filters\PermissionFilter::class, + 'session' => \CodeIgniter\Shield\Filters\SessionAuth::class, + 'tokens' => \CodeIgniter\Shield\Filters\TokenAuth::class, + 'chain' => \CodeIgniter\Shield\Filters\ChainAuth::class, + 'auth-rates' => \CodeIgniter\Shield\Filters\AuthRates::class, + 'group' => \CodeIgniter\Shield\Filters\GroupFilter::class, + 'permission' => \CodeIgniter\Shield\Filters\PermissionFilter::class, 'force-reset' => \CodeIgniter\Shield\Filters\ForcePasswordResetFilter::class, + 'jwt' => \CodeIgniter\Shield\Filters\JWTAuth::class, ]; ``` @@ -213,6 +217,7 @@ Filters | Description --- | --- session and tokens | The `Session` and `AccessTokens` authenticators, respectively. chained | The filter will check both authenticators in sequence to see if the user is logged in through either of authenticators, allowing a single API endpoint to work for both an SPA using session auth, and a mobile app using access tokens. +jwt | The `JWT` authenticator. See [JWT Authentication](./addons/jwt.md). auth-rates | Provides a good basis for rate limiting of auth-related routes. group | Checks if the user is in one of the groups passed in. permission | Checks if the user has the passed permissions. diff --git a/docs/quickstart.md b/docs/quickstart.md index 70b960f9c..49e25a38f 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -271,7 +271,9 @@ By default, the only values stored in the users table is the username. The first ```php use CodeIgniter\Shield\Entities\User; -$users = model('UserModel'); +// Get the User Provider (UserModel by default) +$users = auth()->getProvider(); + $user = new User([ 'username' => 'foo-bar', 'email' => 'foo.bar@example.com', @@ -291,7 +293,9 @@ $users->addToDefaultGroup($user); A user's data can be spread over a few different tables so you might be concerned about how to delete all of the user's data from the system. This is handled automatically at the database level for all information that Shield knows about, through the `onCascade` settings of the table's foreign keys. You can delete a user like any other entity. ```php -$users = model('UserModel'); +// Get the User Provider (UserModel by default) +$users = auth()->getProvider(); + $users->delete($user->id, true); ``` @@ -302,9 +306,10 @@ $users->delete($user->id, true); The `UserModel::save()`, `update()` and `insert()` methods have been modified to ensure that an email or password previously set on the `User` entity will be automatically updated in the correct `UserIdentity` record. ```php -$users = model('UserModel'); -$user = $users->findById(123); +// Get the User Provider (UserModel by default) +$users = auth()->getProvider(); +$user = $users->findById(123); $user->fill([ 'username' => 'JoeSmith111', 'email' => 'joe.smith@example.com', diff --git a/mkdocs.yml b/mkdocs.yml index 023b20886..1b1752483 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -54,3 +54,5 @@ nav: - guides/api_tokens.md - guides/mobile_apps.md - guides/strengthen_password.md + - Addons: + - JWT Authentication: addons/jwt.md diff --git a/src/Auth.php b/src/Auth.php index 4ec710a5a..41163479a 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -28,7 +28,7 @@ class Auth /** * The current version of CodeIgniter Shield */ - public const SHIELD_VERSION = '1.0.0-beta.5'; + public const SHIELD_VERSION = '1.0.0-beta.6'; protected Authentication $authenticate; diff --git a/src/Authentication/Authenticators/JWT.php b/src/Authentication/Authenticators/JWT.php new file mode 100644 index 000000000..c223bdbc7 --- /dev/null +++ b/src/Authentication/Authenticators/JWT.php @@ -0,0 +1,279 @@ +provider = $provider; + + $this->jwtManager = service('jwtmanager'); + $this->tokenLoginModel = model(TokenLoginModel::class); + } + + /** + * Attempts to authenticate a user with the given $credentials. + * Logs the user in with a successful check. + * + * @param array{token?: string} $credentials + */ + public function attempt(array $credentials): Result + { + $config = config(AuthJWT::class); + + /** @var IncomingRequest $request */ + $request = service('request'); + + $ipAddress = $request->getIPAddress(); + $userAgent = (string) $request->getUserAgent(); + + $result = $this->check($credentials); + + if (! $result->isOK()) { + if ($config->recordLoginAttempt >= Auth::RECORD_LOGIN_ATTEMPT_FAILURE) { + // Record a failed login attempt. + $this->tokenLoginModel->recordLoginAttempt( + self::ID_TYPE_JWT, + $credentials['token'] ?? '', + false, + $ipAddress, + $userAgent + ); + } + + return $result; + } + + $user = $result->extraInfo(); + + if ($user->isBanned()) { + if ($config->recordLoginAttempt >= Auth::RECORD_LOGIN_ATTEMPT_FAILURE) { + // Record a banned login attempt. + $this->tokenLoginModel->recordLoginAttempt( + self::ID_TYPE_JWT, + $credentials['token'] ?? '', + false, + $ipAddress, + $userAgent, + $user->id + ); + } + + $this->user = null; + + return new Result([ + 'success' => false, + 'reason' => $user->getBanMessage() ?? lang('Auth.bannedUser'), + ]); + } + + $this->login($user); + + if ($config->recordLoginAttempt === Auth::RECORD_LOGIN_ATTEMPT_ALL) { + // Record a successful login attempt. + $this->tokenLoginModel->recordLoginAttempt( + self::ID_TYPE_JWT, + $credentials['token'] ?? '', + true, + $ipAddress, + $userAgent, + $this->user->id + ); + } + + return $result; + } + + /** + * Checks a user's $credentials to see if they match an + * existing user. + * + * In this case, $credentials has only a single valid value: token, + * which is the plain text token to return. + * + * @param array{token?: string} $credentials + */ + public function check(array $credentials): Result + { + if (! array_key_exists('token', $credentials) || $credentials['token'] === '') { + return new Result([ + 'success' => false, + 'reason' => lang( + 'Auth.noToken', + [config(AuthJWT::class)->authenticatorHeader] + ), + ]); + } + + // Check JWT + try { + $this->payload = $this->jwtManager->parse($credentials['token'], $this->keyset); + } catch (RuntimeException $e) { + return new Result([ + 'success' => false, + 'reason' => $e->getMessage(), + ]); + } + + $userId = $this->payload->sub ?? null; + + if ($userId === null) { + return new Result([ + 'success' => false, + 'reason' => 'Invalid JWT: no user_id', + ]); + } + + // Find User + $user = $this->provider->findById($userId); + + if ($user === null) { + return new Result([ + 'success' => false, + 'reason' => lang('Auth.invalidUser'), + ]); + } + + return new Result([ + 'success' => true, + 'extraInfo' => $user, + ]); + } + + /** + * Checks if the user is currently logged in. + * Since AccessToken usage is inherently stateless, + * it runs $this->attempt on each usage. + */ + public function loggedIn(): bool + { + if ($this->user !== null) { + return true; + } + + /** @var IncomingRequest $request */ + $request = service('request'); + + $config = config(AuthJWT::class); + + return $this->attempt([ + 'token' => $request->getHeaderLine($config->authenticatorHeader), + ])->isOK(); + } + + /** + * Logs the given user in by saving them to the class. + */ + public function login(User $user): void + { + $this->user = $user; + } + + /** + * Logs a user in based on their ID. + * + * @param int|string $userId + * + * @throws AuthenticationException + */ + public function loginById($userId): void + { + $user = $this->provider->findById($userId); + + if ($user === null) { + throw AuthenticationException::forInvalidUser(); + } + + $this->login($user); + } + + /** + * Logs the current user out. + */ + public function logout(): void + { + $this->user = null; + } + + /** + * Returns the currently logged in user. + */ + public function getUser(): ?User + { + return $this->user; + } + + /** + * Updates the user's last active date. + */ + public function recordActiveDate(): void + { + if (! $this->user instanceof User) { + throw new InvalidArgumentException( + __METHOD__ . '() requires logged in user before calling.' + ); + } + + $this->user->last_active = Time::now(); + + $this->provider->save($this->user); + } + + /** + * @param string $keyset The key group. The array key of Config\AuthJWT::$keys. + */ + public function setKeyset($keyset): void + { + $this->keyset = $keyset; + } + + /** + * Returns payload + */ + public function getPayload(): ?stdClass + { + return $this->payload; + } +} diff --git a/src/Authentication/JWT/Adapters/FirebaseAdapter.php b/src/Authentication/JWT/Adapters/FirebaseAdapter.php new file mode 100644 index 000000000..3a246ed3f --- /dev/null +++ b/src/Authentication/JWT/Adapters/FirebaseAdapter.php @@ -0,0 +1,156 @@ +createKeysForDecode($keyset); + + return JWT::decode($encodedToken, $keys); + } catch (InvalidArgumentException $e) { + // provided key/key-array is empty or malformed. + throw new ShieldInvalidArgumentException( + 'Invalid Keyset: "' . $keyset . '". ' . $e->getMessage(), + 0, + $e + ); + } catch (DomainException $e) { + // provided algorithm is unsupported OR + // provided key is invalid OR + // unknown error thrown in openSSL or libsodium OR + // libsodium is required but not available. + throw new ShieldLogicException('Cannot decode JWT: ' . $e->getMessage(), 0, $e); + } catch (SignatureInvalidException $e) { + // provided JWT signature verification failed. + throw InvalidTokenException::forInvalidToken($e); + } catch (BeforeValidException $e) { + // provided JWT is trying to be used before "nbf" claim OR + // provided JWT is trying to be used before "iat" claim. + throw InvalidTokenException::forBeforeValidToken($e); + } catch (ExpiredException $e) { + // provided JWT is trying to be used after "exp" claim. + throw InvalidTokenException::forExpiredToken($e); + } catch (UnexpectedValueException $e) { + // provided JWT is malformed OR + // provided JWT is missing an algorithm / using an unsupported algorithm OR + // provided JWT algorithm does not match provided key OR + // provided key ID in key/key-array is empty or invalid. + log_message( + 'error', + '[Shield] ' . class_basename($this) . '::' . __FUNCTION__ + . '(' . __LINE__ . ') ' + . get_class($e) . ': ' . $e->getMessage() + ); + + throw InvalidTokenException::forInvalidToken($e); + } + } + + /** + * Creates keys for Decode + * + * @param string $keyset + * + * @return array|Key key or key array + */ + private function createKeysForDecode($keyset) + { + $config = config(AuthJWT::class); + + $configKeys = $config->keys[$keyset]; + + if (count($configKeys) === 1) { + $key = $configKeys[0]['secret'] ?? $configKeys[0]['public']; + $algorithm = $configKeys[0]['alg']; + + return new Key($key, $algorithm); + } + + $keys = []; + + foreach ($config->keys[$keyset] as $item) { + $key = $item['secret'] ?? $item['public']; + $algorithm = $item['alg']; + + $keys[$item['kid']] = new Key($key, $algorithm); + } + + return $keys; + } + + /** + * {@inheritDoc} + */ + public function encode(array $payload, $keyset, ?array $headers = null): string + { + try { + [$key, $keyId, $algorithm] = $this->createKeysForEncode($keyset); + + return JWT::encode($payload, $key, $algorithm, $keyId, $headers); + } catch (LogicException $e) { + // errors having to do with environmental setup or malformed JWT Keys + throw new ShieldLogicException('Cannot encode JWT: ' . $e->getMessage(), 0, $e); + } catch (UnexpectedValueException $e) { + // errors having to do with JWT signature and claims + throw new ShieldLogicException('Cannot encode JWT: ' . $e->getMessage(), 0, $e); + } + } + + /** + * Creates keys for Encode + * + * @param string $keyset + */ + private function createKeysForEncode($keyset): array + { + $config = config(AuthJWT::class); + + if (isset($config->keys[$keyset][0]['secret'])) { + $key = $config->keys[$keyset][0]['secret']; + } else { + $passphrase = $config->keys[$keyset][0]['passphrase'] ?? ''; + + if ($passphrase !== '') { + $key = openssl_pkey_get_private( + $config->keys[$keyset][0]['private'], + $passphrase + ); + } else { + $key = $config->keys[$keyset][0]['private']; + } + } + + $algorithm = $config->keys[$keyset][0]['alg']; + + $keyId = $config->keys[$keyset][0]['kid'] ?? null; + if ($keyId === '') { + $keyId = null; + } + + return [$key, $keyId, $algorithm]; + } +} diff --git a/src/Authentication/JWT/Exceptions/InvalidTokenException.php b/src/Authentication/JWT/Exceptions/InvalidTokenException.php new file mode 100644 index 000000000..b3f9b5569 --- /dev/null +++ b/src/Authentication/JWT/Exceptions/InvalidTokenException.php @@ -0,0 +1,30 @@ + $payload The payload. + * @param string $keyset The key group. + * The array key of Config\AuthJWT::$keys. + * @param array|null $headers An array with header elements to attach. + * + * @return string JWT (JWS) + */ + public function encode(array $payload, $keyset, ?array $headers = null): string; + + /** + * Decode Signed JWT (JWS) + * + * @param string $keyset The key group. The array key of Config\AuthJWT::$keys. + * + * @return stdClass Payload + */ + public function decode(string $encodedToken, $keyset): stdClass; +} diff --git a/src/Authentication/JWT/JWSDecoder.php b/src/Authentication/JWT/JWSDecoder.php new file mode 100644 index 000000000..3ba548ac6 --- /dev/null +++ b/src/Authentication/JWT/JWSDecoder.php @@ -0,0 +1,33 @@ +jwsAdapter = $jwsAdapter ?? new FirebaseAdapter(); + } + + /** + * Returns payload of the JWT + * + * @param string $keyset The key group. The array key of Config\AuthJWT::$keys. + */ + public function decode(string $encodedToken, $keyset = 'default'): stdClass + { + return $this->jwsAdapter->decode($encodedToken, $keyset); + } +} diff --git a/src/Authentication/JWT/JWSEncoder.php b/src/Authentication/JWT/JWSEncoder.php new file mode 100644 index 000000000..327d5ea03 --- /dev/null +++ b/src/Authentication/JWT/JWSEncoder.php @@ -0,0 +1,67 @@ +jwsAdapter = $jwsAdapter ?? new FirebaseAdapter(); + $this->clock = $clock ?? new Time(); + } + + /** + * Issues Signed JWT (JWS) + * + * @param array $claims The payload items. + * @param int|null $ttl Time to live in seconds. + * @param string $keyset The key group. + * The array key of Config\AuthJWT::$keys. + * @param array|null $headers An array with header elements to attach. + */ + public function encode( + array $claims, + ?int $ttl = null, + $keyset = 'default', + ?array $headers = null + ): string { + assert( + (array_key_exists('exp', $claims) && ($ttl !== null)) === false, + 'Cannot pass $claims[\'exp\'] and $ttl at the same time.' + ); + + $config = config(AuthJWT::class); + + $payload = array_merge( + $config->defaultClaims, + $claims + ); + + if (! array_key_exists('iat', $claims)) { + $payload['iat'] = $this->clock->now()->getTimestamp(); + } + + if (! array_key_exists('exp', $claims)) { + $payload['exp'] = $payload['iat'] + $config->timeToLive; + } + + if ($ttl !== null) { + $payload['exp'] = $payload['iat'] + $ttl; + } + + return $this->jwsAdapter->encode( + $payload, + $keyset, + $headers + ); + } +} diff --git a/src/Authentication/JWTManager.php b/src/Authentication/JWTManager.php new file mode 100644 index 000000000..11f1dba93 --- /dev/null +++ b/src/Authentication/JWTManager.php @@ -0,0 +1,85 @@ +clock = $clock ?? new Time(); + $this->jwsEncoder = $jwsEncoder ?? new JWSEncoder(null, $this->clock); + $this->jwsDecoder = $jwsDecoder ?? new JWSDecoder(); + } + + /** + * Issues Signed JWT (JWS) for a User + * + * @param array $claims The payload items. + * @param int|null $ttl Time to live in seconds. + * @param string $keyset The key group. + * The array key of Config\AuthJWT::$keys. + * @param array|null $headers An array with header elements to attach. + */ + public function generateToken( + User $user, + array $claims = [], + ?int $ttl = null, + $keyset = 'default', + ?array $headers = null + ): string { + $payload = array_merge( + $claims, + [ + 'sub' => (string) $user->id, // subject + ], + ); + + return $this->issue($payload, $ttl, $keyset, $headers); + } + + /** + * Issues Signed JWT (JWS) + * + * @param array $claims The payload items. + * @param int|null $ttl Time to live in seconds. + * @param string $keyset The key group. + * The array key of Config\AuthJWT::$keys. + * @param array|null $headers An array with header elements to attach. + */ + public function issue( + array $claims, + ?int $ttl = null, + $keyset = 'default', + ?array $headers = null + ): string { + return $this->jwsEncoder->encode($claims, $ttl, $keyset, $headers); + } + + /** + * Returns payload of the JWT + * + * @param string $keyset The key group. The array key of Config\AuthJWT::$keys. + */ + public function parse(string $encodedToken, $keyset = 'default'): stdClass + { + return $this->jwsDecoder->decode($encodedToken, $keyset); + } +} diff --git a/src/Config/Auth.php b/src/Config/Auth.php index 6cdd5d9b8..b4d9ebae0 100644 --- a/src/Config/Auth.php +++ b/src/Config/Auth.php @@ -8,6 +8,7 @@ use CodeIgniter\Shield\Authentication\Actions\ActionInterface; use CodeIgniter\Shield\Authentication\AuthenticatorInterface; use CodeIgniter\Shield\Authentication\Authenticators\AccessTokens; +use CodeIgniter\Shield\Authentication\Authenticators\JWT; use CodeIgniter\Shield\Authentication\Authenticators\Session; use CodeIgniter\Shield\Authentication\Passwords\CompositionValidator; use CodeIgniter\Shield\Authentication\Passwords\DictionaryValidator; @@ -18,6 +19,10 @@ class Auth extends BaseConfig { + public const RECORD_LOGIN_ATTEMPT_NONE = 0; // Do not record at all + public const RECORD_LOGIN_ATTEMPT_FAILURE = 1; // Record only failures + public const RECORD_LOGIN_ATTEMPT_ALL = 2; // Record all login attempts + /** * //////////////////////////////////////////////////////////////////// * AUTHENTICATION @@ -122,6 +127,7 @@ class Auth extends BaseConfig public array $authenticators = [ 'tokens' => AccessTokens::class, 'session' => Session::class, + // 'jwt' => JWT::class, ]; /** @@ -168,6 +174,7 @@ class Auth extends BaseConfig public array $authenticationChain = [ 'session', 'tokens', + // 'jwt', ]; /** diff --git a/src/Config/AuthGroups.php b/src/Config/AuthGroups.php index 1fef9e312..3b92aee82 100644 --- a/src/Config/AuthGroups.php +++ b/src/Config/AuthGroups.php @@ -20,10 +20,11 @@ class AuthGroups extends BaseConfig * -------------------------------------------------------------------- * Groups * -------------------------------------------------------------------- - * An associative array of the available groups in the system, where the keys are - * the group names and the values are arrays of the group info. + * An associative array of the available groups in the system, where the keys + * are the group names and the values are arrays of the group info. * - * Whatever value you assign as the key will be used to refer to the group when using functions such as: + * Whatever value you assign as the key will be used to refer to the group + * when using functions such as: * $user->addGroup('superadmin'); * * @var array> @@ -57,8 +58,7 @@ class AuthGroups extends BaseConfig * -------------------------------------------------------------------- * Permissions * -------------------------------------------------------------------- - * The available permissions in the system. Each system is defined - * where the key is the + * The available permissions in the system. * * If a permission is not listed here it cannot be used. */ @@ -77,6 +77,8 @@ class AuthGroups extends BaseConfig * Permissions Matrix * -------------------------------------------------------------------- * Maps permissions to groups. + * + * This defines group-level permissions. */ public array $matrix = [ 'superadmin' => [ diff --git a/src/Config/AuthJWT.php b/src/Config/AuthJWT.php new file mode 100644 index 000000000..adf9bf404 --- /dev/null +++ b/src/Config/AuthJWT.php @@ -0,0 +1,87 @@ + + */ + public array $defaultClaims = [ + 'iss' => '', + ]; + + /** + * -------------------------------------------------------------------- + * The Keys + * -------------------------------------------------------------------- + * The key of the array is the key group name. + * The first key of the group is used for signing. + * + * @var array>> + * @phpstan-var array>> + */ + public array $keys = [ + 'default' => [ + // Symmetric Key + [ + 'kid' => '', // Key ID. Optional if you have only one key. + 'alg' => 'HS256', // algorithm. + // Set secret random string. Needs at least 256 bits for HS256 algorithm. + // E.g., $ php -r 'echo base64_encode(random_bytes(32));' + 'secret' => '', + ], + // Asymmetric Key + // [ + // 'kid' => '', // Key ID. Optional if you have only one key. + // 'alg' => 'RS256', // algorithm. + // 'public' => '', // Public Key + // 'private' => '', // Private Key + // 'passphrase' => '' // Passphrase + // ], + ], + ]; + + /** + * -------------------------------------------------------------------- + * Time To Live (in seconds) + * -------------------------------------------------------------------- + * Specifies the amount of time, in seconds, that a token is valid. + */ + public int $timeToLive = HOUR; + + /** + * -------------------------------------------------------------------- + * Record Login Attempts + * -------------------------------------------------------------------- + * Whether login attempts are recorded in the database. + * + * Valid values are: + * - Auth::RECORD_LOGIN_ATTEMPT_NONE + * - Auth::RECORD_LOGIN_ATTEMPT_FAILURE + * - Auth::RECORD_LOGIN_ATTEMPT_ALL + */ + public int $recordLoginAttempt = Auth::RECORD_LOGIN_ATTEMPT_FAILURE; +} diff --git a/src/Config/Registrar.php b/src/Config/Registrar.php index d3bcf8672..290b036d5 100644 --- a/src/Config/Registrar.php +++ b/src/Config/Registrar.php @@ -10,6 +10,7 @@ use CodeIgniter\Shield\Filters\ChainAuth; use CodeIgniter\Shield\Filters\ForcePasswordResetFilter; use CodeIgniter\Shield\Filters\GroupFilter; +use CodeIgniter\Shield\Filters\JWTAuth; use CodeIgniter\Shield\Filters\PermissionFilter; use CodeIgniter\Shield\Filters\SessionAuth; use CodeIgniter\Shield\Filters\TokenAuth; @@ -30,6 +31,7 @@ public static function Filters(): array 'group' => GroupFilter::class, 'permission' => PermissionFilter::class, 'force-reset' => ForcePasswordResetFilter::class, + 'jwt' => JWTAuth::class, ], ]; } diff --git a/src/Config/Services.php b/src/Config/Services.php index 2fb696176..1e002b6a0 100644 --- a/src/Config/Services.php +++ b/src/Config/Services.php @@ -6,6 +6,7 @@ use CodeIgniter\Shield\Auth; use CodeIgniter\Shield\Authentication\Authentication; +use CodeIgniter\Shield\Authentication\JWTManager; use CodeIgniter\Shield\Authentication\Passwords; use Config\Services as BaseService; @@ -36,4 +37,16 @@ public static function passwords(bool $getShared = true): Passwords return new Passwords(config('Auth')); } + + /** + * JWT Manager. + */ + public static function jwtmanager(bool $getShared = true): JWTManager + { + if ($getShared) { + return self::getSharedInstance('jwtmanager'); + } + + return new JWTManager(); + } } diff --git a/src/Controllers/LoginController.php b/src/Controllers/LoginController.php index 9cff8f3aa..8c5bc445d 100644 --- a/src/Controllers/LoginController.php +++ b/src/Controllers/LoginController.php @@ -47,7 +47,7 @@ public function loginAction(): RedirectResponse // like the password, can only be validated properly here. $rules = $this->getValidationRules(); - if (! $this->validate($rules)) { + if (! $this->validateData($this->request->getPost(), $rules)) { return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); } diff --git a/src/Controllers/MagicLinkController.php b/src/Controllers/MagicLinkController.php index 84fdfbb7e..2f081a82c 100644 --- a/src/Controllers/MagicLinkController.php +++ b/src/Controllers/MagicLinkController.php @@ -65,7 +65,7 @@ public function loginAction() { // Validate email format $rules = $this->getValidationRules(); - if (! $this->validate($rules)) { + if (! $this->validateData($this->request->getPost(), $rules)) { return redirect()->route('magic-link')->with('errors', $this->validator->getErrors()); } diff --git a/src/Controllers/RegisterController.php b/src/Controllers/RegisterController.php index 2fe94c31a..6f394faa0 100644 --- a/src/Controllers/RegisterController.php +++ b/src/Controllers/RegisterController.php @@ -100,7 +100,7 @@ public function registerAction(): RedirectResponse // like the password, can only be validated properly here. $rules = $this->getValidationRules(); - if (! $this->validate($rules)) { + if (! $this->validateData($this->request->getPost(), $rules)) { return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); } diff --git a/src/Exceptions/SecurityException.php b/src/Exceptions/SecurityException.php index b0646c057..c72d7aa04 100644 --- a/src/Exceptions/SecurityException.php +++ b/src/Exceptions/SecurityException.php @@ -4,8 +4,6 @@ namespace CodeIgniter\Shield\Exceptions; -use RuntimeException; - class SecurityException extends RuntimeException { } diff --git a/src/Exceptions/ValidationException.php b/src/Exceptions/ValidationException.php index 9b6d51d52..3aae180ef 100644 --- a/src/Exceptions/ValidationException.php +++ b/src/Exceptions/ValidationException.php @@ -4,8 +4,6 @@ namespace CodeIgniter\Shield\Exceptions; -use RuntimeException; - class ValidationException extends RuntimeException { } diff --git a/src/Filters/JWTAuth.php b/src/Filters/JWTAuth.php new file mode 100644 index 000000000..a0a6c2a7d --- /dev/null +++ b/src/Filters/JWTAuth.php @@ -0,0 +1,84 @@ +getAuthenticator(); + + $token = $this->getTokenFromHeader($request); + + $result = $authenticator->attempt(['token' => $token]); + + if (! $result->isOK()) { + return Services::response() + ->setJSON([ + 'error' => $result->reason(), + ]) + ->setStatusCode(ResponseInterface::HTTP_UNAUTHORIZED); + } + + if (setting('Auth.recordActiveDate')) { + $authenticator->recordActiveDate(); + } + } + + private function getTokenFromHeader(RequestInterface $request): string + { + assert($request instanceof IncomingRequest); + + $config = config(AuthJWT::class); + + $tokenHeader = $request->getHeaderLine( + $config->authenticatorHeader ?? 'Authorization' + ); + + if (strpos($tokenHeader, 'Bearer') === 0) { + return trim(substr($tokenHeader, 6)); + } + + return $tokenHeader; + } + + /** + * We don't have anything to do here. + * + * @param Response|ResponseInterface $response + * @param array|null $arguments + */ + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): void + { + } +} diff --git a/src/Language/de/Auth.php b/src/Language/de/Auth.php index 8e2c2476f..a681516bd 100644 --- a/src/Language/de/Auth.php +++ b/src/Language/de/Auth.php @@ -20,6 +20,10 @@ 'unableSendEmailToUser' => 'Leider gab es ein Problem beim Senden der E-Mail. Wir konnten keine E-Mail an "{0}" senden.', 'throttled' => 'Es wurden zu viele Anfragen von dieser IP-Adresse gestellt. Sie können es in {0} Sekunden erneut versuchen.', 'notEnoughPrivilege' => 'Sie haben nicht die erforderliche Berechtigung, um den gewünschten Vorgang auszuführen.', + // JWT Exceptions + 'invalidJWT' => '(To be translated) The token is invalid.', + 'expiredJWT' => '(To be translated) The token has expired.', + 'beforeValidJWT' => '(To be translated) The token is not yet available.', 'email' => 'E-Mail-Adresse', 'username' => 'Benutzername', diff --git a/src/Language/en/Auth.php b/src/Language/en/Auth.php index 306c233d5..363fd4af7 100644 --- a/src/Language/en/Auth.php +++ b/src/Language/en/Auth.php @@ -20,6 +20,10 @@ 'unableSendEmailToUser' => 'Sorry, there was a problem sending the email. We could not send an email to "{0}".', 'throttled' => 'Too many requests made from this IP address. You may try again in {0} seconds.', 'notEnoughPrivilege' => 'You do not have the necessary permission to perform the desired operation.', + // JWT Exceptions + 'invalidJWT' => 'The token is invalid.', + 'expiredJWT' => 'The token has expired.', + 'beforeValidJWT' => 'The token is not yet available.', 'email' => 'Email Address', 'username' => 'Username', diff --git a/src/Language/es/Auth.php b/src/Language/es/Auth.php index ab72b1608..2cf2c6211 100644 --- a/src/Language/es/Auth.php +++ b/src/Language/es/Auth.php @@ -4,27 +4,31 @@ return [ // Excepciones - 'unknownAuthenticator' => '{0} no es un handler válido.', - 'unknownUserProvider' => 'No podemos determinar que Proveedor de Usuarios usar.', - 'invalidUser' => 'No podemos localizar este usuario.', - 'bannedUser' => '(To be translated) Can not log you in as you are currently banned.', - 'logOutBannedUser' => '(To be translated) You have been logged out because you have been banned.', - 'badAttempt' => 'No puedes entrar. Por favor, comprueba tus creenciales.', - 'noPassword' => 'No se puede validar un usuario sin una contraseña.', - 'invalidPassword' => 'No uedes entrar. Por favor, comprueba tu contraseña.', - 'noToken' => 'Cada petición debe tenerun token en la {0}.', - 'badToken' => 'Token de acceso no válido.', + 'unknownAuthenticator' => '{0} no es un autenticador válido.', + 'unknownUserProvider' => 'No se puede determinar el proveedor de usuario a utilizar.', + 'invalidUser' => 'No se puede localizar al usuario especificado.', + 'bannedUser' => 'No puedes iniciar sesión ya que estás actualmente vetado.', + 'logOutBannedUser' => 'Se ha cerrado la sesión porque se ha vetado al usuario.', + 'badAttempt' => 'No se puede iniciar sesión. Por favor, comprueba tus credenciales.', + 'noPassword' => 'No se puede validar un usuario sin contraseña.', + 'invalidPassword' => 'No se puede iniciar sesión. Por favor, comprueba tu contraseña.', + 'noToken' => 'Cada solicitud debe tener un token de portador en la cabecera {0}.', + 'badToken' => 'El token de acceso no es válido.', 'oldToken' => 'El token de acceso ha caducado.', - 'noUserEntity' => 'Se debe dar una Entidad de Usuario para validar la contraseña.', - 'invalidEmail' => 'No podemos verificar que el email coincida con un email registrado.', - 'unableSendEmailToUser' => 'Lo sentimaos, ha habido un problema al enviar el email. No podemos enviar un email a "{0}".', - 'throttled' => 'Demasiadas peticiones hechas desde esta IP. Puedes intentarlo de nuevo en {0} segundos.', - 'notEnoughPrivilege' => 'No tiene los permisos necesarios para realizar la operación deseada.', + 'noUserEntity' => 'Se debe proporcionar una entidad de usuario para la validación de contraseña.', + 'invalidEmail' => 'No se puede verificar que la dirección de correo electrónico coincida con la registrada.', + 'unableSendEmailToUser' => 'Lo siento, hubo un problema al enviar el correo electrónico. No pudimos enviar un correo electrónico a "{0}".', + 'throttled' => 'Se han realizado demasiadas solicitudes desde esta dirección IP. Puedes intentarlo de nuevo en {0} segundos.', + 'notEnoughPrivilege' => 'No tienes los permisos necesarios para realizar la operación deseada.', + // JWT Exceptions + 'invalidJWT' => '(To be translated) The token is invalid.', + 'expiredJWT' => '(To be translated) The token has expired.', + 'beforeValidJWT' => '(To be translated) The token is not yet available.', - 'email' => 'Dirección Email', - 'username' => 'Usuario', + 'email' => 'Correo Electrónico', + 'username' => 'Nombre de usuario', 'password' => 'Contraseña', - 'passwordConfirm' => 'Contraseña (de nuevo)', + 'passwordConfirm' => 'Contraseña (otra vez)', 'haveAccount' => '¿Ya tienes una cuenta?', // Botones @@ -32,65 +36,65 @@ 'send' => 'Enviar', // Registro - 'register' => 'Registro', - 'registerDisabled' => 'Actualmente no se permiten registros.', + 'register' => 'Registrarse', + 'registerDisabled' => 'Actualmente no se permite el registro.', 'registerSuccess' => '¡Bienvenido a bordo!', // Login - 'login' => 'Entrar', + 'login' => 'Iniciar sesión', 'needAccount' => '¿Necesitas una cuenta?', - 'rememberMe' => '¿Recordarme?', - 'forgotPassword' => '¿Has olvidado tu contraseña?', - 'useMagicLink' => 'Recordar contraseña', - 'magicLinkSubject' => 'Tu Enlace para Entrar', - 'magicTokenNotFound' => 'No podemos verificar el enlace.', - 'magicLinkExpired' => 'Lo sentimos, el enlace ha caducado.', - 'checkYourEmail' => 'Comprueba tu email', - 'magicLinkDetails' => 'Te hemos enviado un email que contiene un enlace para Entrar. Solo es válido durante {0} minutos.', - 'successLogout' => 'Has salido de forma correcta.', + 'rememberMe' => 'Recordarme', + 'forgotPassword' => '¿Olvidaste tu contraseña', + 'useMagicLink' => 'Usar un enlace de inicio de sesión', + 'magicLinkSubject' => 'Tu enlace de inicio de sesión', + 'magicTokenNotFound' => 'No se puede verificar el enlace.', + 'magicLinkExpired' => 'Lo siento, el enlace ha caducado.', + 'checkYourEmail' => '¡Revisa tu correo electrónico!', + 'magicLinkDetails' => 'Acabamos de enviarte un correo electrónico con un enlace de inicio de sesión. Solo es válido durante {0} minutos.', + 'successLogout' => 'Has cerrado sesión correctamente.', // Contraseñas - 'errorPasswordLength' => 'La contraseña debe tener al menos {0, number} caracteres.', - 'suggestPasswordLength' => 'Las claves de acceso, de hasta 255 caracteres, crean contraseñas más seguras y fáciles de recordar.', - 'errorPasswordCommon' => 'La contraseña no debe ser una contraseña común.', - 'suggestPasswordCommon' => 'La contraseña se comparó con más de 65.000 contraseñas de uso común o contraseñas que se filtraron a través de hacks.', - 'errorPasswordPersonal' => 'Las contraseñas no pueden contener información personal modificada.', - 'suggestPasswordPersonal' => 'No deben usarse variaciones de tu dirección de correo electrónico o nombre de usuario para contraseñas.', - 'errorPasswordTooSimilar' => 'La contraseña es demasiado parecida al usuario.', - 'suggestPasswordTooSimilar' => 'No uses partes de tu usuario en tu contraseña.', - 'errorPasswordPwned' => 'La contraseña {0} ha quedado expuesta debido a una violación de datos y se ha visto comprometida {1, número} veces en {2} contraseñas.', - 'suggestPasswordPwned' => '{0} no se debe usar nunca como contraseña. Si la estás usando en algún sitio, cámbiala inmediatamente.', - 'errorPasswordEmpty' => 'Se necesita una contraseña.', - 'errorPasswordTooLongBytes' => '(To be translated) Password cannot exceed {param} bytes in length.', - 'passwordChangeSuccess' => 'Contraseña modificada correctamente', - 'userDoesNotExist' => 'No se ha cambiado la contraseña. No existe el usuario', - 'resetTokenExpired' => 'Lo sentimos. Tu token de reseteo ha caducado.', + 'errorPasswordLength' => 'Las contraseñas deben tener al menos {0, number} caracteres.', + 'suggestPasswordLength' => 'Las frases de contraseña, de hasta 255 caracteres de longitud, hacen que las contraseñas sean más seguras y fáciles de recordar.', + 'errorPasswordCommon' => 'La contraseña no puede ser una contraseña común.', + 'suggestPasswordCommon' => 'La contraseña se comprobó frente a más de 65k contraseñas comúnmente utilizadas o contraseñas que se filtraron a través de ataques.', + 'errorPasswordPersonal' => 'Las contraseñas no pueden contener información personal reutilizada.', + 'suggestPasswordPersonal' => 'No se deben usar variaciones de su dirección de correo electrónico o nombre de usuario como contraseña.', + 'errorPasswordTooSimilar' => 'La contraseña es demasiado similar al nombre de usuario.', + 'suggestPasswordTooSimilar' => 'No use partes de su nombre de usuario en su contraseña.', + 'errorPasswordPwned' => 'La contraseña {0} se ha expuesto debido a una violación de datos y se ha visto {1, number} veces en {2} de contraseñas comprometidas.', + 'suggestPasswordPwned' => 'Nunca se debe usar {0} como contraseña. Si lo está utilizando en algún lugar, cambie su contraseña de inmediato.', + 'errorPasswordEmpty' => 'Se requiere una contraseña.', + 'errorPasswordTooLongBytes' => 'La contraseña no puede tener más de {param} caracteres', + 'passwordChangeSuccess' => 'Contraseña cambiada correctamente', + 'userDoesNotExist' => 'La contraseña no se cambió. El usuario no existe', + 'resetTokenExpired' => 'Lo siento. Su token de reinicio ha caducado.', // Email Globals - 'emailInfo' => 'Algunos datos sobre la persona:', + 'emailInfo' => 'Alguna información sobre la persona:', 'emailIpAddress' => 'Dirección IP:', 'emailDevice' => 'Dispositivo:', 'emailDate' => 'Fecha:', // 2FA - 'email2FATitle' => 'Authenticación de Doble Factor', - 'confirmEmailAddress' => 'Confirma tu dirección de email.', - 'emailEnterCode' => 'Confirma tu Email', - 'emailConfirmCode' => 'teclea el código de 6 dígitos qu ete hemos enviado a tu dirección email.', + 'email2FATitle' => 'Autenticación de dos factores', + 'confirmEmailAddress' => 'Confirma tu dirección de correo electrónico.', + 'emailEnterCode' => 'Confirma tu correo electrónico', + 'emailConfirmCode' => 'Ingresa el código de 6 dígitos que acabamos de enviar a tu correo electrónico.', 'email2FASubject' => 'Tu código de autenticación', 'email2FAMailBody' => 'Tu código de autenticación es:', - 'invalid2FAToken' => 'El token era incorrecto.', - 'need2FA' => 'Debes completar la verificación de doble factor.', - 'needVerification' => 'Comprueba tu buzón para completar la activación de la cuenta.', + 'invalid2FAToken' => 'El código era incorrecto.', + 'need2FA' => 'Debes completar la verificación de dos factores.', + 'needVerification' => 'Verifica tu correo electrónico para completar la activación de la cuenta.', // Activar - 'emailActivateTitle' => 'Email de Activación', - 'emailActivateBody' => 'Te enviamos un email con un código, para confirmar tu dirección email. Copia ese código y pégalo abajo.', + 'emailActivateTitle' => 'Activación de correo electrónico', + 'emailActivateBody' => 'Acabamos de enviarte un correo electrónico con un código para confirmar tu dirección de correo electrónico. Copia ese código y pégalo a continuación.', 'emailActivateSubject' => 'Tu código de activación', - 'emailActivateMailBody' => 'Por favor, usa el código de abajo para activar tu cuenta y empezar a usar el sitio.', - 'invalidActivateToken' => 'El código no es correcto.', - 'needActivate' => '(To be translated) You must complete your registration by confirming the code sent to your email address.', - 'activationBlocked' => '(to be translated) You must activate your account before logging in.', + 'emailActivateMailBody' => 'Utiliza el código siguiente para activar tu cuenta y comenzar a usar el sitio.', + 'invalidActivateToken' => 'El código era incorrecto.', + 'needActivate' => 'Debes completar tu registro confirmando el código enviado a tu dirección de correo electrónico.', + 'activationBlocked' => 'Debes activar tu cuenta antes de iniciar sesión.', // Grupos 'unknownGroup' => '{0} no es un grupo válido.', diff --git a/src/Language/fa/Auth.php b/src/Language/fa/Auth.php index b3d8e4fe5..26d242525 100644 --- a/src/Language/fa/Auth.php +++ b/src/Language/fa/Auth.php @@ -20,6 +20,10 @@ 'unableSendEmailToUser' => 'متاسفانه, در ارسال ایمیل مشکلی پیش آمد. ما نتوانستیم ایمیلی را به "{0}" ارسال کنیم.', 'throttled' => 'درخواست های بسیار زیادی از این آدرس IP انجام شده است. می توانید بعد از {0} ثانیه دوباره امتحان کنید.', 'notEnoughPrivilege' => 'شما مجوز لازم برای انجام عملیات مورد نظر را ندارید.', + // JWT Exceptions + 'invalidJWT' => 'توکن معتبر نمی باشد.', + 'expiredJWT' => 'توکن منقضی شده است.', + 'beforeValidJWT' => 'در حال حاضر امکان استفاده از توکن وجود ندارد.', 'email' => 'آدرس ایمیل', 'username' => 'نام کاربری', diff --git a/src/Language/fr/Auth.php b/src/Language/fr/Auth.php index 50c56881a..b43a354b0 100644 --- a/src/Language/fr/Auth.php +++ b/src/Language/fr/Auth.php @@ -20,6 +20,10 @@ 'unableSendEmailToUser' => 'Désolé, il y a eu un problème lors de l\'envoi de l\'email. Nous ne pouvons pas envoyer un email à "{0}".', 'throttled' => 'Trop de requêtes faites depuis cette adresse IP. Vous pouvez réessayer dans {0} secondes.', 'notEnoughPrivilege' => 'Vous n\'avez pas l\'autorisation nécessaire pour effectuer l\'opération souhaitée.', + // JWT Exceptions + 'invalidJWT' => '(To be translated) The token is invalid.', + 'expiredJWT' => '(To be translated) The token has expired.', + 'beforeValidJWT' => '(To be translated) The token is not yet available.', 'email' => 'Adresse email', 'username' => 'Identifiant', diff --git a/src/Language/id/Auth.php b/src/Language/id/Auth.php index ada97cf22..f2be28a35 100644 --- a/src/Language/id/Auth.php +++ b/src/Language/id/Auth.php @@ -20,6 +20,10 @@ 'unableSendEmailToUser' => 'Maaf, ada masalah saat mengirim email. Kami tidak dapat mengirim email ke "{0}".', 'throttled' => 'Terlalu banyak permintaan yang dibuat dari alamat IP ini. Anda dapat mencoba lagi dalam {0} detik.', 'notEnoughPrivilege' => 'Anda tidak memiliki izin yang diperlukan untuk melakukan operasi yang diinginkan.', + // JWT Exceptions + 'invalidJWT' => '(To be translated) The token is invalid.', + 'expiredJWT' => '(To be translated) The token has expired.', + 'beforeValidJWT' => '(To be translated) The token is not yet available.', 'email' => 'Alamat Email', 'username' => 'Nama Pengguna', diff --git a/src/Language/it/Auth.php b/src/Language/it/Auth.php index f517ac988..af2b41e24 100644 --- a/src/Language/it/Auth.php +++ b/src/Language/it/Auth.php @@ -20,6 +20,10 @@ 'unableSendEmailToUser' => 'Spiacente, c\'è stato un problema inviando l\'email. Non possiamo inviare un\'email a "{0}".', 'throttled' => 'Troppe richieste effettuate da questo indirizzo IP. Potrai riprovare tra {0} secondi.', 'notEnoughPrivilege' => 'Non si dispone dell\'autorizzazione necessaria per eseguire l\'operazione desiderata.', + // JWT Exceptions + 'invalidJWT' => '(To be translated) The token is invalid.', + 'expiredJWT' => '(To be translated) The token has expired.', + 'beforeValidJWT' => '(To be translated) The token is not yet available.', 'email' => 'Indirizzo Email', 'username' => 'Nome Utente', diff --git a/src/Language/ja/Auth.php b/src/Language/ja/Auth.php index e1d4870be..1ec2f346b 100644 --- a/src/Language/ja/Auth.php +++ b/src/Language/ja/Auth.php @@ -4,98 +4,102 @@ return [ // Exceptions - 'unknownAuthenticator' => '{0} は有効なオーセンティケーターではありません。', // '{0} is not a valid authenticator.', - 'unknownUserProvider' => '使用するユーザープロバイダーを決定できません。', // 'Unable to determine the User Provider to use.', - 'invalidUser' => '指定されたユーザーを見つけることができません。', // 'Unable to locate the specified user.', - 'bannedUser' => '現在あなたはアクセスが禁止されているため、ログインできません。', - 'logOutBannedUser' => 'アクセスが禁止されたため、ログアウトされました。', - 'badAttempt' => 'ログインできません。認証情報を確認してください。', // 'Unable to log you in. Please check your credentials.', - 'noPassword' => 'パスワードのないユーザーは認証できません。', // 'Cannot validate a user without a password.', - 'invalidPassword' => 'ログインできません。パスワードを確認してください。', // 'Unable to log you in. Please check your password.', - 'noToken' => 'すべてのリクエストは、{0}ヘッダーにBearerトークンが必要です。', // 'Every request must have a bearer token in the Authorization header.', - 'badToken' => 'アクセストークンが無効です。', // 'The access token is invalid.', - 'oldToken' => 'アクセストークンの有効期限が切れています。', // 'The access token has expired.', - 'noUserEntity' => 'パスワード検証のため、Userエンティティを指定する必要があります。', // 'User Entity must be provided for password validation.', - 'invalidEmail' => 'メールアドレスが一致しません。', // 'Unable to verify the email address matches the email on record.', - 'unableSendEmailToUser' => '申し訳ありませんが、メールの送信に問題がありました。 "{0}"にメールを送信できませんでした。', // 'Sorry, there was a problem sending the email. We could not send an email to "{0}".', - 'throttled' => 'このIPアドレスからのリクエストが多すぎます。 {0}秒後に再試行できます。', // Too many requests made from this IP address. You may try again in {0} seconds. - 'notEnoughPrivilege' => '目的の操作を実行するために必要な権限がありません。', // You do not have the necessary permission to perform the desired operation. + 'unknownAuthenticator' => '{0} は有効なオーセンティケーターではありません。', // '{0} is not a valid authenticator.' + 'unknownUserProvider' => '使用するユーザープロバイダーを決定できません。', // 'Unable to determine the User Provider to use.' + 'invalidUser' => '指定されたユーザーを見つけることができません。', // 'Unable to locate the specified user.' + 'bannedUser' => '現在あなたはアクセスが禁止されているため、ログインできません。', // 'Can not log you in as you are currently banned.' + 'logOutBannedUser' => 'アクセスが禁止されたため、ログアウトされました。', // 'You have been logged out because you have been banned.' + 'badAttempt' => 'ログインできません。認証情報を確認してください。', // 'Unable to log you in. Please check your credentials.' + 'noPassword' => 'パスワードのないユーザーは認証できません。', // 'Cannot validate a user without a password.' + 'invalidPassword' => 'ログインできません。パスワードを確認してください。', // 'Unable to log you in. Please check your password.' + 'noToken' => 'すべてのリクエストは、{0}ヘッダーにBearerトークンが必要です。', // 'Every request must have a bearer token in the {0} header.' + 'badToken' => 'アクセストークンが無効です。', // 'The access token is invalid.' + 'oldToken' => 'アクセストークンの有効期限が切れています。', // 'The access token has expired.' + 'noUserEntity' => 'パスワード検証のため、Userエンティティを指定する必要があります。', // 'User Entity must be provided for password validation.' + 'invalidEmail' => 'メールアドレスが一致しません。', // 'Unable to verify the email address matches the email on record.' + 'unableSendEmailToUser' => '申し訳ありませんが、メールの送信に問題がありました。 "{0}"にメールを送信できませんでした。', // 'Sorry, there was a problem sending the email. We could not send an email to "{0}".' + 'throttled' => 'このIPアドレスからのリクエストが多すぎます。 {0}秒後に再試行できます。', // 'Too many requests made from this IP address. You may try again in {0} seconds.' + 'notEnoughPrivilege' => '目的の操作を実行するために必要な権限がありません。', // 'You do not have the necessary permission to perform the desired operation.' + // JWT Exceptions + 'invalidJWT' => 'トークンが無効です。', // 'The token is invalid.' + 'expiredJWT' => 'トークンの有効期限が切れています。', // 'The token has expired.' + 'beforeValidJWT' => 'このトークンはまだ使えません。', // 'The token is not yet available.' - 'email' => 'メールアドレス', // 'Email Address', - 'username' => 'ユーザー名', // 'Username', - 'password' => 'パスワード', // 'Password', - 'passwordConfirm' => 'パスワード(再)', // 'Password (again)', - 'haveAccount' => 'すでにアカウントをお持ちの方', // 'Already have an account?', + 'email' => 'メールアドレス', // 'Email Address' + 'username' => 'ユーザー名', // 'Username' + 'password' => 'パスワード', // 'Password' + 'passwordConfirm' => 'パスワード(再)', // 'Password (again)' + 'haveAccount' => 'すでにアカウントをお持ちの方', // 'Already have an account?' // Buttons - 'confirm' => '確認する', // 'Confirm', - 'send' => '送信する', // 'Send', + 'confirm' => '確認する', // 'Confirm' + 'send' => '送信する', // 'Send' // Registration - 'register' => '登録', // 'Register', - 'registerDisabled' => '現在、登録はできません。', // 'Registration is not currently allowed.', - 'registerSuccess' => 'ようこそ!', // 'Welcome aboard!', + 'register' => '登録', // 'Register' + 'registerDisabled' => '現在、登録はできません。', // 'Registration is not currently allowed.' + 'registerSuccess' => 'ようこそ!', // 'Welcome aboard!' // Login - 'login' => 'ログイン', // 'Login', - 'needAccount' => 'アカウントが必要な方', // 'Need an account?', - 'rememberMe' => 'ログイン状態を保持する', // 'Remember me?', - 'forgotPassword' => 'パスワードをお忘れの方', // 'Forgot your password?', - 'useMagicLink' => 'ログインリンクを使用する', // 'Use a Login Link', - 'magicLinkSubject' => 'あなたのログインリンク', // 'Your Login Link', - 'magicTokenNotFound' => 'リンクを確認できません。', // 'Unable to verify the link.', - 'magicLinkExpired' => '申し訳ございません、リンクは切れています。', // 'Sorry, link has expired.', - 'checkYourEmail' => 'メールをチェックしてください!', // 'Check your email!', - 'magicLinkDetails' => 'ログインリンクが含まれたメールを送信しました。これは {0} 分間だけ有効です。', // 'We just sent you an email with a Login link inside. It is only valid for {0} minutes.', - 'successLogout' => '正常にログアウトしました。', // 'You have successfully logged out.', + 'login' => 'ログイン', // 'Login' + 'needAccount' => 'アカウントが必要な方', // 'Need an account?' + 'rememberMe' => 'ログイン状態を保持する', // 'Remember me?' + 'forgotPassword' => 'パスワードをお忘れの方', // 'Forgot your password?' + 'useMagicLink' => 'ログインリンクを使用する', // 'Use a Login Link' + 'magicLinkSubject' => 'あなたのログインリンク', // 'Your Login Link' + 'magicTokenNotFound' => 'リンクを確認できません。', // 'Unable to verify the link.' + 'magicLinkExpired' => '申し訳ございません、リンクは切れています。', // 'Sorry, link has expired.' + 'checkYourEmail' => 'メールをチェックしてください!', // 'Check your email!' + 'magicLinkDetails' => 'ログインリンクが含まれたメールを送信しました。これは {0} 分間だけ有効です。', // 'We just sent you an email with a Login link inside. It is only valid for {0} minutes.' + 'successLogout' => '正常にログアウトしました。', // 'You have successfully logged out.' // Passwords - 'errorPasswordLength' => 'パスワードは最低でも {0, number} 文字でなければなりません。', // 'Passwords must be at least {0, number} characters long.', - 'suggestPasswordLength' => 'パスフレーズ(最大255文字)は、覚えやすく、より安全なパスワードになります。', // 'Pass phrases - up to 255 characters long - make more secure passwords that are easy to remember.', - 'errorPasswordCommon' => 'パスワードは一般的なものであってはなりません。', // 'Password must not be a common password.', - 'suggestPasswordCommon' => 'パスワードは、65,000を超える一般的に使用されるパスワード、またはハッキングによって漏洩したパスワードに対してチェックされました。', // 'The password was checked against over 65k commonly used passwords or passwords that have been leaked through hacks.', - 'errorPasswordPersonal' => 'パスワードは、個人情報を再ハッシュ化したものを含むことはできません。', // 'Passwords cannot contain re-hashed personal information.', - 'suggestPasswordPersonal' => 'メールアドレスやユーザー名のバリエーションは、パスワードに使用しないでください。', // 'Variations on your email address or username should not be used for passwords.', - 'errorPasswordTooSimilar' => 'パスワードがユーザー名と似すぎています。', // 'Password is too similar to the username.', - 'suggestPasswordTooSimilar' => 'パスワードにユーザー名の一部を使用しないでください。', // 'Do not use parts of your username in your password.', - 'errorPasswordPwned' => 'パスワード {0} はデータ漏洩により公開されており、{2} の漏洩したパスワード中で {1, number} 回見られます。', // 'The password {0} has been exposed due to a data breach and has been seen {1, number} times in {2} of compromised passwords.', - 'suggestPasswordPwned' => '{0} は絶対にパスワードとして使ってはいけません。もしどこかで使っていたら、すぐに変更してください。', // '{0} should never be used as a password. If you are using it anywhere change it immediately.', - 'errorPasswordEmpty' => 'パスワードが必要です。', // 'A Password is required.', - 'errorPasswordTooLongBytes' => '{param} バイトを超えるパスワードは設定できません。', // 'Password cannot exceed {param} bytes in length.', - 'passwordChangeSuccess' => 'パスワードの変更に成功しました', // 'Password changed successfully', - 'userDoesNotExist' => 'パスワードは変更されていません。ユーザーは存在しません', // 'Password was not changed. User does not exist', - 'resetTokenExpired' => '申し訳ありません。リセットトークンの有効期限が切れました。', // 'Sorry. Your reset token has expired.', + 'errorPasswordLength' => 'パスワードは最低でも {0, number} 文字でなければなりません。', // 'Passwords must be at least {0, number} characters long.' + 'suggestPasswordLength' => 'パスフレーズ(最大255文字)は、覚えやすく、より安全なパスワードになります。', // 'Pass phrases - up to 255 characters long - make more secure passwords that are easy to remember.' + 'errorPasswordCommon' => 'パスワードは一般的なものであってはなりません。', // 'Password must not be a common password.' + 'suggestPasswordCommon' => 'パスワードは、65,000を超える一般的に使用されるパスワード、またはハッキングによって漏洩したパスワードに対してチェックされました。', // 'The password was checked against over 65k commonly used passwords or passwords that have been leaked through hacks.' + 'errorPasswordPersonal' => 'パスワードは、個人情報を再ハッシュ化したものを含むことはできません。', // 'Passwords cannot contain re-hashed personal information.' + 'suggestPasswordPersonal' => 'メールアドレスやユーザー名のバリエーションは、パスワードに使用しないでください。', // 'Variations on your email address or username should not be used for passwords.' + 'errorPasswordTooSimilar' => 'パスワードがユーザー名と似すぎています。', // 'Password is too similar to the username.' + 'suggestPasswordTooSimilar' => 'パスワードにユーザー名の一部を使用しないでください。', // 'Do not use parts of your username in your password.' + 'errorPasswordPwned' => 'パスワード {0} はデータ漏洩により公開されており、{2} の漏洩したパスワード中で {1, number} 回見られます。', // 'The password {0} has been exposed due to a data breach and has been seen {1, number} times in {2} of compromised passwords.' + 'suggestPasswordPwned' => '{0} は絶対にパスワードとして使ってはいけません。もしどこかで使っていたら、すぐに変更してください。', // '{0} should never be used as a password. If you are using it anywhere change it immediately.' + 'errorPasswordEmpty' => 'パスワードが必要です。', // 'A Password is required.' + 'errorPasswordTooLongBytes' => '{param} バイトを超えるパスワードは設定できません。', // 'Password cannot exceed {param} bytes in length.' + 'passwordChangeSuccess' => 'パスワードの変更に成功しました', // 'Password changed successfully' + 'userDoesNotExist' => 'パスワードは変更されていません。ユーザーは存在しません', // 'Password was not changed. User does not exist' + 'resetTokenExpired' => '申し訳ありません。リセットトークンの有効期限が切れました。', // 'Sorry. Your reset token has expired.' // Email Globals - 'emailInfo' => '本人に関する情報:', - 'emailIpAddress' => 'IPアドレス:', - 'emailDevice' => 'デバイス:', - 'emailDate' => '日時:', + 'emailInfo' => '本人に関する情報:', // 'Some information about the person:' + 'emailIpAddress' => 'IPアドレス:', // 'IP Address:' + 'emailDevice' => 'デバイス:', // 'Device:' + 'emailDate' => '日時:', // 'Date:' // 2FA - 'email2FATitle' => '二要素認証', // 'Two Factor Authentication', - 'confirmEmailAddress' => 'メールアドレスを確認してください。', // 'Confirm your email address.', - 'emailEnterCode' => 'メールを確認してください', // 'Confirm your Email', - 'emailConfirmCode' => '先ほどあなたのメールアドレスにお送りした 6桁のコードを入力してください。', // 'Enter the 6-digit code we just sent to your email address.', - 'email2FASubject' => '認証コード', // 'Your authentication code', - 'email2FAMailBody' => 'あなたの認証コード:', // 'Your authentication code is:', - 'invalid2FAToken' => 'コードが間違っています。', // 'The code was incorrect.', - 'need2FA' => '二要素認証を完了させる必要があります。', // 'You must complete a two-factor verification.', - 'needVerification' => 'アカウントの有効化を完了するために、メールを確認してください。', // 'Check your email to complete account activation.', + 'email2FATitle' => '二要素認証', // 'Two Factor Authentication' + 'confirmEmailAddress' => 'メールアドレスを確認してください。', // 'Confirm your email address.' + 'emailEnterCode' => 'メールを確認してください', // 'Confirm your Email' + 'emailConfirmCode' => '先ほどあなたのメールアドレスにお送りした 6桁のコードを入力してください。', // 'Enter the 6-digit code we just sent to your email address.' + 'email2FASubject' => '認証コード', // 'Your authentication code' + 'email2FAMailBody' => 'あなたの認証コード:', // 'Your authentication code is:' + 'invalid2FAToken' => 'コードが間違っています。', // 'The code was incorrect.' + 'need2FA' => '二要素認証を完了させる必要があります。', // 'You must complete a two-factor verification.' + 'needVerification' => 'アカウントの有効化を完了するために、メールを確認してください。', // 'Check your email to complete account activation.' // Activate - 'emailActivateTitle' => 'メールアクティベーション', // 'Email Activation', - 'emailActivateBody' => 'メールアドレスを確認するために、コードを送信しました。以下にコピーペーストしてください。', // 'We just sent an email to you with a code to confirm your email address. Copy that code and paste it below.', - 'emailActivateSubject' => 'アクティベーションコード', // 'Your activation code', - 'emailActivateMailBody' => '以下のコードを使用してアカウントを有効化し、サイトの利用を開始してください。', // 'Please use the code below to activate your account and start using the site.', - 'invalidActivateToken' => 'コードが間違っています。', // 'The code was incorrect.', - 'needActivate' => 'メールアドレスに送信されたコードを確認し、登録を完了する必要があります。', // 'You must complete your registration by confirming the code sent to your email address.', - 'activationBlocked' => 'ログインする前にアカウントを有効化する必要があります。', + 'emailActivateTitle' => 'メールアクティベーション', // 'Email Activation' + 'emailActivateBody' => 'メールアドレスを確認するために、コードを送信しました。以下にコピーペーストしてください。', // 'We just sent an email to you with a code to confirm your email address. Copy that code and paste it below.' + 'emailActivateSubject' => 'アクティベーションコード', // 'Your activation code' + 'emailActivateMailBody' => '以下のコードを使用してアカウントを有効化し、サイトの利用を開始してください。', // 'Please use the code below to activate your account and start using the site.' + 'invalidActivateToken' => 'コードが間違っています。', // 'The code was incorrect.' + 'needActivate' => 'メールアドレスに送信されたコードを確認し、登録を完了する必要があります。', // 'You must complete your registration by confirming the code sent to your email address.' + 'activationBlocked' => 'ログインする前にアカウントを有効化する必要があります。', // 'You must activate your account before logging in.' // Groups - 'unknownGroup' => '{0} は有効なグループではありません。', // '{0} is not a valid group.', - 'missingTitle' => 'グループにはタイトルが必要です。', // 'Groups must have a title.', + 'unknownGroup' => '{0} は有効なグループではありません。', // '{0} is not a valid group.' + 'missingTitle' => 'グループにはタイトルが必要です。', // 'Groups must have a title.' // Permissions - 'unknownPermission' => '{0} は有効なパーミッションではありません。', // '{0} is not a valid permission.', + 'unknownPermission' => '{0} は有効なパーミッションではありません。', // '{0} is not a valid permission.' ]; diff --git a/src/Language/pt-BR/Auth.php b/src/Language/pt-BR/Auth.php index 4a9c6cbf4..b2506bcdd 100644 --- a/src/Language/pt-BR/Auth.php +++ b/src/Language/pt-BR/Auth.php @@ -20,6 +20,10 @@ 'unableSendEmailToUser' => 'Desculpe, houve um problema ao enviar o email. Não pudemos enviar um email para {0}.', 'throttled' => 'Muitas solicitações feitas a partir deste endereço IP. Você pode tentar novamente em {0} segundos.', 'notEnoughPrivilege' => 'Você não tem a permissão necessária para realizar a operação desejada.', + // JWT Exceptions + 'invalidJWT' => 'O token é inválido.', + 'expiredJWT' => 'O token expirou.', + 'beforeValidJWT' => 'O token ainda não está disponível.', 'email' => 'Endereço de Email', 'username' => 'Nome de usuário', diff --git a/src/Language/pt/Auth.php b/src/Language/pt/Auth.php new file mode 100644 index 000000000..a5cfa8492 --- /dev/null +++ b/src/Language/pt/Auth.php @@ -0,0 +1,105 @@ + '{0} não é um autenticador válido.', + 'unknownUserProvider' => 'Não foi possível determinar o provedor de utilizador a ser usado.', + 'invalidUser' => 'Não foi possível localizar o utilizador especificado.', + 'bannedUser' => 'Não é possível fazer login porque está banido de momento.', + 'logOutBannedUser' => 'Foi desconectado porque foi banido.', + 'badAttempt' => 'Não foi possível fazer login. Por favor, verifique as suas credenciais.', + 'noPassword' => 'Não é possível validar um utilizador sem uma password.', + 'invalidPassword' => 'Não foi possível fazer login. Por favor, verifique a sua password.', + 'noToken' => 'Todos os pedidos devem ter um token portador no cabeçalho {0}.', + 'badToken' => 'O token de acesso é inválido.', + 'oldToken' => 'O token de acesso expirou.', + 'noUserEntity' => 'A entidade do utilizador deve ser fornecida para validação da password.', + 'invalidEmail' => 'Não foi possível verificar se o endereço de email corresponde ao e-mail registado.', + 'unableSendEmailToUser' => 'Desculpe, houve um problema ao enviar o email. Não pudemos enviar um email para {0}.', + 'throttled' => 'Muitas solicitações feitas a partir deste endereço IP. Pode tentar novamente em {0} segundos.', + 'notEnoughPrivilege' => 'Não tem a permissão necessária para realizar a operação desejada.', + // JWT Exceptions + 'invalidJWT' => '(To be translated) The token is invalid.', + 'expiredJWT' => '(To be translated) The token has expired.', + 'beforeValidJWT' => '(To be translated) The token is not yet available.', + + 'email' => 'Endereço de Email', + 'username' => 'Nome de utilizador', + 'password' => 'Senha', + 'passwordConfirm' => 'Senha (novamente)', + 'haveAccount' => 'Já tem uma conta?', + + // Botões + 'confirm' => 'Confirmar', + 'send' => 'Enviar', + + // Registro + 'register' => 'Registar', + 'registerDisabled' => 'O registo não é permitido no momento.', + 'registerSuccess' => 'Bem-vindo a bordo!', + + // Login + 'login' => 'Login', + 'needAccount' => 'Precisa de uma conta?', + 'rememberMe' => 'Lembrar', + 'forgotPassword' => 'Esqueceu a sua password?', + 'useMagicLink' => 'Use um Link de Login', + 'magicLinkSubject' => 'O seu Link de Login', + 'magicTokenNotFound' => 'Não foi possível verificar o link.', + 'magicLinkExpired' => 'Desculpe, o link expirou.', + 'checkYourEmail' => 'Verifique o seu e-mail!', + 'magicLinkDetails' => 'Acabamos de enviar um e-mail com um link de Login. Ele é válido apenas por {0} minutos.', + 'successLogout' => 'Saiu com sucesso.', + + // Senhas + 'errorPasswordLength' => 'As passwords devem ter pelo menos {0, number} caracteres.', + 'suggestPasswordLength' => 'Frases de password - até 255 caracteres - criam passwords mais seguras que são fáceis de lembrar.', + 'errorPasswordCommon' => 'A password não deve ser uma password comum.', + 'suggestPasswordCommon' => 'A password foi verificada contra mais de 65k passwords comuns ou passwords que foram vazadas por invasões.', + 'errorPasswordPersonal' => 'As passwords não podem conter informações pessoais re-criptografadas.', + 'suggestPasswordPersonal' => 'Variações do seu endereço de e-mail ou nome de utilizador não devem ser usadas como passwords.', + 'errorPasswordTooSimilar' => 'A password é muito semelhante ao nome de utilizador.', + 'suggestPasswordTooSimilar' => 'Não use partes do seu nome de utilizador na sua password.', + 'errorPasswordPwned' => 'A password {0} foi exposta devido a uma violação de dados e foi vista {1, number} vezes em {2} de passwords comprometidas.', + 'suggestPasswordPwned' => '{0} nunca deve ser usado como uma password. Se você estiver usando em algum lugar, altere imediatamente.', + 'errorPasswordEmpty' => 'É necessária uma password.', + 'errorPasswordTooLongBytes' => 'A password não pode exceder {param} bytes.', + 'passwordChangeSuccess' => 'Senha alterada com sucesso', + 'userDoesNotExist' => 'Senha não foi alterada. utilizador não existe', + 'resetTokenExpired' => 'Desculpe. Seu token de redefinição expirou.', + + // E-mails Globais + 'emailInfo' => 'Algumas informações sobre a pessoa:', + 'emailIpAddress' => 'Endereço IP:', + 'emailDevice' => 'Dispositivo:', + 'emailDate' => 'Data:', + + // 2FA + 'email2FATitle' => 'Autenticação de dois fatores', + 'confirmEmailAddress' => 'Confirme seu endereço de e-mail.', + 'emailEnterCode' => 'Confirme seu email', + 'emailConfirmCode' => 'Insira o código de 6 dígitos que acabamos de enviar para seu endereço de e-mail.', + 'email2FASubject' => 'Seu código de autenticação', + 'email2FAMailBody' => 'Seu código de autenticação é:', + 'invalid2FAToken' => 'O código estava incorreto.', + 'need2FA' => 'Deve concluir uma verificação de dois fatores.', + 'needVerification' => 'Verifique seu e-mail para concluir a ativação da conta.', + + // Ativar + 'emailActivateTitle' => 'Ativação de email', + 'emailActivateBody' => 'Acabamos de enviar um email para você com um código para confirmar seu endereço de e-mail. Copie esse código e cole abaixo.', + 'emailActivateSubject' => 'O seu código de ativação', + 'emailActivateMailBody' => 'Use o código abaixo para ativar sua conta e começar a usar o site.', + 'invalidActivateToken' => 'O código estava incorreto.', + 'needActivate' => 'Deve concluir seu registro confirmando o código enviado para seu endereço de e-mail.', + 'activationBlocked' => 'Deve ativar sua conta antes de fazer o login.', + + // Grupos + 'unknownGroup' => '{0} não é um grupo válido.', + 'missingTitle' => 'Os grupos devem ter um título.', + + // Permissões + 'unknownPermission' => '{0} não é uma permissão válida.', +]; diff --git a/src/Language/sk/Auth.php b/src/Language/sk/Auth.php index 3617e0560..3424e88c4 100644 --- a/src/Language/sk/Auth.php +++ b/src/Language/sk/Auth.php @@ -20,6 +20,10 @@ 'unableSendEmailToUser' => 'Ľutujeme, pri odosielaní e-mailu sa vyskytol problém. Nepodarilo sa nám odoslať e-mail na adresu „{0}".', 'throttled' => 'Z tejto adresy IP bolo odoslaných príliš veľa žiadostí. Môžete to skúsiť znova o {0} sekúnd.', 'notEnoughPrivilege' => 'Nemáte potrebné povolenie na vykonanie požadovanej operácie.', + // JWT Exceptions + 'invalidJWT' => '(To be translated) The token is invalid.', + 'expiredJWT' => '(To be translated) The token has expired.', + 'beforeValidJWT' => '(To be translated) The token is not yet available.', 'email' => 'Emailová adresa', 'username' => 'Používateľské meno', diff --git a/src/Language/sr/Auth.php b/src/Language/sr/Auth.php index 22b2ecb5d..6c8e71f01 100644 --- a/src/Language/sr/Auth.php +++ b/src/Language/sr/Auth.php @@ -20,6 +20,10 @@ 'unableSendEmailToUser' => 'Žao nam je ali slanje email poruke nije moguće. Nismo u mogućnosti poslati poruku na "{0}".', 'throttled' => 'Preveliki broj zahteva sa vaše IP adrese. Možete pokušati ponovo za {0} secondi.', 'notEnoughPrivilege' => 'Nemate dovoljan nivo autorizacije za zahtevanu akciju.', + // JWT Exceptions + 'invalidJWT' => '(To be translated) The token is invalid.', + 'expiredJWT' => '(To be translated) The token has expired.', + 'beforeValidJWT' => '(To be translated) The token is not yet available.', 'email' => 'E-mail Adresa', 'username' => 'Korisničko ime', diff --git a/src/Language/sv-SE/Auth.php b/src/Language/sv-SE/Auth.php index 7176f650a..a37b09665 100644 --- a/src/Language/sv-SE/Auth.php +++ b/src/Language/sv-SE/Auth.php @@ -20,6 +20,10 @@ 'unableSendEmailToUser' => 'Det var inte möjligt att skicka epost. Det gick inte att skicka till "{0}".', 'throttled' => 'För många anrop från denna IP-adress. Du kan försöka igen om {0} sekunder.', 'notEnoughPrivilege' => 'Du har inte nödvändiga rättigheter för detta kommando.', + // JWT Exceptions + 'invalidJWT' => '(To be translated) The token is invalid.', + 'expiredJWT' => '(To be translated) The token has expired.', + 'beforeValidJWT' => '(To be translated) The token is not yet available.', 'email' => 'Epostadress', 'username' => 'Användarnamn', diff --git a/src/Language/tr/Auth.php b/src/Language/tr/Auth.php index 48c7a247f..57f515e31 100644 --- a/src/Language/tr/Auth.php +++ b/src/Language/tr/Auth.php @@ -20,6 +20,10 @@ 'unableSendEmailToUser' => 'Üzgünüz, e-posta gönderilirken bir sorun oluştu. "{0}" adresine e-posta gönderemedik.', 'throttled' => 'Bu IP adresinden çok fazla istek yapıldı. {0} saniye sonra tekrar deneyebilirsiniz.', 'notEnoughPrivilege' => 'İstediğiniz işlemi gerçekleştirmek için gerekli izne sahip değilsiniz.', + // JWT Exceptions + 'invalidJWT' => '(To be translated) The token is invalid.', + 'expiredJWT' => '(To be translated) The token has expired.', + 'beforeValidJWT' => '(To be translated) The token is not yet available.', 'email' => 'E-posta Adresi', 'username' => 'Kullanıcı Adı', diff --git a/src/Language/uk/Auth.php b/src/Language/uk/Auth.php new file mode 100644 index 000000000..56828330c --- /dev/null +++ b/src/Language/uk/Auth.php @@ -0,0 +1,105 @@ + '{0} не є дійсним автентифікатором.', + 'unknownUserProvider' => 'Неможливо визначити постачальника користувача для використання.', + 'invalidUser' => 'Неможливо знайти вказаного користувача.', + 'bannedUser' => 'Неможливо увійти, оскільки ви зараз забанені.', + 'logOutBannedUser' => 'Ви вийшли з системи, оскільки вас забанили.', + 'badAttempt' => 'Неможливо увійти. Перевірте свої облікові дані.', + 'noPassword' => 'Неможливо перевірити користувача без пароля.', + 'invalidPassword' => 'Неможливо увійти. Перевірте свій пароль.', + 'noToken' => 'Кожен запит повинен мати токен носія в заголовку {0}.', + 'badToken' => 'Токен доступу недійсний.', + 'oldToken' => 'Термін дії токена доступу минув.', + 'noUserEntity' => 'Потрібно вказати сутність користувача для підтвердження пароля.', + 'invalidEmail' => 'Неможливо перевірити, що адреса електронної пошти відповідає зареєстрованій.', + 'unableSendEmailToUser' => 'Вибачте, під час надсилання електронного листа виникла проблема. Не вдалося надіслати електронний лист на "{0}".', + 'throttled' => 'Забагато запитів зроблено з цієї IP-адреси. Ви можете спробувати ще раз через {0} секунд.', + 'notEnoughPrivilege' => 'У вас немає необхідного дозволу для виконання потрібної операції.', + // JWT Exceptions + 'invalidJWT' => 'Маркер недійсний.', + 'expiredJWT' => 'Термін дії маркера минув.', + 'beforeValidJWT' => 'Маркер ще не доступний.', + + 'email' => 'Адреса електронної пошти', + 'username' => 'Ім’я користувача', + 'password' => 'Пароль', + 'passwordConfirm' => 'Пароль (ще раз)', + 'haveAccount' => 'Вже є обліковий запис?', + + // Buttons + 'confirm' => 'Підтвердити', + 'send' => 'Надіслати', + + // Registration + 'register' => 'Зареєструватися', + 'registerDisabled' => 'Реєстрація зараз не дозволена.', + 'registerSuccess' => 'Ласкаво просимо на борт!', + + // Login + 'login' => 'Вхід', + 'needAccount' => 'Потрібен обліковий запис?', + 'rememberMe' => 'Пам’ятай мене?', + 'forgotPassword' => 'Забули пароль?', + 'useMagicLink' => 'Використовуйте посилання для входу', + 'magicLinkSubject' => 'Ваше посилання для входу', + 'magicTokenNotFound' => 'Неможливо перевірити посилання.', + 'magicLinkExpired' => 'Вибачте, термін дії посилання закінчився.', + 'checkYourEmail' => 'Перевірте свою електронну пошту!', + 'magicLinkDetails' => 'Ми щойно надіслали вам електронний лист із посиланням для входу. Він дійсний лише протягом {0} хвилин.', + 'successLogout' => 'Ви успішно вийшли.', + + // Passwords + 'errorPasswordLength' => 'Паролі повинні містити принаймні {0, числових} символів.', + 'suggestPasswordLength' => 'Паролі до 255 символів створюють надійніші паролі, які легко запам’ятати.', + 'errorPasswordCommon' => 'Пароль не має бути звичайним.', + 'suggestPasswordCommon' => 'Пароль перевірено на більш ніж 65 тисяч часто використовуваних паролів або паролів, які були розкриті через хакерські атаки.', + 'errorPasswordPersonal' => 'Паролі не можуть містити повторно хешовану особисту інформацію.', + 'suggestPasswordPersonal' => 'Варіанти вашої адреси електронної пошти або імені користувача не повинні використовувати для паролів.', + 'errorPasswordTooSimilar' => 'Пароль занадто схожий на ім’я користувача.', + 'suggestPasswordTooSimilar' => 'Не використовуйте частини свого імені користувача в паролі.', + 'errorPasswordPwned' => 'Пароль {0} було розкрито внаслідок витоку даних і було виявлено {1} разів у {2} зламаних паролів.', + 'suggestPasswordPwned' => '{0} ніколи не слід використовувати як пароль. Якщо ви використовуєте його десь, негайно змініть його.', + 'errorPasswordEmpty' => 'Необхідно ввести пароль.', + 'errorPasswordTooLongBytes' => 'Довжина пароля не може перевищувати {param} байт.', + 'passwordChangeSuccess' => 'Пароль успішно змінено', + 'userDoesNotExist' => 'Пароль не змінено. Користувач не існує', + 'resetTokenExpired' => 'Вибачте. Термін дії вашого токена скидання минув.', + + // Email Globals + 'emailInfo' => 'Деяка відомості про особу:', + 'emailIpAddress' => 'IP-адреса:', + 'emailDevice' => 'Пристрій:', + 'emailDate' => 'Дата:', + + // 2FA + 'email2FATitle' => 'Двофакторна автентифікація', + 'confirmEmailAddress' => 'Підтвердьте адресу електронної пошти.', + 'emailEnterCode' => 'Підтвердьте свій Email', + 'emailConfirmCode' => 'Введіть 6-значний код, який ми щойно надіслали на вашу адресу електронної пошти.', + 'email2FASubject' => 'Ваш код автентифікації', + 'email2FAMailBody' => 'Ваш код автентифікації:', + 'invalid2FAToken' => 'Код невірний.', + 'need2FA' => 'Ви повинні пройти двофакторну перевірку.', + 'needVerification' => 'Перевірте свою електронну пошту, щоб завершити активацію облікового запису.', + + // Activate + 'emailActivateTitle' => 'Активація електронної пошти', + 'emailActivateBody' => 'Ми щойно надіслали вам електронний лист із кодом для підтвердження вашої електронної адреси. Скопіюйте цей код і вставте його нижче.', + 'emailActivateSubject' => 'Ваш код активації', + 'emailActivateMailBody' => 'Будь ласка, використовуйте наведений нижче код, щоб активувати свій обліковий запис і почати користуватися сайтом.', + 'invalidActivateToken' => 'Код був невірний.', + 'needActivate' => 'Ви повинні завершити реєстрацію, підтвердивши код, надісланий на вашу електронну адресу.', + 'activationBlocked' => 'Ви повинні активувати свій обліковий запис перед входом.', + + // Groups + 'unknownGroup' => '{0} недійсна група.', + 'missingTitle' => 'Групи повинні мати назву.', + + // Permissions + 'unknownPermission' => '{0} не дійсний дозвіл.', +]; diff --git a/tests/Authentication/Authenticators/JWTAuthenticatorTest.php b/tests/Authentication/Authenticators/JWTAuthenticatorTest.php new file mode 100644 index 000000000..5c074df12 --- /dev/null +++ b/tests/Authentication/Authenticators/JWTAuthenticatorTest.php @@ -0,0 +1,276 @@ +authenticators['jwt'] = JWT::class; + + $auth = new Authentication($config); + $auth->setProvider(\model(UserModel::class)); + + /** @var JWT $authenticator */ + $authenticator = $auth->factory('jwt'); + $this->auth = $authenticator; + + Services::injectMock('events', new MockEvents()); + } + + private function createUser(): User + { + return \fake(UserModel::class); + } + + public function testLogin(): void + { + $user = $this->createUser(); + + $this->auth->login($user); + + // Stores the user + $this->assertInstanceOf(User::class, $this->auth->getUser()); + $this->assertSame($user->id, $this->auth->getUser()->id); + } + + public function testLogout(): void + { + // this one's a little odd since it's stateless, but roll with it... + + $user = $this->createUser(); + + $this->auth->login($user); + $this->assertNotNull($this->auth->getUser()); + + $this->auth->logout(); + $this->assertNull($this->auth->getUser()); + } + + public function testLoginById(): void + { + $user = $this->createUser(); + + $this->assertFalse($this->auth->loggedIn()); + + $this->auth->loginById($user->id); + + $this->assertTrue($this->auth->loggedIn()); + } + + public function testLoginByIdNoUser(): void + { + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('Unable to locate the specified user.'); + + $this->createUser(); + + $this->assertFalse($this->auth->loggedIn()); + + $this->auth->loginById(9999); + } + + public function testCheckNoToken(): void + { + $result = $this->auth->check([]); + + $this->assertFalse($result->isOK()); + $this->assertSame( + \lang('Auth.noToken', [config(AuthJWT::class)->authenticatorHeader]), + $result->reason() + ); + } + + public function testCheckBadSignatureToken(): void + { + $result = $this->auth->check(['token' => self::BAD_JWT]); + + $this->assertFalse($result->isOK()); + $this->assertSame(lang('Auth.invalidJWT'), $result->reason()); + } + + public function testCheckNoSubToken(): void + { + /** @var AuthJWT $config */ + $config = config('AuthJWT'); + $payload = [ + 'iss' => $config->defaultClaims['iss'], // issuer + ]; + $token = FirebaseJWT::encode($payload, $config->keys['default'][0]['secret'], $config->keys['default'][0]['alg']); + + $result = $this->auth->check(['token' => $token]); + + $this->assertFalse($result->isOK()); + $this->assertSame('Invalid JWT: no user_id', $result->reason()); + } + + public function testCheckOldToken(): void + { + Time::setTestNow('-1 hour'); + $token = $this->generateJWT(); + Time::setTestNow(); + + $result = $this->auth->check(['token' => $token]); + + $this->assertFalse($result->isOK()); + $this->assertSame(lang('Auth.expiredJWT'), $result->reason()); + } + + public function testCheckNoUserInDatabase(): void + { + $token = $this->generateJWT(); + + $users = \model(UserModel::class); + $users->delete(1); + + $result = $this->auth->check(['token' => $token]); + + $this->assertFalse($result->isOK()); + $this->assertSame(\lang('Auth.invalidUser'), $result->reason()); + } + + public function testCheckSuccess(): void + { + $token = $this->generateJWT(); + + $result = $this->auth->check(['token' => $token]); + + $this->assertTrue($result->isOK()); + $this->assertInstanceOf(User::class, $result->extraInfo()); + $this->assertSame(1, $result->extraInfo()->id); + } + + public function testGetPayload(): void + { + $token = $this->generateJWT(); + + $this->auth->check(['token' => $token]); + $payload = $this->auth->getPayload(); + + $this->assertSame((string) $this->user->id, $payload->sub); + /** @var AuthJWT $config */ + $config = config('AuthJWT'); + $this->assertSame($config->defaultClaims['iss'], $payload->iss); + } + + public function testAttemptBadSignatureToken(): void + { + $result = $this->auth->attempt([ + 'token' => self::BAD_JWT, + ]); + + $this->assertInstanceOf(Result::class, $result); + $this->assertFalse($result->isOK()); + $this->assertSame(lang('Auth.invalidJWT'), $result->reason()); + + // A login attempt should have always been recorded + $this->seeInDatabase('auth_token_logins', [ + 'id_type' => JWT::ID_TYPE_JWT, + 'identifier' => self::BAD_JWT, + 'success' => 0, + ]); + } + + public function testAttemptBannedUser(): void + { + $token = $this->generateJWT(); + + $this->user->ban(); + + $result = $this->auth->attempt([ + 'token' => $token, + ]); + + $this->assertInstanceOf(Result::class, $result); + $this->assertFalse($result->isOK()); + $this->assertSame(lang('Auth.bannedUser'), $result->reason()); + + // The login attempt should have been recorded + $this->seeInDatabase('auth_token_logins', [ + 'id_type' => JWT::ID_TYPE_JWT, + 'identifier' => $token, + 'success' => 0, + 'user_id' => $this->user->id, + ]); + } + + public function testAttemptSuccess(): void + { + // Change $recordLoginAttempt in Config. + /** @var AuthJWT $config */ + $config = config('AuthJWT'); + $config->recordLoginAttempt = Auth::RECORD_LOGIN_ATTEMPT_ALL; + + $token = $this->generateJWT(); + + $result = $this->auth->attempt([ + 'token' => $token, + ]); + + $this->assertInstanceOf(Result::class, $result); + $this->assertTrue($result->isOK()); + + $foundUser = $result->extraInfo(); + + $this->assertInstanceOf(User::class, $foundUser); + $this->assertSame(1, $foundUser->id); + + // A login attempt should have been recorded + $this->seeInDatabase('auth_token_logins', [ + 'id_type' => JWT::ID_TYPE_JWT, + 'identifier' => $token, + 'success' => 1, + ]); + } + + public function testRecordActiveDateNoUser(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Authentication\Authenticators\JWT::recordActiveDate() requires logged in user before calling.' + ); + + $this->auth->recordActiveDate(); + } + + /** + * @param Time|null $clock The Time object + */ + private function generateJWT(?Time $clock = null): string + { + $this->user = \fake(UserModel::class, ['id' => 1, 'username' => 'John Smith']); + + $generator = new JWTManager($clock); + + return $generator->generateToken($this->user); + } +} diff --git a/tests/Authentication/Filters/JWTFilterTest.php b/tests/Authentication/Filters/JWTFilterTest.php new file mode 100644 index 000000000..082627888 --- /dev/null +++ b/tests/Authentication/Filters/JWTFilterTest.php @@ -0,0 +1,87 @@ +authenticators['jwt'] = JWT::class; + + // Register our filter + $filterConfig = \config('Filters'); + $filterConfig->aliases['jwtAuth'] = JWTAuth::class; + Factories::injectMock('filters', 'filters', $filterConfig); + + // Add a test route that we can visit to trigger. + $routes = \service('routes'); + $routes->group('/', ['filter' => 'jwtAuth'], static function ($routes): void { + $routes->get('protected-route', static function (): void { + echo 'Protected'; + }); + }); + $routes->get('open-route', static function (): void { + echo 'Open'; + }); + $routes->get('login', 'AuthController::login', ['as' => 'login']); + Services::injectMock('routes', $routes); + } + + public function testFilterNotAuthorized(): void + { + $result = $this->call('get', 'protected-route'); + + $result->assertStatus(401); + + $result = $this->get('open-route'); + + $result->assertStatus(200); + $result->assertSee('Open'); + } + + public function testFilterSuccess(): void + { + /** @var User $user */ + $user = \fake(UserModel::class); + + $generator = new JWTManager(); + $token = $generator->generateToken($user); + + $result = $this->withHeaders(['Authorization' => 'Bearer ' . $token]) + ->get('protected-route'); + + $result->assertStatus(200); + $result->assertSee('Protected'); + + $this->assertSame($user->id, \auth('jwt')->id()); + $this->assertSame($user->id, \auth('jwt')->user()->id); + } +} diff --git a/tests/Language/AbstractTranslationTestCase.php b/tests/Language/AbstractTranslationTestCase.php index 49e114184..05097897c 100644 --- a/tests/Language/AbstractTranslationTestCase.php +++ b/tests/Language/AbstractTranslationTestCase.php @@ -65,16 +65,16 @@ abstract class AbstractTranslationTestCase extends TestCase // DutchTranslationTest::class => 'nl', // NorwegianTranslationTest::class => 'no', // PolishTranslationTest::class => 'pl', - // PortugueseTranslationTest::class => 'pt', - BrazilianTranslationTest::class => 'pt-BR', + PortugueseTranslationTest::class => 'pt', + BrazilianTranslationTest::class => 'pt-BR', // RussianTranslationTest::class => 'ru', // SinhalaTranslationTest::class => 'si', SlovakTranslationTest::class => 'sk', SerbianTranslationTest::class => 'sr', SwedishTranslationTest::class => 'sv-SE', // ThaiTranslationTest::class => 'th', - TurkishTranslationTest::class => 'tr', - // UkrainianTranslationTest::class => 'uk', + TurkishTranslationTest::class => 'tr', + UkrainianTranslationTest::class => 'uk', // VietnameseTranslationTest::class => 'vi', // SimplifiedChineseTranslationTest::class => 'zh-CN', // TraditionalChineseTranslationTest::class => 'zh-TW', diff --git a/tests/Language/PortugueseTranslationTest.php b/tests/Language/PortugueseTranslationTest.php new file mode 100644 index 000000000..1b74b90ad --- /dev/null +++ b/tests/Language/PortugueseTranslationTest.php @@ -0,0 +1,15 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Language; + +/** + * @internal + */ +final class UkrainianTranslationTest extends AbstractTranslationTestCase +{ +} diff --git a/tests/Unit/Authentication/JWT/Adapters/FirebaseAdapaterTest.php b/tests/Unit/Authentication/JWT/Adapters/FirebaseAdapaterTest.php new file mode 100644 index 000000000..9618c151e --- /dev/null +++ b/tests/Unit/Authentication/JWT/Adapters/FirebaseAdapaterTest.php @@ -0,0 +1,145 @@ +generateJWT(); + + $adapter = new FirebaseAdapter(); + + /** @var AuthJWT $config */ + $config = config('AuthJWT'); + + $key = 'default'; + $payload = $adapter->decode($token, $key); + + $this->assertSame($config->defaultClaims['iss'], $payload->iss); + $this->assertSame('1', $payload->sub); + } + + /** + * @param Time|null $clock The Time object + */ + public static function generateJWT(?Time $clock = null): string + { + /** @var User $user */ + $user = fake(UserModel::class, ['id' => 1, 'username' => 'John Smith'], false); + + $generator = new JWTManager($clock); + + return $generator->generateToken($user); + } + + public function testDecodeSignatureInvalidException(): void + { + $this->expectException(InvalidTokenException::class); + $this->expectExceptionMessage(lang('Auth.invalidJWT')); + + $adapter = new FirebaseAdapter(); + + $key = 'default'; + $token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJJc3N1ZXIgb2YgdGhlIEpXVCIsImF1ZCI6IkF1ZGllbmNlIG9mIHRoZSBKV1QiLCJzdWIiOiIxIiwiaWF0IjoxNjUzOTkxOTg5LCJleHAiOjE2NTM5OTU1ODl9.hgOYHEcT6RGHb3po1lspTcmjrylY1Cy1IvYmHOyx0CY'; + $adapter->decode($token, $key); + } + + public function testDecodeExpiredToken(): void + { + $this->expectException(InvalidTokenException::class); + $this->expectExceptionMessage(lang('Auth.expiredJWT')); + + $adapter = new FirebaseAdapter(); + + Time::setTestNow('-1 hour'); + $token = $this->generateJWT(); + Time::setTestNow(); + + $key = 'default'; + $adapter->decode($token, $key); + } + + public function testDecodeInvalidTokenExceptionUnexpectedValueException(): void + { + $token = $this->generateJWT(); + + // Change algorithm and it makes the key invalid. + $config = config(AuthJWT::class); + $config->keys['default'][0]['alg'] = 'ES256'; + + $adapter = new FirebaseAdapter(); + + try { + $key = 'default'; + $adapter->decode($token, $key); + } catch (InvalidTokenException $e) { + $prevException = $e->getPrevious(); + + $this->assertInstanceOf(UnexpectedValueException::class, $prevException); + $this->assertSame('Incorrect key for this algorithm', $prevException->getMessage()); + + return; + } + + $this->fail('InvalidTokenException is not thrown.'); + } + + public function testDecodeInvalidArgumentException(): void + { + $this->expectException(ShieldInvalidArgumentException::class); + $this->expectExceptionMessage('Invalid Keyset: "default". Key material must not be empty'); + + $token = $this->generateJWT(); + + // Set invalid key. + $config = config(AuthJWT::class); + $config->keys['default'][0] = [ + 'alg' => '', + 'secret' => '', + ]; + + $adapter = new FirebaseAdapter(); + + $key = 'default'; + $adapter->decode($token, $key); + } + + public function testEncodeLogicExceptionLogicException(): void + { + $this->expectException(ShieldLogicException::class); + $this->expectExceptionMessage('Cannot encode JWT: Algorithm not supported'); + + // Set unsupported algorithm. + $config = config(AuthJWT::class); + $config->keys['default'][0]['alg'] = 'PS256'; + + $adapter = new FirebaseAdapter(); + + $key = 'default'; + $payload = [ + 'iss' => 'http://example.org', + 'aud' => 'http://example.com', + 'iat' => 1_356_999_524, + 'nbf' => 1_357_000_000, + ]; + $adapter->encode($payload, $key); + } +} diff --git a/tests/Unit/Authentication/JWT/JWTManagerTest.php b/tests/Unit/Authentication/JWT/JWTManagerTest.php new file mode 100644 index 000000000..7f5adfc1a --- /dev/null +++ b/tests/Unit/Authentication/JWT/JWTManagerTest.php @@ -0,0 +1,383 @@ + 1, 'username' => 'John Smith'], false); + + // Fix the current time for testing. + Time::setTestNow('now'); + + $clock = new Time(); + $manager = $this->createJWTManager($clock); + + $currentTime = $clock->now(); + + $token = $manager->generateToken($user); + + // Reset the current time. + Time::setTestNow(); + + $this->assertIsString($token); + $this->assertStringStartsWith('eyJ', $token); + + return [$token, $currentTime]; + } + + /** + * @depends testGenerateToken + */ + public function testGenerateTokenPayload(array $data): void + { + [$token, $currentTime] = $data; + + $manager = $this->createJWTManager(); + $payload = $manager->parse($token); + + $config = config(AuthJWT::class); + $expected = [ + 'iss' => $config->defaultClaims['iss'], + 'sub' => '1', + 'iat' => $currentTime->getTimestamp(), + 'exp' => $currentTime->getTimestamp() + $config->timeToLive, + ]; + $this->assertSame($expected, (array) $payload); + } + + public function testGenerateTokenAddClaims(): void + { + /** @var User $user */ + $user = fake(UserModel::class, ['id' => 1, 'username' => 'John Smith'], false); + + $manager = $this->createJWTManager(); + + $claims = [ + 'email' => 'admin@example.jp', + ]; + $token = $manager->generateToken($user, $claims); + + $this->assertIsString($token); + + $payload = $this->decodeJWT($token, 'payload'); + + $this->assertStringStartsWith('1', $payload['sub']); + $this->assertStringStartsWith('admin@example.jp', $payload['email']); + } + + public function testIssue() + { + // Fix the current time for testing. + Time::setTestNow('now'); + + $clock = new Time(); + $manager = $this->createJWTManager($clock); + + $currentTime = $clock->now(); + + $payload = [ + 'user_id' => '1', + 'email' => 'admin@example.jp', + ]; + + $token = $manager->issue($payload, DAY); + + // Reset the current time. + Time::setTestNow(); + + $this->assertIsString($token); + $this->assertStringStartsWith('eyJ', $token); + + return [$token, $currentTime]; + } + + /** + * @depends testIssue + */ + public function testIssuePayload(array $data): void + { + [$token, $currentTime] = $data; + + $manager = $this->createJWTManager(); + $payload = $manager->parse($token); + + $config = config(AuthJWT::class); + $expected = [ + 'iss' => $config->defaultClaims['iss'], + 'user_id' => '1', + 'email' => 'admin@example.jp', + 'iat' => $currentTime->getTimestamp(), + 'exp' => $currentTime->getTimestamp() + DAY, + ]; + $this->assertSame($expected, (array) $payload); + } + + public function testIssueSetKid(): void + { + $manager = $this->createJWTManager(); + + // Set kid + $config = config(AuthJWT::class); + $config->keys['default'][0]['kid'] = 'Key01'; + + $payload = [ + 'user_id' => '1', + ]; + $token = $manager->issue($payload, DAY); + + $this->assertIsString($token); + + $headers = $this->decodeJWT($token, 'header'); + $this->assertSame([ + 'typ' => 'JWT', + 'alg' => 'HS256', + 'kid' => 'Key01', + ], $headers); + } + + public function testIssueAddHeader(): void + { + $manager = $this->createJWTManager(); + + $payload = [ + 'user_id' => '1', + ]; + $headers = [ + 'extra_key' => 'extra_value', + ]; + $token = $manager->issue($payload, DAY, 'default', $headers); + + $this->assertIsString($token); + + $headers = $this->decodeJWT($token, 'header'); + $this->assertSame([ + 'extra_key' => 'extra_value', + 'typ' => 'JWT', + 'alg' => 'HS256', + ], $headers); + } + + public function testIssueWithAsymmetricKey(): void + { + $manager = $this->createJWTManager(); + + $config = config(AuthJWT::class); + $config->keys['default'][0] = [ + 'alg' => 'RS256', // algorithm. + 'public' => '', // Public Key + 'private' => <<<'EOD' + -----BEGIN RSA PRIVATE KEY----- + MIIEowIBAAKCAQEAuzWHNM5f+amCjQztc5QTfJfzCC5J4nuW+L/aOxZ4f8J3Frew + M2c/dufrnmedsApb0By7WhaHlcqCh/ScAPyJhzkPYLae7bTVro3hok0zDITR8F6S + JGL42JAEUk+ILkPI+DONM0+3vzk6Kvfe548tu4czCuqU8BGVOlnp6IqBHhAswNMM + 78pos/2z0CjPM4tbeXqSTTbNkXRboxjU29vSopcT51koWOgiTf3C7nJUoMWZHZI5 + HqnIhPAG9yv8HAgNk6CMk2CadVHDo4IxjxTzTTqo1SCSH2pooJl9O8at6kkRYsrZ + WwsKlOFE2LUce7ObnXsYihStBUDoeBQlGG/BwQIDAQABAoIBAFtGaOqNKGwggn9k + 6yzr6GhZ6Wt2rh1Xpq8XUz514UBhPxD7dFRLpbzCrLVpzY80LbmVGJ9+1pJozyWc + VKeCeUdNwbqkr240Oe7GTFmGjDoxU+5/HX/SJYPpC8JZ9oqgEA87iz+WQX9hVoP2 + oF6EB4ckDvXmk8FMwVZW2l2/kd5mrEVbDaXKxhvUDf52iVD+sGIlTif7mBgR99/b + c3qiCnxCMmfYUnT2eh7Vv2LhCR/G9S6C3R4lA71rEyiU3KgsGfg0d82/XWXbegJW + h3QbWNtQLxTuIvLq5aAryV3PfaHlPgdgK0ft6ocU2de2FagFka3nfVEyC7IUsNTK + bq6nhAECgYEA7d/0DPOIaItl/8BWKyCuAHMss47j0wlGbBSHdJIiS55akMvnAG0M + 39y22Qqfzh1at9kBFeYeFIIU82ZLF3xOcE3z6pJZ4Dyvx4BYdXH77odo9uVK9s1l + 3T3BlMcqd1hvZLMS7dviyH79jZo4CXSHiKzc7pQ2YfK5eKxKqONeXuECgYEAyXlG + vonaus/YTb1IBei9HwaccnQ/1HRn6MvfDjb7JJDIBhNClGPt6xRlzBbSZ73c2QEC + 6Fu9h36K/HZ2qcLd2bXiNyhIV7b6tVKk+0Psoj0dL9EbhsD1OsmE1nTPyAc9XZbb + OPYxy+dpBCUA8/1U9+uiFoCa7mIbWcSQ+39gHuECgYAz82pQfct30aH4JiBrkNqP + nJfRq05UY70uk5k1u0ikLTRoVS/hJu/d4E1Kv4hBMqYCavFSwAwnvHUo51lVCr/y + xQOVYlsgnwBg2MX4+GjmIkqpSVCC8D7j/73MaWb746OIYZervQ8dbKahi2HbpsiG + 8AHcVSA/agxZr38qvWV54QKBgCD5TlDE8x18AuTGQ9FjxAAd7uD0kbXNz2vUYg9L + hFL5tyL3aAAtUrUUw4xhd9IuysRhW/53dU+FsG2dXdJu6CxHjlyEpUJl2iZu/j15 + YnMzGWHIEX8+eWRDsw/+Ujtko/B7TinGcWPz3cYl4EAOiCeDUyXnqnO1btCEUU44 + DJ1BAoGBAJuPD27ErTSVtId90+M4zFPNibFP50KprVdc8CR37BE7r8vuGgNYXmnI + RLnGP9p3pVgFCktORuYS2J/6t84I3+A17nEoB4xvhTLeAinAW/uTQOUmNicOP4Ek + 2MsLL2kHgL8bLTmvXV4FX+PXphrDKg1XxzOYn0otuoqdAQrkK4og + -----END RSA PRIVATE KEY----- + EOD, + ]; + + $payload = [ + 'user_id' => '1', + ]; + $token = $manager->issue($payload, DAY); + + $this->assertIsString($token); + + $headers = $this->decodeJWT($token, 'header'); + $this->assertSame([ + 'typ' => 'JWT', + 'alg' => 'RS256', + ], $headers); + } + + private function decodeJWT(string $token, $part): array + { + $map = [ + 'header' => 0, + 'payload' => 1, + ]; + $index = $map[$part]; + + return json_decode( + base64_decode( + str_replace( + '_', + '/', + str_replace( + '-', + '+', + explode('.', $token)[$index] + ) + ), + true + ), + true + ); + } + + public function testParseCanDecodeTokenSignedByOldKey(): void + { + $config = config(AuthJWT::class); + $config->keys['default'] = [ + [ + 'kid' => 'Key01', + 'alg' => 'HS256', // algorithm. + 'secret' => 'Key01_Secret', + ], + ]; + + // Generate token with Key01. + $manager = $this->createJWTManager(); + $payload = [ + 'user_id' => '1', + ]; + $token = $manager->issue($payload, DAY, 'default'); + + // Add new Key02. + $config->keys['default'] = [ + [ + 'kid' => 'Key02', + 'alg' => 'HS256', // algorithm. + 'secret' => 'Key02_Secret', + ], + [ + 'kid' => 'Key01', + 'alg' => 'HS256', // algorithm. + 'secret' => 'Key01_Secret', + ], + ]; + + $payload = $manager->parse($token); + + $this->assertSame('1', $payload->user_id); + } + + public function testParseCanSpecifyKey(): void + { + $config = config(AuthJWT::class); + $config->keys['mobile'] = [ + [ + 'kid' => 'Key01', + 'alg' => 'HS256', // algorithm. + 'secret' => 'Key01_Secret', + ], + ]; + + // Generate token with the mobile key. + $manager = $this->createJWTManager(); + $payload = [ + 'user_id' => '1', + ]; + $token = $manager->issue($payload, DAY, 'mobile'); + + $payload = $manager->parse($token, 'mobile'); + + $this->assertSame('1', $payload->user_id); + } + + public function testParseCanDecodeWithAsymmetricKey(): void + { + $token = $this->generateJWTWithAsymmetricKey(); + + $manager = $this->createJWTManager(); + $payload = $manager->parse($token); + + $this->assertSame('1', $payload->user_id); + } + + private function generateJWTWithAsymmetricKey(): string + { + $manager = $this->createJWTManager(); + + $config = config(AuthJWT::class); + $config->keys['default'][0] = [ + 'alg' => 'RS256', // algorithm. + 'public' => <<<'EOD' + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzWHNM5f+amCjQztc5QT + fJfzCC5J4nuW+L/aOxZ4f8J3FrewM2c/dufrnmedsApb0By7WhaHlcqCh/ScAPyJ + hzkPYLae7bTVro3hok0zDITR8F6SJGL42JAEUk+ILkPI+DONM0+3vzk6Kvfe548t + u4czCuqU8BGVOlnp6IqBHhAswNMM78pos/2z0CjPM4tbeXqSTTbNkXRboxjU29vS + opcT51koWOgiTf3C7nJUoMWZHZI5HqnIhPAG9yv8HAgNk6CMk2CadVHDo4IxjxTz + TTqo1SCSH2pooJl9O8at6kkRYsrZWwsKlOFE2LUce7ObnXsYihStBUDoeBQlGG/B + wQIDAQAB + -----END PUBLIC KEY----- + EOD, + 'private' => <<<'EOD' + -----BEGIN RSA PRIVATE KEY----- + MIIEowIBAAKCAQEAuzWHNM5f+amCjQztc5QTfJfzCC5J4nuW+L/aOxZ4f8J3Frew + M2c/dufrnmedsApb0By7WhaHlcqCh/ScAPyJhzkPYLae7bTVro3hok0zDITR8F6S + JGL42JAEUk+ILkPI+DONM0+3vzk6Kvfe548tu4czCuqU8BGVOlnp6IqBHhAswNMM + 78pos/2z0CjPM4tbeXqSTTbNkXRboxjU29vSopcT51koWOgiTf3C7nJUoMWZHZI5 + HqnIhPAG9yv8HAgNk6CMk2CadVHDo4IxjxTzTTqo1SCSH2pooJl9O8at6kkRYsrZ + WwsKlOFE2LUce7ObnXsYihStBUDoeBQlGG/BwQIDAQABAoIBAFtGaOqNKGwggn9k + 6yzr6GhZ6Wt2rh1Xpq8XUz514UBhPxD7dFRLpbzCrLVpzY80LbmVGJ9+1pJozyWc + VKeCeUdNwbqkr240Oe7GTFmGjDoxU+5/HX/SJYPpC8JZ9oqgEA87iz+WQX9hVoP2 + oF6EB4ckDvXmk8FMwVZW2l2/kd5mrEVbDaXKxhvUDf52iVD+sGIlTif7mBgR99/b + c3qiCnxCMmfYUnT2eh7Vv2LhCR/G9S6C3R4lA71rEyiU3KgsGfg0d82/XWXbegJW + h3QbWNtQLxTuIvLq5aAryV3PfaHlPgdgK0ft6ocU2de2FagFka3nfVEyC7IUsNTK + bq6nhAECgYEA7d/0DPOIaItl/8BWKyCuAHMss47j0wlGbBSHdJIiS55akMvnAG0M + 39y22Qqfzh1at9kBFeYeFIIU82ZLF3xOcE3z6pJZ4Dyvx4BYdXH77odo9uVK9s1l + 3T3BlMcqd1hvZLMS7dviyH79jZo4CXSHiKzc7pQ2YfK5eKxKqONeXuECgYEAyXlG + vonaus/YTb1IBei9HwaccnQ/1HRn6MvfDjb7JJDIBhNClGPt6xRlzBbSZ73c2QEC + 6Fu9h36K/HZ2qcLd2bXiNyhIV7b6tVKk+0Psoj0dL9EbhsD1OsmE1nTPyAc9XZbb + OPYxy+dpBCUA8/1U9+uiFoCa7mIbWcSQ+39gHuECgYAz82pQfct30aH4JiBrkNqP + nJfRq05UY70uk5k1u0ikLTRoVS/hJu/d4E1Kv4hBMqYCavFSwAwnvHUo51lVCr/y + xQOVYlsgnwBg2MX4+GjmIkqpSVCC8D7j/73MaWb746OIYZervQ8dbKahi2HbpsiG + 8AHcVSA/agxZr38qvWV54QKBgCD5TlDE8x18AuTGQ9FjxAAd7uD0kbXNz2vUYg9L + hFL5tyL3aAAtUrUUw4xhd9IuysRhW/53dU+FsG2dXdJu6CxHjlyEpUJl2iZu/j15 + YnMzGWHIEX8+eWRDsw/+Ujtko/B7TinGcWPz3cYl4EAOiCeDUyXnqnO1btCEUU44 + DJ1BAoGBAJuPD27ErTSVtId90+M4zFPNibFP50KprVdc8CR37BE7r8vuGgNYXmnI + RLnGP9p3pVgFCktORuYS2J/6t84I3+A17nEoB4xvhTLeAinAW/uTQOUmNicOP4Ek + 2MsLL2kHgL8bLTmvXV4FX+PXphrDKg1XxzOYn0otuoqdAQrkK4og + -----END RSA PRIVATE KEY----- + EOD, + ]; + + $payload = [ + 'user_id' => '1', + ]; + + return $manager->issue($payload, DAY); + } +}