From 07a6d7655fd77b9c33360b855a0c08d922b2f3ed Mon Sep 17 00:00:00 2001 From: Jasper Weyne Date: Wed, 1 Jul 2020 23:27:50 +0200 Subject: [PATCH 01/50] First basic OpenID Connect implementation --- .env.example.complete | 8 + app/Auth/Access/Guards/OpenIdSessionGuard.php | 39 +++ app/Auth/Access/OpenIdService.php | 235 ++++++++++++++++++ app/Config/auth.php | 4 + app/Config/openid.php | 43 ++++ app/Exceptions/OpenIdException.php | 6 + app/Http/Controllers/Auth/LoginController.php | 3 +- .../Controllers/Auth/OpenIdController.php | 70 ++++++ app/Http/Controllers/UserController.php | 4 +- app/Http/Middleware/VerifyCsrfToken.php | 3 +- app/Providers/AuthServiceProvider.php | 11 + composer.json | 3 +- composer.lock | 177 ++++++++++++- resources/lang/en/errors.php | 4 + .../views/auth/forms/login/openid.blade.php | 11 + resources/views/common/header.blade.php | 2 + resources/views/settings/index.blade.php | 2 +- resources/views/settings/roles/form.blade.php | 2 +- resources/views/users/form.blade.php | 2 +- routes/web.php | 5 + tests/Auth/AuthTest.php | 2 + 21 files changed, 626 insertions(+), 10 deletions(-) create mode 100644 app/Auth/Access/Guards/OpenIdSessionGuard.php create mode 100644 app/Auth/Access/OpenIdService.php create mode 100644 app/Config/openid.php create mode 100644 app/Exceptions/OpenIdException.php create mode 100644 app/Http/Controllers/Auth/OpenIdController.php create mode 100644 resources/views/auth/forms/login/openid.blade.php diff --git a/.env.example.complete b/.env.example.complete index 472ca051b..b211ad939 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -228,6 +228,14 @@ SAML2_USER_TO_GROUPS=false SAML2_GROUP_ATTRIBUTE=group SAML2_REMOVE_FROM_GROUPS=false +# OpenID Connect authentication configuration +OPENID_CLIENT_ID=null +OPENID_CLIENT_SECRET=null +OPENID_ISSUER=https://example.com +OPENID_PUBLIC_KEY=file:///my/public.key +OPENID_URL_AUTHORIZE=https://example.com/authorize +OPENID_URL_TOKEN=https://example.com/token + # Disable default third-party services such as Gravatar and Draw.IO # Service-specific options will override this option DISABLE_EXTERNAL_SERVICES=false diff --git a/app/Auth/Access/Guards/OpenIdSessionGuard.php b/app/Auth/Access/Guards/OpenIdSessionGuard.php new file mode 100644 index 000000000..0dfbb67a3 --- /dev/null +++ b/app/Auth/Access/Guards/OpenIdSessionGuard.php @@ -0,0 +1,39 @@ +config = config('openid'); + $this->registrationService = $registrationService; + $this->user = $user; + } + + /** + * Initiate a authorization flow. + * @throws Error + */ + public function login(): array + { + $provider = $this->getProvider(); + return [ + 'url' => $provider->getAuthorizationUrl(), + 'state' => $provider->getState(), + ]; + } + + /** + * Initiate a logout flow. + * @throws Error + */ + public function logout(): array + { + $this->actionLogout(); + $url = '/'; + $id = null; + + return ['url' => $url, 'id' => $id]; + } + + /** + * Process the Authorization response from the authorization server and + * return the matching, or new if registration active, user matched to + * the authorization server. + * Returns null if not authenticated. + * @throws Error + * @throws OpenIdException + * @throws ValidationError + * @throws JsonDebugException + * @throws UserRegistrationException + */ + public function processAuthorizeResponse(?string $authorizationCode): ?User + { + $provider = $this->getProvider(); + + // Try to exchange authorization code for access token + $accessToken = $provider->getAccessToken('authorization_code', [ + 'code' => $authorizationCode, + ]); + + return $this->processAccessTokenCallback($accessToken); + } + + /** + * Do the required actions to log a user out. + */ + protected function actionLogout() + { + auth()->logout(); + session()->invalidate(); + } + + /** + * Load the underlying Onelogin SAML2 toolkit. + * @throws Error + * @throws Exception + */ + protected function getProvider(): OpenIDConnectProvider + { + $settings = $this->config['openid']; + $overrides = $this->config['openid_overrides'] ?? []; + + if ($overrides && is_string($overrides)) { + $overrides = json_decode($overrides, true); + } + + $openIdSettings = $this->loadOpenIdDetails(); + $settings = array_replace_recursive($settings, $openIdSettings, $overrides); + + $signer = new \Lcobucci\JWT\Signer\Rsa\Sha256(); + return new OpenIDConnectProvider($settings, ['signer' => $signer]); + } + + /** + * Load dynamic service provider options required by the onelogin toolkit. + */ + protected function loadOpenIdDetails(): array + { + return [ + 'redirectUri' => url('/openid/redirect'), + ]; + } + + /** + * Calculate the display name + */ + protected function getUserDisplayName(Token $token, string $defaultValue): string + { + $displayNameAttr = $this->config['display_name_attributes']; + + $displayName = []; + foreach ($displayNameAttr as $dnAttr) { + $dnComponent = $token->getClaim($dnAttr, ''); + if ($dnComponent !== '') { + $displayName[] = $dnComponent; + } + } + + if (count($displayName) == 0) { + $displayName = $defaultValue; + } else { + $displayName = implode(' ', $displayName); + } + + return $displayName; + } + + /** + * Get the value to use as the external id saved in BookStack + * used to link the user to an existing BookStack DB user. + */ + protected function getExternalId(Token $token, string $defaultValue) + { + $userNameAttr = $this->config['external_id_attribute']; + if ($userNameAttr === null) { + return $defaultValue; + } + + return $token->getClaim($userNameAttr, $defaultValue); + } + + /** + * Extract the details of a user from a SAML response. + */ + protected function getUserDetails(Token $token): array + { + $email = null; + $emailAttr = $this->config['email_attribute']; + if ($token->hasClaim($emailAttr)) { + $email = $token->getClaim($emailAttr); + } + + return [ + 'external_id' => $token->getClaim('sub'), + 'email' => $email, + 'name' => $this->getUserDisplayName($token, $email), + ]; + } + + /** + * Get the user from the database for the specified details. + * @throws OpenIdException + * @throws UserRegistrationException + */ + protected function getOrRegisterUser(array $userDetails): ?User + { + $user = $this->user->newQuery() + ->where('external_auth_id', '=', $userDetails['external_id']) + ->first(); + + if (is_null($user)) { + $userData = [ + 'name' => $userDetails['name'], + 'email' => $userDetails['email'], + 'password' => Str::random(32), + 'external_auth_id' => $userDetails['external_id'], + ]; + + $user = $this->registrationService->registerUser($userData, null, false); + } + + return $user; + } + + /** + * Processes a received access token for a user. Login the user when + * they exist, optionally registering them automatically. + * @throws OpenIdException + * @throws JsonDebugException + * @throws UserRegistrationException + */ + public function processAccessTokenCallback(AccessToken $accessToken): User + { + $userDetails = $this->getUserDetails($accessToken->getIdToken()); + $isLoggedIn = auth()->check(); + + if ($this->config['dump_user_details']) { + throw new JsonDebugException($accessToken->jsonSerialize()); + } + + if ($userDetails['email'] === null) { + throw new OpenIdException(trans('errors.openid_no_email_address')); + } + + if ($isLoggedIn) { + throw new OpenIdException(trans('errors.openid_already_logged_in'), '/login'); + } + + $user = $this->getOrRegisterUser($userDetails); + if ($user === null) { + throw new OpenIdException(trans('errors.openid_user_not_registered', ['name' => $userDetails['external_id']]), '/login'); + } + + auth()->login($user); + return $user; + } +} diff --git a/app/Config/auth.php b/app/Config/auth.php index 51b152ff1..a1824bc78 100644 --- a/app/Config/auth.php +++ b/app/Config/auth.php @@ -40,6 +40,10 @@ return [ 'driver' => 'saml2-session', 'provider' => 'external', ], + 'openid' => [ + 'driver' => 'openid-session', + 'provider' => 'external', + ], 'api' => [ 'driver' => 'api-token', ], diff --git a/app/Config/openid.php b/app/Config/openid.php new file mode 100644 index 000000000..2232ba7b2 --- /dev/null +++ b/app/Config/openid.php @@ -0,0 +1,43 @@ + env('OPENID_NAME', 'SSO'), + + // Dump user details after a login request for debugging purposes + 'dump_user_details' => env('OPENID_DUMP_USER_DETAILS', false), + + // Attribute, within a OpenId token, to find the user's email address + 'email_attribute' => env('OPENID_EMAIL_ATTRIBUTE', 'email'), + // Attribute, within a OpenId token, to find the user's display name + 'display_name_attributes' => explode('|', env('OPENID_DISPLAY_NAME_ATTRIBUTES', 'username')), + // Attribute, within a OpenId token, to use to connect a BookStack user to the OpenId user. + 'external_id_attribute' => env('OPENID_EXTERNAL_ID_ATTRIBUTE', null), + + // Overrides, in JSON format, to the configuration passed to underlying OpenIDConnectProvider library. + 'openid_overrides' => env('OPENID_OVERRIDES', null), + + 'openid' => [ + // OAuth2/OpenId client id, as configured in your Authorization server. + 'clientId' => env('OPENID_CLIENT_ID', ''), + + // OAuth2/OpenId client secret, as configured in your Authorization server. + 'clientSecret' => env('OPENID_CLIENT_SECRET', ''), + + // OAuth2 scopes that are request, by default the OpenId-native profile and email scopes. + 'scopes' => 'profile email', + + // The issuer of the identity token (id_token) this will be compared with what is returned in the token. + 'idTokenIssuer' => env('OPENID_ISSUER', ''), + + // Public key that's used to verify the JWT token with. + 'publicKey' => env('OPENID_PUBLIC_KEY', ''), + + // OAuth2 endpoints. + 'urlAuthorize' => env('OPENID_URL_AUTHORIZE', ''), + 'urlAccessToken' => env('OPENID_URL_TOKEN', ''), + 'urlResourceOwnerDetails' => env('OPENID_URL_RESOURCE', ''), + ], + +]; diff --git a/app/Exceptions/OpenIdException.php b/app/Exceptions/OpenIdException.php new file mode 100644 index 000000000..056f95c56 --- /dev/null +++ b/app/Exceptions/OpenIdException.php @@ -0,0 +1,6 @@ +can('users-manage') && $user->can('user-roles-manage')) { - $guards = ['standard', 'ldap', 'saml2']; + $guards = ['standard', 'ldap', 'saml2', 'openid']; foreach ($guards as $guard) { auth($guard)->login($user); } @@ -186,5 +186,4 @@ class LoginController extends Controller return redirect('/login'); } - } diff --git a/app/Http/Controllers/Auth/OpenIdController.php b/app/Http/Controllers/Auth/OpenIdController.php new file mode 100644 index 000000000..8e475ffdb --- /dev/null +++ b/app/Http/Controllers/Auth/OpenIdController.php @@ -0,0 +1,70 @@ +openidService = $openidService; + $this->middleware('guard:openid'); + } + + /** + * Start the authorization login flow via OpenId Connect. + */ + public function login() + { + $loginDetails = $this->openidService->login(); + session()->flash('openid_state', $loginDetails['state']); + + return redirect($loginDetails['url']); + } + + /** + * Start the logout flow via OpenId Connect. + */ + public function logout() + { + $logoutDetails = $this->openidService->logout(); + + if ($logoutDetails['id']) { + session()->flash('saml2_logout_request_id', $logoutDetails['id']); + } + + return redirect($logoutDetails['url']); + } + + /** + * Authorization flow Redirect. + * Processes authorization response from the OpenId Connect Authorization Server. + */ + public function redirect() + { + $storedState = session()->pull('openid_state'); + $responseState = request()->query('state'); + + if ($storedState !== $responseState) { + $this->showErrorNotification(trans('errors.openid_fail_authed', ['system' => config('saml2.name')])); + return redirect('/login'); + } + + $user = $this->openidService->processAuthorizeResponse(request()->query('code')); + if ($user === null) { + $this->showErrorNotification(trans('errors.openid_fail_authed', ['system' => config('saml2.name')])); + return redirect('/login'); + } + + return redirect()->intended(); + } +} diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 97ec31dcc..5db3c59bd 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -76,7 +76,7 @@ class UserController extends Controller if ($authMethod === 'standard' && !$sendInvite) { $validationRules['password'] = 'required|min:6'; $validationRules['password-confirm'] = 'required|same:password'; - } elseif ($authMethod === 'ldap' || $authMethod === 'saml2') { + } elseif ($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') { $validationRules['external_auth_id'] = 'required'; } $this->validate($request, $validationRules); @@ -85,7 +85,7 @@ class UserController extends Controller if ($authMethod === 'standard') { $user->password = bcrypt($request->get('password', Str::random(32))); - } elseif ($authMethod === 'ldap' || $authMethod === 'saml2') { + } elseif ($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') { $user->external_auth_id = $request->get('external_auth_id'); } diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index bdeb26554..007564eb3 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -19,6 +19,7 @@ class VerifyCsrfToken extends Middleware * @var array */ protected $except = [ - 'saml2/*' + 'saml2/*', + 'openid/*' ]; } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index fe52df168..1b3f169ae 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -7,6 +7,7 @@ use BookStack\Api\ApiTokenGuard; use BookStack\Auth\Access\ExternalBaseUserProvider; use BookStack\Auth\Access\Guards\LdapSessionGuard; use BookStack\Auth\Access\Guards\Saml2SessionGuard; +use BookStack\Auth\Access\Guards\OpenIdSessionGuard; use BookStack\Auth\Access\LdapService; use BookStack\Auth\Access\RegistrationService; use BookStack\Auth\UserRepo; @@ -45,6 +46,16 @@ class AuthServiceProvider extends ServiceProvider $app[RegistrationService::class] ); }); + + Auth::extend('openid-session', function ($app, $name, array $config) { + $provider = Auth::createUserProvider($config['provider']); + return new OpenIdSessionGuard( + $name, + $provider, + $this->app['session.store'], + $app[RegistrationService::class] + ); + }); } /** diff --git a/composer.json b/composer.json index 59fc909d6..7b1a3d592 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,8 @@ "socialiteproviders/microsoft-azure": "^3.0", "socialiteproviders/okta": "^1.0", "socialiteproviders/slack": "^3.0", - "socialiteproviders/twitch": "^5.0" + "socialiteproviders/twitch": "^5.0", + "steverhoades/oauth2-openid-connect-client": "^0.3.0" }, "require-dev": { "barryvdh/laravel-debugbar": "^3.2.8", diff --git a/composer.lock b/composer.lock index a8c3b1e50..0f5e29792 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "34390536dd685e0bc49b179babaa06ec", + "content-hash": "f9d604c2456771f9b939f04492dde182", "packages": [ { "name": "aws/aws-sdk-php", @@ -1814,6 +1814,71 @@ ], "time": "2020-02-04T15:30:01+00:00" }, + { + "name": "lcobucci/jwt", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "56f10808089e38623345e28af2f2d5e4eb579455" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/56f10808089e38623345e28af2f2d5e4eb579455", + "reference": "56f10808089e38623345e28af2f2d5e4eb579455", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-openssl": "*", + "php": "^5.6 || ^7.0" + }, + "require-dev": { + "mikey179/vfsstream": "~1.5", + "phpmd/phpmd": "~2.2", + "phpunit/php-invoker": "~1.1", + "phpunit/phpunit": "^5.7 || ^7.3", + "squizlabs/php_codesniffer": "~2.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1-dev" + } + }, + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Otávio Cobucci Oblonczyk", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2020-05-22T08:21:12+00:00" + }, { "name": "league/commonmark", "version": "1.4.3", @@ -2114,6 +2179,73 @@ ], "time": "2016-08-17T00:36:58+00:00" }, + { + "name": "league/oauth2-client", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-client.git", + "reference": "cc114abc622a53af969e8664722e84ca36257530" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/cc114abc622a53af969e8664722e84ca36257530", + "reference": "cc114abc622a53af969e8664722e84ca36257530", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^6.0", + "paragonie/random_compat": "^1|^2|^9.99", + "php": "^5.6|^7.0" + }, + "require-dev": { + "eloquent/liberator": "^2.0", + "eloquent/phony-phpunit": "^1.0|^3.0", + "jakub-onderka/php-parallel-lint": "^0.9.2", + "phpunit/phpunit": "^5.7|^6.0", + "squizlabs/php_codesniffer": "^2.3|^3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\OAuth2\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Bilbie", + "email": "hello@alexbilbie.com", + "homepage": "http://www.alexbilbie.com", + "role": "Developer" + }, + { + "name": "Woody Gilk", + "homepage": "https://github.com/shadowhand", + "role": "Contributor" + } + ], + "description": "OAuth 2.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "identity", + "idp", + "oauth", + "oauth2", + "single sign on" + ], + "time": "2018-11-22T18:33:57+00:00" + }, { "name": "monolog/monolog", "version": "2.1.0", @@ -3502,6 +3634,49 @@ "description": "Twitch OAuth2 Provider for Laravel Socialite", "time": "2020-05-06T22:51:30+00:00" }, + { + "name": "steverhoades/oauth2-openid-connect-client", + "version": "v0.3.0", + "source": { + "type": "git", + "url": "https://github.com/steverhoades/oauth2-openid-connect-client.git", + "reference": "0159471487540a4620b8d0b693f5f215503a8d75" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/steverhoades/oauth2-openid-connect-client/zipball/0159471487540a4620b8d0b693f5f215503a8d75", + "reference": "0159471487540a4620b8d0b693f5f215503a8d75", + "shasum": "" + }, + "require": { + "lcobucci/jwt": "^3.2", + "league/oauth2-client": "^2.0" + }, + "require-dev": { + "phpmd/phpmd": "~2.2", + "phpunit/php-invoker": "~1.1", + "phpunit/phpunit": "~4.5", + "squizlabs/php_codesniffer": "~2.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "OpenIDConnectClient\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Steve Rhoades", + "email": "sedonami@gmail.com" + } + ], + "description": "OAuth2 OpenID Connect Client that utilizes the PHP Leagues OAuth2 Client", + "time": "2020-05-19T23:06:36+00:00" + }, { "name": "swiftmailer/swiftmailer", "version": "v6.2.3", diff --git a/resources/lang/en/errors.php b/resources/lang/en/errors.php index 06a5285f5..ec4ce813e 100644 --- a/resources/lang/en/errors.php +++ b/resources/lang/en/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', 'saml_invalid_response_id' => 'The request from the external authentication system is not recognised by a process started by this application. Navigating back after a login could cause this issue.', 'saml_fail_authed' => 'Login using :system failed, system did not provide successful authorization', + 'openid_already_logged_in' => 'Already logged in', + 'openid_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'openid_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'openid_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'No action defined', 'social_login_bad_response' => "Error received during :socialAccount login: \n:error", 'social_account_in_use' => 'This :socialAccount account is already in use, Try logging in via the :socialAccount option.', diff --git a/resources/views/auth/forms/login/openid.blade.php b/resources/views/auth/forms/login/openid.blade.php new file mode 100644 index 000000000..b1ef51d13 --- /dev/null +++ b/resources/views/auth/forms/login/openid.blade.php @@ -0,0 +1,11 @@ +
+ {!! csrf_field() !!} + +
+ +
+ +
\ No newline at end of file diff --git a/resources/views/common/header.blade.php b/resources/views/common/header.blade.php index 827abcac6..996d44d27 100644 --- a/resources/views/common/header.blade.php +++ b/resources/views/common/header.blade.php @@ -66,6 +66,8 @@
  • @if(config('auth.method') === 'saml2') @icon('logout'){{ trans('auth.logout') }} + @elseif(config('auth.method') === 'openid') + @icon('logout'){{ trans('auth.logout') }} @else @icon('logout'){{ trans('auth.logout') }} @endif diff --git a/resources/views/settings/index.blade.php b/resources/views/settings/index.blade.php index 6ccb8d8f9..500db64e6 100644 --- a/resources/views/settings/index.blade.php +++ b/resources/views/settings/index.blade.php @@ -224,7 +224,7 @@ 'label' => trans('settings.reg_enable_toggle') ]) - @if(in_array(config('auth.method'), ['ldap', 'saml2'])) + @if(in_array(config('auth.method'), ['ldap', 'saml2', 'openid']))
    {{ trans('settings.reg_enable_external_warning') }}
    @endif diff --git a/resources/views/settings/roles/form.blade.php b/resources/views/settings/roles/form.blade.php index ed57ad944..b5aa96911 100644 --- a/resources/views/settings/roles/form.blade.php +++ b/resources/views/settings/roles/form.blade.php @@ -19,7 +19,7 @@ @include('form.text', ['name' => 'description']) - @if(config('auth.method') === 'ldap' || config('auth.method') === 'saml2') + @if(config('auth.method') === 'ldap' || config('auth.method') === 'saml2' || config('auth.method') === 'openid')
    @include('form.text', ['name' => 'external_auth_id']) diff --git a/resources/views/users/form.blade.php b/resources/views/users/form.blade.php index df3d06c2f..f3e8cedff 100644 --- a/resources/views/users/form.blade.php +++ b/resources/views/users/form.blade.php @@ -25,7 +25,7 @@
    -@if(($authMethod === 'ldap' || $authMethod === 'saml2') && userCan('users-manage')) +@if(($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') && userCan('users-manage'))
    diff --git a/routes/web.php b/routes/web.php index 6b7911825..a47080e8e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -234,6 +234,11 @@ Route::get('/saml2/metadata', 'Auth\Saml2Controller@metadata'); Route::get('/saml2/sls', 'Auth\Saml2Controller@sls'); Route::post('/saml2/acs', 'Auth\Saml2Controller@acs'); +// OpenId routes +Route::post('/openid/login', 'Auth\OpenIdController@login'); +Route::get('/openid/logout', 'Auth\OpenIdController@logout'); +Route::get('/openid/redirect', 'Auth\OpenIdController@redirect'); + // User invitation routes Route::get('/register/invite/{token}', 'Auth\UserInviteController@showSetPassword'); Route::post('/register/invite/{token}', 'Auth\UserInviteController@setPassword'); diff --git a/tests/Auth/AuthTest.php b/tests/Auth/AuthTest.php index f1f476966..779d5e70f 100644 --- a/tests/Auth/AuthTest.php +++ b/tests/Auth/AuthTest.php @@ -387,6 +387,7 @@ class AuthTest extends BrowserKitTest $this->assertTrue(auth()->check()); $this->assertTrue(auth('ldap')->check()); $this->assertTrue(auth('saml2')->check()); + $this->assertTrue(auth('openid')->check()); } public function test_login_authenticates_nonadmins_on_default_guard_only() @@ -399,6 +400,7 @@ class AuthTest extends BrowserKitTest $this->assertTrue(auth()->check()); $this->assertFalse(auth('ldap')->check()); $this->assertFalse(auth('saml2')->check()); + $this->assertFalse(auth('openid')->check()); } /** From 25144a13c7150c75a023cb039972a3f784bee8cf Mon Sep 17 00:00:00 2001 From: Jasper Weyne Date: Mon, 6 Jul 2020 18:14:43 +0200 Subject: [PATCH 02/50] Deduplicated getOrRegisterUser method --- app/Auth/Access/ExternalAuthService.php | 37 +++++++++++++++++++++++++ app/Auth/Access/OpenIdService.php | 32 ++------------------- app/Auth/Access/Saml2Service.php | 32 ++------------------- 3 files changed, 41 insertions(+), 60 deletions(-) diff --git a/app/Auth/Access/ExternalAuthService.php b/app/Auth/Access/ExternalAuthService.php index db8bd2dfb..7f15307ae 100644 --- a/app/Auth/Access/ExternalAuthService.php +++ b/app/Auth/Access/ExternalAuthService.php @@ -3,9 +3,46 @@ use BookStack\Auth\Role; use BookStack\Auth\User; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Str; class ExternalAuthService { + protected $registrationService; + protected $user; + + /** + * ExternalAuthService base constructor. + */ + public function __construct(RegistrationService $registrationService, User $user) + { + $this->registrationService = $registrationService; + $this->user = $user; + } + + /** + * Get the user from the database for the specified details. + * @throws UserRegistrationException + */ + protected function getOrRegisterUser(array $userDetails): ?User + { + $user = $this->user->newQuery() + ->where('external_auth_id', '=', $userDetails['external_id']) + ->first(); + + if (is_null($user)) { + $userData = [ + 'name' => $userDetails['name'], + 'email' => $userDetails['email'], + 'password' => Str::random(32), + 'external_auth_id' => $userDetails['external_id'], + ]; + + $user = $this->registrationService->registerUser($userData, null, false); + } + + return $user; + } + /** * Check a role against an array of group names to see if it matches. * Checked against role 'external_auth_id' if set otherwise the name of the role. diff --git a/app/Auth/Access/OpenIdService.php b/app/Auth/Access/OpenIdService.php index 870299a57..084adfb13 100644 --- a/app/Auth/Access/OpenIdService.php +++ b/app/Auth/Access/OpenIdService.php @@ -5,7 +5,6 @@ use BookStack\Exceptions\JsonDebugException; use BookStack\Exceptions\OpenIdException; use BookStack\Exceptions\UserRegistrationException; use Exception; -use Illuminate\Support\Str; use Lcobucci\JWT\Token; use OpenIDConnectClient\AccessToken; use OpenIDConnectClient\OpenIDConnectProvider; @@ -17,17 +16,15 @@ use OpenIDConnectClient\OpenIDConnectProvider; class OpenIdService extends ExternalAuthService { protected $config; - protected $registrationService; - protected $user; /** * OpenIdService constructor. */ public function __construct(RegistrationService $registrationService, User $user) { + parent::__construct($registrationService, $user); + $this->config = config('openid'); - $this->registrationService = $registrationService; - $this->user = $user; } /** @@ -175,31 +172,6 @@ class OpenIdService extends ExternalAuthService ]; } - /** - * Get the user from the database for the specified details. - * @throws OpenIdException - * @throws UserRegistrationException - */ - protected function getOrRegisterUser(array $userDetails): ?User - { - $user = $this->user->newQuery() - ->where('external_auth_id', '=', $userDetails['external_id']) - ->first(); - - if (is_null($user)) { - $userData = [ - 'name' => $userDetails['name'], - 'email' => $userDetails['email'], - 'password' => Str::random(32), - 'external_auth_id' => $userDetails['external_id'], - ]; - - $user = $this->registrationService->registerUser($userData, null, false); - } - - return $user; - } - /** * Processes a received access token for a user. Login the user when * they exist, optionally registering them automatically. diff --git a/app/Auth/Access/Saml2Service.php b/app/Auth/Access/Saml2Service.php index 8f9a24cde..4c1fce864 100644 --- a/app/Auth/Access/Saml2Service.php +++ b/app/Auth/Access/Saml2Service.php @@ -5,7 +5,6 @@ use BookStack\Exceptions\JsonDebugException; use BookStack\Exceptions\SamlException; use BookStack\Exceptions\UserRegistrationException; use Exception; -use Illuminate\Support\Str; use OneLogin\Saml2\Auth; use OneLogin\Saml2\Error; use OneLogin\Saml2\IdPMetadataParser; @@ -18,17 +17,15 @@ use OneLogin\Saml2\ValidationError; class Saml2Service extends ExternalAuthService { protected $config; - protected $registrationService; - protected $user; /** * Saml2Service constructor. */ public function __construct(RegistrationService $registrationService, User $user) { + parent::__construct($registrationService, $user); + $this->config = config('saml2'); - $this->registrationService = $registrationService; - $this->user = $user; } /** @@ -309,31 +306,6 @@ class Saml2Service extends ExternalAuthService return $defaultValue; } - /** - * Get the user from the database for the specified details. - * @throws SamlException - * @throws UserRegistrationException - */ - protected function getOrRegisterUser(array $userDetails): ?User - { - $user = $this->user->newQuery() - ->where('external_auth_id', '=', $userDetails['external_id']) - ->first(); - - if (is_null($user)) { - $userData = [ - 'name' => $userDetails['name'], - 'email' => $userDetails['email'], - 'password' => Str::random(32), - 'external_auth_id' => $userDetails['external_id'], - ]; - - $user = $this->registrationService->registerUser($userData, null, false); - } - - return $user; - } - /** * Process the SAML response for a user. Login the user when * they exist, optionally registering them automatically. From 10c890947f9ea5661729f88e9e85464522498dd7 Mon Sep 17 00:00:00 2001 From: Jasper Weyne Date: Tue, 7 Jul 2020 02:26:00 +0200 Subject: [PATCH 03/50] Token expiration and refreshing using the refresh_token flow --- app/Auth/Access/Guards/OpenIdSessionGuard.php | 40 ++++++++++++++++ app/Auth/Access/OpenIdService.php | 46 ++++++++++++++++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/app/Auth/Access/Guards/OpenIdSessionGuard.php b/app/Auth/Access/Guards/OpenIdSessionGuard.php index 0dfbb67a3..634464493 100644 --- a/app/Auth/Access/Guards/OpenIdSessionGuard.php +++ b/app/Auth/Access/Guards/OpenIdSessionGuard.php @@ -2,6 +2,11 @@ namespace BookStack\Auth\Access\Guards; +use BookStack\Auth\Access\OpenIdService; +use BookStack\Auth\Access\RegistrationService; +use Illuminate\Contracts\Auth\UserProvider; +use Illuminate\Contracts\Session\Session; + /** * OpenId Session Guard * @@ -14,6 +19,41 @@ namespace BookStack\Auth\Access\Guards; */ class OpenIdSessionGuard extends ExternalBaseSessionGuard { + + protected $openidService; + + /** + * OpenIdSessionGuard constructor. + */ + public function __construct( + $name, + UserProvider $provider, + Session $session, + OpenIdService $openidService, + RegistrationService $registrationService + ) { + $this->openidService = $openidService; + parent::__construct($name, $provider, $session, $registrationService); + } + + /** + * Get the currently authenticated user. + * + * @return \Illuminate\Contracts\Auth\Authenticatable|null + */ + public function user() + { + // retrieve the current user + $user = parent::user(); + + // refresh the current user + if ($user && !$this->openidService->refresh()) { + $this->user = null; + } + + return $this->user; + } + /** * Validate a user's credentials. * diff --git a/app/Auth/Access/OpenIdService.php b/app/Auth/Access/OpenIdService.php index 084adfb13..377925d61 100644 --- a/app/Auth/Access/OpenIdService.php +++ b/app/Auth/Access/OpenIdService.php @@ -6,6 +6,7 @@ use BookStack\Exceptions\OpenIdException; use BookStack\Exceptions\UserRegistrationException; use Exception; use Lcobucci\JWT\Token; +use League\OAuth2\Client\Provider\Exception\IdentityProviderException; use OpenIDConnectClient\AccessToken; use OpenIDConnectClient\OpenIDConnectProvider; @@ -53,6 +54,46 @@ class OpenIdService extends ExternalAuthService return ['url' => $url, 'id' => $id]; } + /** + * Refresh the currently logged in user. + * @throws Error + */ + public function refresh(): bool + { + // Retrieve access token for current session + $json = session()->get('openid_token'); + $accessToken = new AccessToken(json_decode($json, true)); + + // Check whether the access token or ID token is expired + if (!$accessToken->getIdToken()->isExpired() && !$accessToken->hasExpired()) { + return true; + } + + // If no refresh token available, logout + if ($accessToken->getRefreshToken() === null) { + $this->actionLogout(); + return false; + } + + // ID token or access token is expired, we refresh it using the refresh token + try { + $provider = $this->getProvider(); + + $accessToken = $provider->getAccessToken('refresh_token', [ + 'refresh_token' => $accessToken->getRefreshToken(), + ]); + } catch (IdentityProviderException $e) { + // Refreshing failed, logout + $this->actionLogout(); + return false; + } + + // A valid token was obtained, we update the access token + session()->put('openid_token', json_encode($accessToken)); + + return true; + } + /** * Process the Authorization response from the authorization server and * return the matching, or new if registration active, user matched to @@ -86,7 +127,7 @@ class OpenIdService extends ExternalAuthService } /** - * Load the underlying Onelogin SAML2 toolkit. + * Load the underlying OpenID Connect Provider. * @throws Error * @throws Exception */ @@ -155,7 +196,7 @@ class OpenIdService extends ExternalAuthService } /** - * Extract the details of a user from a SAML response. + * Extract the details of a user from an ID token. */ protected function getUserDetails(Token $token): array { @@ -202,6 +243,7 @@ class OpenIdService extends ExternalAuthService } auth()->login($user); + session()->put('openid_token', json_encode($accessToken)); return $user; } } From 5df7db510524a156a0a1f0d659a06a02dd5d3644 Mon Sep 17 00:00:00 2001 From: Jasper Weyne Date: Tue, 7 Jul 2020 02:51:33 +0200 Subject: [PATCH 04/50] Ignore ID token expiry if unavailable --- app/Auth/Access/OpenIdService.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/Auth/Access/OpenIdService.php b/app/Auth/Access/OpenIdService.php index 377925d61..7b651c3de 100644 --- a/app/Auth/Access/OpenIdService.php +++ b/app/Auth/Access/OpenIdService.php @@ -8,6 +8,7 @@ use Exception; use Lcobucci\JWT\Token; use League\OAuth2\Client\Provider\Exception\IdentityProviderException; use OpenIDConnectClient\AccessToken; +use OpenIDConnectClient\Exception\InvalidTokenException; use OpenIDConnectClient\OpenIDConnectProvider; /** @@ -64,8 +65,9 @@ class OpenIdService extends ExternalAuthService $json = session()->get('openid_token'); $accessToken = new AccessToken(json_decode($json, true)); - // Check whether the access token or ID token is expired - if (!$accessToken->getIdToken()->isExpired() && !$accessToken->hasExpired()) { + // Check if both the access token and the ID token (if present) are unexpired + $idToken = $accessToken->getIdToken(); + if (!$accessToken->hasExpired() && (!$idToken || !$idToken->isExpired())) { return true; } @@ -86,6 +88,9 @@ class OpenIdService extends ExternalAuthService // Refreshing failed, logout $this->actionLogout(); return false; + } catch (InvalidTokenException $e) { + // A refresh token doesn't necessarily contain + // an ID token, ignore this exception } // A valid token was obtained, we update the access token From 97cde9c56a3268da179c2701d209a9a1224bac85 Mon Sep 17 00:00:00 2001 From: Jasper Weyne Date: Wed, 8 Jul 2020 17:02:52 +0200 Subject: [PATCH 05/50] Generalize refresh failure handling --- app/Auth/Access/OpenIdService.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/Auth/Access/OpenIdService.php b/app/Auth/Access/OpenIdService.php index 7b651c3de..14b6ac9a5 100644 --- a/app/Auth/Access/OpenIdService.php +++ b/app/Auth/Access/OpenIdService.php @@ -88,9 +88,10 @@ class OpenIdService extends ExternalAuthService // Refreshing failed, logout $this->actionLogout(); return false; - } catch (InvalidTokenException $e) { - // A refresh token doesn't necessarily contain - // an ID token, ignore this exception + } catch (\Exception $e) { + // Unknown error, logout and throw + $this->actionLogout(); + throw $e; } // A valid token was obtained, we update the access token From 13d0260cc97c5cce9399f44afa65b70857499da6 Mon Sep 17 00:00:00 2001 From: Jasper Weyne Date: Thu, 9 Jul 2020 16:27:45 +0200 Subject: [PATCH 06/50] Configurable OpenID Connect services --- app/Auth/Access/OpenIdService.php | 22 +++++++++++++++++++--- app/Config/openid.php | 3 +++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/app/Auth/Access/OpenIdService.php b/app/Auth/Access/OpenIdService.php index 14b6ac9a5..fc0c00298 100644 --- a/app/Auth/Access/OpenIdService.php +++ b/app/Auth/Access/OpenIdService.php @@ -139,6 +139,7 @@ class OpenIdService extends ExternalAuthService */ protected function getProvider(): OpenIDConnectProvider { + // Setup settings $settings = $this->config['openid']; $overrides = $this->config['openid_overrides'] ?? []; @@ -149,12 +150,27 @@ class OpenIdService extends ExternalAuthService $openIdSettings = $this->loadOpenIdDetails(); $settings = array_replace_recursive($settings, $openIdSettings, $overrides); - $signer = new \Lcobucci\JWT\Signer\Rsa\Sha256(); - return new OpenIDConnectProvider($settings, ['signer' => $signer]); + // Setup services + $services = $this->loadOpenIdServices(); + $overrides = $this->config['openid_services'] ?? []; + + $services = array_replace_recursive($services, $overrides); + + return new OpenIDConnectProvider($settings, $services); } /** - * Load dynamic service provider options required by the onelogin toolkit. + * Load services utilized by the OpenID Connect provider. + */ + protected function loadOpenIdServices(): array + { + return [ + 'signer' => new \Lcobucci\JWT\Signer\Rsa\Sha256(), + ]; + } + + /** + * Load dynamic service provider options required by the OpenID Connect provider. */ protected function loadOpenIdDetails(): array { diff --git a/app/Config/openid.php b/app/Config/openid.php index 2232ba7b2..20089518b 100644 --- a/app/Config/openid.php +++ b/app/Config/openid.php @@ -18,6 +18,9 @@ return [ // Overrides, in JSON format, to the configuration passed to underlying OpenIDConnectProvider library. 'openid_overrides' => env('OPENID_OVERRIDES', null), + // Custom service instances, used by the underlying OpenIDConnectProvider library + 'openid_services' => [], + 'openid' => [ // OAuth2/OpenId client id, as configured in your Authorization server. 'clientId' => env('OPENID_CLIENT_ID', ''), From 75b4a05200ebf6b107b4448915f811f247bcba69 Mon Sep 17 00:00:00 2001 From: Jasper Weyne Date: Thu, 9 Jul 2020 18:00:16 +0200 Subject: [PATCH 07/50] Add OpenIdService to OpenIdSessionGuard constructor call --- app/Providers/AuthServiceProvider.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 1b3f169ae..653a29248 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -9,6 +9,7 @@ use BookStack\Auth\Access\Guards\LdapSessionGuard; use BookStack\Auth\Access\Guards\Saml2SessionGuard; use BookStack\Auth\Access\Guards\OpenIdSessionGuard; use BookStack\Auth\Access\LdapService; +use BookStack\Auth\Access\OpenIdService; use BookStack\Auth\Access\RegistrationService; use BookStack\Auth\UserRepo; use Illuminate\Support\ServiceProvider; @@ -53,6 +54,7 @@ class AuthServiceProvider extends ServiceProvider $name, $provider, $this->app['session.store'], + $app[OpenIdService::class], $app[RegistrationService::class] ); }); From 46388a591b7cff9364dff2502419ffdafab0137c Mon Sep 17 00:00:00 2001 From: Jasper Weyne Date: Thu, 9 Jul 2020 18:29:44 +0200 Subject: [PATCH 08/50] AccessToken empty array parameter on null --- app/Auth/Access/OpenIdService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Auth/Access/OpenIdService.php b/app/Auth/Access/OpenIdService.php index fc0c00298..3d8818fa5 100644 --- a/app/Auth/Access/OpenIdService.php +++ b/app/Auth/Access/OpenIdService.php @@ -63,7 +63,7 @@ class OpenIdService extends ExternalAuthService { // Retrieve access token for current session $json = session()->get('openid_token'); - $accessToken = new AccessToken(json_decode($json, true)); + $accessToken = new AccessToken(json_decode($json, true) ?? []); // Check if both the access token and the ID token (if present) are unexpired $idToken = $accessToken->getIdToken(); From 6feaf25c902d8cf1315ca0612e3f54387dbb55f4 Mon Sep 17 00:00:00 2001 From: Jasper Weyne Date: Tue, 4 Aug 2020 21:29:11 +0200 Subject: [PATCH 09/50] Increase robustness of the refresh method --- app/Auth/Access/OpenIdService.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/Auth/Access/OpenIdService.php b/app/Auth/Access/OpenIdService.php index 3d8818fa5..2b536d492 100644 --- a/app/Auth/Access/OpenIdService.php +++ b/app/Auth/Access/OpenIdService.php @@ -8,7 +8,6 @@ use Exception; use Lcobucci\JWT\Token; use League\OAuth2\Client\Provider\Exception\IdentityProviderException; use OpenIDConnectClient\AccessToken; -use OpenIDConnectClient\Exception\InvalidTokenException; use OpenIDConnectClient\OpenIDConnectProvider; /** @@ -63,11 +62,20 @@ class OpenIdService extends ExternalAuthService { // Retrieve access token for current session $json = session()->get('openid_token'); + + // If no access token was found, reject the refresh + if (!$json) { + $this->actionLogout(); + return false; + } + $accessToken = new AccessToken(json_decode($json, true) ?? []); // Check if both the access token and the ID token (if present) are unexpired $idToken = $accessToken->getIdToken(); - if (!$accessToken->hasExpired() && (!$idToken || !$idToken->isExpired())) { + $accessTokenUnexpired = $accessToken->getExpires() && !$accessToken->hasExpired(); + $idTokenUnexpired = !$idToken || !$idToken->isExpired(); + if ($accessTokenUnexpired && $idTokenUnexpired) { return true; } From 23402ae81287bdfd0539d20a3a81c38d9efce1e5 Mon Sep 17 00:00:00 2001 From: Jasper Weyne Date: Tue, 4 Aug 2020 21:30:17 +0200 Subject: [PATCH 10/50] Initial unit tests for OpenID --- tests/Auth/OpenIdTest.php | 112 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 tests/Auth/OpenIdTest.php diff --git a/tests/Auth/OpenIdTest.php b/tests/Auth/OpenIdTest.php new file mode 100644 index 000000000..9ad90fb5b --- /dev/null +++ b/tests/Auth/OpenIdTest.php @@ -0,0 +1,112 @@ +set([ + 'auth.method' => 'openid', + 'auth.defaults.guard' => 'openid', + 'openid.name' => 'SingleSignOn-Testing', + 'openid.email_attribute' => 'email', + 'openid.display_name_attributes' => ['given_name', 'family_name'], + 'openid.external_id_attribute' => 'uid', + 'openid.openid_overrides' => null, + 'openid.openid.clientId' => 'testapp', + 'openid.openid.clientSecret' => 'testpass', + 'openid.openid.publicKey' => $this->testCert, + 'openid.openid.idTokenIssuer' => 'https://openid.local', + 'openid.openid.urlAuthorize' => 'https://openid.local/auth', + 'openid.openid.urlAccessToken' => 'https://openid.local/token', + ]); + } + + public function test_openid_overrides_functions_as_expected() + { + $json = '{"urlAuthorize": "https://openid.local/custom"}'; + config()->set(['openid.openid_overrides' => $json]); + + $req = $this->get('/openid/login'); + $redirect = $req->headers->get('location'); + $this->assertStringStartsWith('https://openid.local/custom', $redirect, 'Login redirects to SSO location'); + } + + public function test_login_option_shows_on_login_page() + { + $req = $this->get('/login'); + $req->assertSeeText('SingleSignOn-Testing'); + $req->assertElementExists('form[action$="/openid/login"][method=POST] button'); + } + + public function test_login() + { + $req = $this->post('/openid/login'); + $redirect = $req->headers->get('location'); + + $this->assertStringStartsWith('https://openid.local/auth', $redirect, 'Login redirects to SSO location'); + $this->assertFalse($this->isAuthenticated()); + } + + public function test_openid_routes_are_only_active_if_openid_enabled() + { + config()->set(['auth.method' => 'standard']); + $getRoutes = ['/logout', '/metadata', '/sls']; + foreach ($getRoutes as $route) { + $req = $this->get('/openid' . $route); + $this->assertPermissionError($req); + } + + $postRoutes = ['/login', '/acs']; + foreach ($postRoutes as $route) { + $req = $this->post('/openid' . $route); + $this->assertPermissionError($req); + } + } + + public function test_forgot_password_routes_inaccessible() + { + $resp = $this->get('/password/email'); + $this->assertPermissionError($resp); + + $resp = $this->post('/password/email'); + $this->assertPermissionError($resp); + + $resp = $this->get('/password/reset/abc123'); + $this->assertPermissionError($resp); + + $resp = $this->post('/password/reset'); + $this->assertPermissionError($resp); + } + + public function test_standard_login_routes_inaccessible() + { + $resp = $this->post('/login'); + $this->assertPermissionError($resp); + + $resp = $this->get('/logout'); + $this->assertPermissionError($resp); + } + + public function test_user_invite_routes_inaccessible() + { + $resp = $this->get('/register/invite/abc123'); + $this->assertPermissionError($resp); + + $resp = $this->post('/register/invite/abc123'); + $this->assertPermissionError($resp); + } + + public function test_user_register_routes_inaccessible() + { + $resp = $this->get('/register'); + $this->assertPermissionError($resp); + + $resp = $this->post('/register'); + $this->assertPermissionError($resp); + } +} From f2d320825a34b425457954b832bccd3a6ed56cfd Mon Sep 17 00:00:00 2001 From: Jasper Weyne Date: Tue, 4 Aug 2020 22:09:53 +0200 Subject: [PATCH 11/50] Simplify refresh method --- app/Auth/Access/OpenIdService.php | 65 +++++++++++++++++++------------ 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/app/Auth/Access/OpenIdService.php b/app/Auth/Access/OpenIdService.php index 2b536d492..4eea3c252 100644 --- a/app/Auth/Access/OpenIdService.php +++ b/app/Auth/Access/OpenIdService.php @@ -71,41 +71,56 @@ class OpenIdService extends ExternalAuthService $accessToken = new AccessToken(json_decode($json, true) ?? []); - // Check if both the access token and the ID token (if present) are unexpired - $idToken = $accessToken->getIdToken(); - $accessTokenUnexpired = $accessToken->getExpires() && !$accessToken->hasExpired(); - $idTokenUnexpired = !$idToken || !$idToken->isExpired(); - if ($accessTokenUnexpired && $idTokenUnexpired) { + // If the token is not expired, refreshing isn't necessary + if ($this->isUnexpired($accessToken)) { return true; } - // If no refresh token available, logout - if ($accessToken->getRefreshToken() === null) { - $this->actionLogout(); - return false; - } - - // ID token or access token is expired, we refresh it using the refresh token + // Try to obtain refreshed access token try { - $provider = $this->getProvider(); - - $accessToken = $provider->getAccessToken('refresh_token', [ - 'refresh_token' => $accessToken->getRefreshToken(), - ]); - } catch (IdentityProviderException $e) { - // Refreshing failed, logout - $this->actionLogout(); - return false; + $newAccessToken = $this->refreshAccessToken($accessToken); } catch (\Exception $e) { - // Unknown error, logout and throw + // Log out if an unknown problem arises $this->actionLogout(); throw $e; } - // A valid token was obtained, we update the access token - session()->put('openid_token', json_encode($accessToken)); + // If a token was obtained, update the access token, otherwise log out + if ($newAccessToken !== null) { + session()->put('openid_token', json_encode($newAccessToken)); + return true; + } else { + $this->actionLogout(); + return false; + } + } - return true; + protected function isUnexpired(AccessToken $accessToken): bool + { + $idToken = $accessToken->getIdToken(); + + $accessTokenUnexpired = $accessToken->getExpires() && !$accessToken->hasExpired(); + $idTokenUnexpired = !$idToken || !$idToken->isExpired(); + + return $accessTokenUnexpired && $idTokenUnexpired; + } + + protected function refreshAccessToken(AccessToken $accessToken): ?AccessToken + { + // If no refresh token available, abort + if ($accessToken->getRefreshToken() === null) { + return null; + } + + // ID token or access token is expired, we refresh it using the refresh token + try { + return $this->getProvider()->getAccessToken('refresh_token', [ + 'refresh_token' => $accessToken->getRefreshToken(), + ]); + } catch (IdentityProviderException $e) { + // Refreshing failed + return null; + } } /** From 35c48b94163d8a17bcdd9a9fb360a3f43f1cd2b5 Mon Sep 17 00:00:00 2001 From: Jasper Weyne Date: Wed, 5 Aug 2020 00:18:43 +0200 Subject: [PATCH 12/50] Method descriptions --- app/Auth/Access/OpenIdService.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/Auth/Access/OpenIdService.php b/app/Auth/Access/OpenIdService.php index 4eea3c252..70df963f5 100644 --- a/app/Auth/Access/OpenIdService.php +++ b/app/Auth/Access/OpenIdService.php @@ -95,6 +95,9 @@ class OpenIdService extends ExternalAuthService } } + /** + * Check whether an access token or OpenID token isn't expired. + */ protected function isUnexpired(AccessToken $accessToken): bool { $idToken = $accessToken->getIdToken(); @@ -105,6 +108,10 @@ class OpenIdService extends ExternalAuthService return $accessTokenUnexpired && $idTokenUnexpired; } + /** + * Generate an updated access token, through the associated refresh token. + * @throws Error + */ protected function refreshAccessToken(AccessToken $accessToken): ?AccessToken { // If no refresh token available, abort From 69a47319d51efb3d39bb36eff4df9ca2c31165ae Mon Sep 17 00:00:00 2001 From: Jasper Weyne Date: Wed, 5 Aug 2020 13:14:46 +0200 Subject: [PATCH 13/50] Default OpenID display name set to standard value --- app/Config/openid.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Config/openid.php b/app/Config/openid.php index 20089518b..90eb6d5db 100644 --- a/app/Config/openid.php +++ b/app/Config/openid.php @@ -11,7 +11,7 @@ return [ // Attribute, within a OpenId token, to find the user's email address 'email_attribute' => env('OPENID_EMAIL_ATTRIBUTE', 'email'), // Attribute, within a OpenId token, to find the user's display name - 'display_name_attributes' => explode('|', env('OPENID_DISPLAY_NAME_ATTRIBUTES', 'username')), + 'display_name_attributes' => explode('|', env('OPENID_DISPLAY_NAME_ATTRIBUTES', 'name')), // Attribute, within a OpenId token, to use to connect a BookStack user to the OpenId user. 'external_id_attribute' => env('OPENID_EXTERNAL_ID_ATTRIBUTE', null), From 75749ef336848269d770f7f3b2bf7c9fb9d42479 Mon Sep 17 00:00:00 2001 From: Franke Date: Mon, 30 Aug 2021 14:35:11 +0200 Subject: [PATCH 14/50] Fixed SAML logout for ADFS. --- .env.example.complete | 5 +++++ app/Auth/Access/Saml2Service.php | 12 ++++++++++-- app/Config/saml2.php | 7 +++++-- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.env.example.complete b/.env.example.complete index 26df8f3cb..58e4e4754 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -224,6 +224,11 @@ SAML2_ONELOGIN_OVERRIDES=null SAML2_DUMP_USER_DETAILS=false SAML2_AUTOLOAD_METADATA=false SAML2_IDP_AUTHNCONTEXT=true +SAML2_SP_CERTIFICATE=null +SAML2_SP_PRIVATEKEY=null +SAML2_SP_NAME_ID_Format=null +SAML2_SP_NAME_ID_SP_NAME_QUALIFIER=null +SAML2_RETRIEVE_PARAMETERS_FROM_SERVER=false # SAML group sync configuration # Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/ diff --git a/app/Auth/Access/Saml2Service.php b/app/Auth/Access/Saml2Service.php index 6cbfdac0b..e72aef979 100644 --- a/app/Auth/Access/Saml2Service.php +++ b/app/Auth/Access/Saml2Service.php @@ -61,8 +61,14 @@ class Saml2Service extends ExternalAuthService $returnRoute = url('/'); try { - $url = $toolKit->logout($returnRoute, [], null, null, true); + $email = auth()->user()['email']; + $nameIdFormat = env('SAML2_SP_NAME_ID_Format', null); + $nameIdSPNameQualifier = env('SAML2_SP_NAME_ID_SP_NAME_QUALIFIER', null); + + + $url = $toolKit->logout($returnRoute, [], $email, null, true, $nameIdFormat, null, $nameIdSPNameQualifier); $id = $toolKit->getLastRequestID(); + } catch (Error $error) { if ($error->getCode() !== Error::SAML_SINGLE_LOGOUT_NOT_SUPPORTED) { throw $error; @@ -117,7 +123,9 @@ class Saml2Service extends ExternalAuthService public function processSlsResponse(?string $requestId): ?string { $toolkit = $this->getToolkit(); - $redirect = $toolkit->processSLO(true, $requestId, false, null, true); + $retrieveParametersFromServer = env('SAML2_RETRIEVE_PARAMETERS_FROM_SERVER', false); + + $redirect = $toolkit->processSLO(true, $requestId, $retrieveParametersFromServer, null, true); $errors = $toolkit->getErrors(); diff --git a/app/Config/saml2.php b/app/Config/saml2.php index fe311057c..709931fc6 100644 --- a/app/Config/saml2.php +++ b/app/Config/saml2.php @@ -80,8 +80,8 @@ return [ 'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', // Usually x509cert and privateKey of the SP are provided by files placed at // the certs folder. But we can also provide them with the following parameters - 'x509cert' => '', - 'privateKey' => '', + 'x509cert' => env('SAML2_SP_CERTIFICATE', ''), + 'privateKey' => env('SAML2_SP_PRIVATEKEY', ''), ], // Identity Provider Data that we want connect with our SP 'idp' => [ @@ -147,6 +147,9 @@ return [ // Multiple forced values can be passed via a space separated array, For example: // SAML2_IDP_AUTHNCONTEXT="urn:federation:authentication:windows urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport" 'requestedAuthnContext' => is_string($SAML2_IDP_AUTHNCONTEXT) ? explode(' ', $SAML2_IDP_AUTHNCONTEXT) : $SAML2_IDP_AUTHNCONTEXT, + 'logoutRequestSigned' => env('', false), + 'logoutResponseSigned' => env('', false), + 'lowercaseUrlencoding' => env('', false) ], ], From 234dd26d22478451c17e648cc820fac2db081dff Mon Sep 17 00:00:00 2001 From: Franke Date: Mon, 30 Aug 2021 14:43:35 +0200 Subject: [PATCH 15/50] Fixes for CodeStyle --- app/Auth/Access/Saml2Service.php | 2 -- app/Config/saml2.php | 6 +++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/app/Auth/Access/Saml2Service.php b/app/Auth/Access/Saml2Service.php index e72aef979..339701d27 100644 --- a/app/Auth/Access/Saml2Service.php +++ b/app/Auth/Access/Saml2Service.php @@ -65,10 +65,8 @@ class Saml2Service extends ExternalAuthService $nameIdFormat = env('SAML2_SP_NAME_ID_Format', null); $nameIdSPNameQualifier = env('SAML2_SP_NAME_ID_SP_NAME_QUALIFIER', null); - $url = $toolKit->logout($returnRoute, [], $email, null, true, $nameIdFormat, null, $nameIdSPNameQualifier); $id = $toolKit->getLastRequestID(); - } catch (Error $error) { if ($error->getCode() !== Error::SAML_SINGLE_LOGOUT_NOT_SUPPORTED) { throw $error; diff --git a/app/Config/saml2.php b/app/Config/saml2.php index 709931fc6..ad6dfe1f9 100644 --- a/app/Config/saml2.php +++ b/app/Config/saml2.php @@ -147,9 +147,9 @@ return [ // Multiple forced values can be passed via a space separated array, For example: // SAML2_IDP_AUTHNCONTEXT="urn:federation:authentication:windows urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport" 'requestedAuthnContext' => is_string($SAML2_IDP_AUTHNCONTEXT) ? explode(' ', $SAML2_IDP_AUTHNCONTEXT) : $SAML2_IDP_AUTHNCONTEXT, - 'logoutRequestSigned' => env('', false), - 'logoutResponseSigned' => env('', false), - 'lowercaseUrlencoding' => env('', false) + 'logoutRequestSigned' => env('SAML2_LOGOUT_REQUEST_SIGNED', false), + 'logoutResponseSigned' => env('SAML2_LOGOUT_RESPONSE_SIGNED', false), + 'lowercaseUrlencoding' => env('SAML2_LOWERCASE_URLENCODING', false) ], ], From 07408ec11243995ef6bfded34d87f455336d9460 Mon Sep 17 00:00:00 2001 From: Franke Date: Mon, 30 Aug 2021 14:44:52 +0200 Subject: [PATCH 16/50] Fixes for CodeStyle vol.2 --- app/Config/saml2.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Config/saml2.php b/app/Config/saml2.php index ad6dfe1f9..3c4319100 100644 --- a/app/Config/saml2.php +++ b/app/Config/saml2.php @@ -149,7 +149,7 @@ return [ 'requestedAuthnContext' => is_string($SAML2_IDP_AUTHNCONTEXT) ? explode(' ', $SAML2_IDP_AUTHNCONTEXT) : $SAML2_IDP_AUTHNCONTEXT, 'logoutRequestSigned' => env('SAML2_LOGOUT_REQUEST_SIGNED', false), 'logoutResponseSigned' => env('SAML2_LOGOUT_RESPONSE_SIGNED', false), - 'lowercaseUrlencoding' => env('SAML2_LOWERCASE_URLENCODING', false) + 'lowercaseUrlencoding' => env('SAML2_LOWERCASE_URLENCODING', false), ], ], From 2ec0aa85cab7c09f45589af6b05a053d44a8ca46 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 6 Oct 2021 17:12:01 +0100 Subject: [PATCH 17/50] Started refactor for merge of OIDC - Made oidc config more generic to not be overly reliant on the library based upon learnings from saml2 auth. - Removed any settings that are redundant or not deemed required for initial implementation. - Reduced some methods down where not needed. - Renamed OpenID to OIDC - Updated .env.example.complete to align with all options and their defaults Related to #2169 --- .env.example.complete | 15 +- app/Auth/Access/ExternalAuthService.php | 2 +- app/Auth/Access/OpenIdService.php | 108 ++---- app/Auth/Access/Saml2Service.php | 2 +- app/Config/oidc.php | 30 ++ app/Config/openid.php | 46 --- composer.lock | 418 ++++++++++++------------ 7 files changed, 283 insertions(+), 338 deletions(-) create mode 100644 app/Config/oidc.php delete mode 100644 app/Config/openid.php diff --git a/.env.example.complete b/.env.example.complete index 5a586d1d1..e92eb5099 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -240,12 +240,15 @@ SAML2_GROUP_ATTRIBUTE=group SAML2_REMOVE_FROM_GROUPS=false # OpenID Connect authentication configuration -OPENID_CLIENT_ID=null -OPENID_CLIENT_SECRET=null -OPENID_ISSUER=https://example.com -OPENID_PUBLIC_KEY=file:///my/public.key -OPENID_URL_AUTHORIZE=https://example.com/authorize -OPENID_URL_TOKEN=https://example.com/token +OIDC_NAME=SSO +OIDC_DISPLAY_NAME_CLAIMS=name +OIDC_CLIENT_ID=null +OIDC_CLIENT_SECRET=null +OIDC_ISSUER=null +OIDC_PUBLIC_KEY=null +OIDC_AUTH_ENDPOINT=null +OIDC_TOKEN_ENDPOINT=null +OIDC_DUMP_USER_DETAILS=false # Disable default third-party services such as Gravatar and Draw.IO # Service-specific options will override this option diff --git a/app/Auth/Access/ExternalAuthService.php b/app/Auth/Access/ExternalAuthService.php index b0c9e8e7b..b2b9302af 100644 --- a/app/Auth/Access/ExternalAuthService.php +++ b/app/Auth/Access/ExternalAuthService.php @@ -4,8 +4,8 @@ namespace BookStack\Auth\Access; use BookStack\Auth\Role; use BookStack\Auth\User; +use BookStack\Exceptions\UserRegistrationException; use Illuminate\Support\Collection; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Str; class ExternalAuthService diff --git a/app/Auth/Access/OpenIdService.php b/app/Auth/Access/OpenIdService.php index 70df963f5..f56ff0b91 100644 --- a/app/Auth/Access/OpenIdService.php +++ b/app/Auth/Access/OpenIdService.php @@ -5,9 +5,11 @@ use BookStack\Exceptions\JsonDebugException; use BookStack\Exceptions\OpenIdException; use BookStack\Exceptions\UserRegistrationException; use Exception; +use Lcobucci\JWT\Signer\Rsa\Sha256; use Lcobucci\JWT\Token; use League\OAuth2\Client\Provider\Exception\IdentityProviderException; use OpenIDConnectClient\AccessToken; +use OpenIDConnectClient\Exception\InvalidTokenException; use OpenIDConnectClient\OpenIDConnectProvider; /** @@ -25,12 +27,12 @@ class OpenIdService extends ExternalAuthService { parent::__construct($registrationService, $user); - $this->config = config('openid'); + $this->config = config('oidc'); } /** - * Initiate a authorization flow. - * @throws Error + * Initiate an authorization flow. + * @throws Exception */ public function login(): array { @@ -43,7 +45,6 @@ class OpenIdService extends ExternalAuthService /** * Initiate a logout flow. - * @throws Error */ public function logout(): array { @@ -56,7 +57,7 @@ class OpenIdService extends ExternalAuthService /** * Refresh the currently logged in user. - * @throws Error + * @throws Exception */ public function refresh(): bool { @@ -79,7 +80,7 @@ class OpenIdService extends ExternalAuthService // Try to obtain refreshed access token try { $newAccessToken = $this->refreshAccessToken($accessToken); - } catch (\Exception $e) { + } catch (Exception $e) { // Log out if an unknown problem arises $this->actionLogout(); throw $e; @@ -110,7 +111,7 @@ class OpenIdService extends ExternalAuthService /** * Generate an updated access token, through the associated refresh token. - * @throws Error + * @throws Exception */ protected function refreshAccessToken(AccessToken $accessToken): ?AccessToken { @@ -135,11 +136,8 @@ class OpenIdService extends ExternalAuthService * return the matching, or new if registration active, user matched to * the authorization server. * Returns null if not authenticated. - * @throws Error - * @throws OpenIdException - * @throws ValidationError - * @throws JsonDebugException - * @throws UserRegistrationException + * @throws Exception + * @throws InvalidTokenException */ public function processAuthorizeResponse(?string $authorizationCode): ?User { @@ -164,87 +162,50 @@ class OpenIdService extends ExternalAuthService /** * Load the underlying OpenID Connect Provider. - * @throws Error - * @throws Exception */ protected function getProvider(): OpenIDConnectProvider { // Setup settings - $settings = $this->config['openid']; - $overrides = $this->config['openid_overrides'] ?? []; - - if ($overrides && is_string($overrides)) { - $overrides = json_decode($overrides, true); - } - - $openIdSettings = $this->loadOpenIdDetails(); - $settings = array_replace_recursive($settings, $openIdSettings, $overrides); + $settings = [ + 'clientId' => $this->config['client_id'], + 'clientSecret' => $this->config['client_secret'], + 'idTokenIssuer' => $this->config['issuer'], + 'redirectUri' => url('/openid/redirect'), + 'urlAuthorize' => $this->config['authorization_endpoint'], + 'urlAccessToken' => $this->config['token_endpoint'], + 'urlResourceOwnerDetails' => null, + 'publicKey' => $this->config['jwt_public_key'], + 'scopes' => 'profile email', + ]; // Setup services - $services = $this->loadOpenIdServices(); - $overrides = $this->config['openid_services'] ?? []; - - $services = array_replace_recursive($services, $overrides); + $services = [ + 'signer' => new Sha256(), + ]; return new OpenIDConnectProvider($settings, $services); } - /** - * Load services utilized by the OpenID Connect provider. - */ - protected function loadOpenIdServices(): array - { - return [ - 'signer' => new \Lcobucci\JWT\Signer\Rsa\Sha256(), - ]; - } - - /** - * Load dynamic service provider options required by the OpenID Connect provider. - */ - protected function loadOpenIdDetails(): array - { - return [ - 'redirectUri' => url('/openid/redirect'), - ]; - } - /** * Calculate the display name */ protected function getUserDisplayName(Token $token, string $defaultValue): string { - $displayNameAttr = $this->config['display_name_attributes']; + $displayNameAttr = $this->config['display_name_claims']; $displayName = []; foreach ($displayNameAttr as $dnAttr) { - $dnComponent = $token->getClaim($dnAttr, ''); + $dnComponent = $token->claims()->get($dnAttr, ''); if ($dnComponent !== '') { $displayName[] = $dnComponent; } } if (count($displayName) == 0) { - $displayName = $defaultValue; - } else { - $displayName = implode(' ', $displayName); + $displayName[] = $defaultValue; } - return $displayName; - } - - /** - * Get the value to use as the external id saved in BookStack - * used to link the user to an existing BookStack DB user. - */ - protected function getExternalId(Token $token, string $defaultValue) - { - $userNameAttr = $this->config['external_id_attribute']; - if ($userNameAttr === null) { - return $defaultValue; - } - - return $token->getClaim($userNameAttr, $defaultValue); + return implode(' ', $displayName);; } /** @@ -252,16 +213,11 @@ class OpenIdService extends ExternalAuthService */ protected function getUserDetails(Token $token): array { - $email = null; - $emailAttr = $this->config['email_attribute']; - if ($token->hasClaim($emailAttr)) { - $email = $token->getClaim($emailAttr); - } - + $id = $token->claims()->get('sub'); return [ - 'external_id' => $token->getClaim('sub'), - 'email' => $email, - 'name' => $this->getUserDisplayName($token, $email), + 'external_id' => $id, + 'email' => $token->claims()->get('email'), + 'name' => $this->getUserDisplayName($token, $id), ]; } diff --git a/app/Auth/Access/Saml2Service.php b/app/Auth/Access/Saml2Service.php index 74e8c7726..b1489fbce 100644 --- a/app/Auth/Access/Saml2Service.php +++ b/app/Auth/Access/Saml2Service.php @@ -26,7 +26,7 @@ class Saml2Service extends ExternalAuthService /** * Saml2Service constructor. */ - public function __construct(RegistrationService $registrationService, LoginService $loginService, User $user), + public function __construct(RegistrationService $registrationService, LoginService $loginService, User $user) { parent::__construct($registrationService, $user); diff --git a/app/Config/oidc.php b/app/Config/oidc.php new file mode 100644 index 000000000..43e8678ad --- /dev/null +++ b/app/Config/oidc.php @@ -0,0 +1,30 @@ + env('OIDC_NAME', 'SSO'), + + // Dump user details after a login request for debugging purposes + 'dump_user_details' => env('OIDC_DUMP_USER_DETAILS', false), + + // Attribute, within a OpenId token, to find the user's display name + 'display_name_claims' => explode('|', env('OIDC_DISPLAY_NAME_CLAIMS', 'name')), + + // OAuth2/OpenId client id, as configured in your Authorization server. + 'client_id' => env('OIDC_CLIENT_ID', null), + + // OAuth2/OpenId client secret, as configured in your Authorization server. + 'client_secret' => env('OIDC_CLIENT_SECRET', null), + + // The issuer of the identity token (id_token) this will be compared with what is returned in the token. + 'issuer' => env('OIDC_ISSUER', null), + + // Public key that's used to verify the JWT token with. + // Can be the key value itself or a local 'file://public.key' reference. + 'jwt_public_key' => env('OIDC_PUBLIC_KEY', null), + + // OAuth2 endpoints. + 'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null), + 'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null), +]; diff --git a/app/Config/openid.php b/app/Config/openid.php deleted file mode 100644 index 90eb6d5db..000000000 --- a/app/Config/openid.php +++ /dev/null @@ -1,46 +0,0 @@ - env('OPENID_NAME', 'SSO'), - - // Dump user details after a login request for debugging purposes - 'dump_user_details' => env('OPENID_DUMP_USER_DETAILS', false), - - // Attribute, within a OpenId token, to find the user's email address - 'email_attribute' => env('OPENID_EMAIL_ATTRIBUTE', 'email'), - // Attribute, within a OpenId token, to find the user's display name - 'display_name_attributes' => explode('|', env('OPENID_DISPLAY_NAME_ATTRIBUTES', 'name')), - // Attribute, within a OpenId token, to use to connect a BookStack user to the OpenId user. - 'external_id_attribute' => env('OPENID_EXTERNAL_ID_ATTRIBUTE', null), - - // Overrides, in JSON format, to the configuration passed to underlying OpenIDConnectProvider library. - 'openid_overrides' => env('OPENID_OVERRIDES', null), - - // Custom service instances, used by the underlying OpenIDConnectProvider library - 'openid_services' => [], - - 'openid' => [ - // OAuth2/OpenId client id, as configured in your Authorization server. - 'clientId' => env('OPENID_CLIENT_ID', ''), - - // OAuth2/OpenId client secret, as configured in your Authorization server. - 'clientSecret' => env('OPENID_CLIENT_SECRET', ''), - - // OAuth2 scopes that are request, by default the OpenId-native profile and email scopes. - 'scopes' => 'profile email', - - // The issuer of the identity token (id_token) this will be compared with what is returned in the token. - 'idTokenIssuer' => env('OPENID_ISSUER', ''), - - // Public key that's used to verify the JWT token with. - 'publicKey' => env('OPENID_PUBLIC_KEY', ''), - - // OAuth2 endpoints. - 'urlAuthorize' => env('OPENID_URL_AUTHORIZE', ''), - 'urlAccessToken' => env('OPENID_URL_TOKEN', ''), - 'urlResourceOwnerDetails' => env('OPENID_URL_RESOURCE', ''), - ], - -]; diff --git a/composer.lock b/composer.lock index a3cfe6e7e..9355deed3 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "10825887b8f66d1d412b92bcc0ca864f", + "content-hash": "620412108a5d19ed91d9fe42418b63b5", "packages": [ { "name": "aws/aws-crt-php", @@ -58,16 +58,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.194.1", + "version": "3.197.0", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "67bdee05acef9e8ad60098090996690b49babd09" + "reference": "c5391ef7c979473b97d81329100bfa5fb018fa62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/67bdee05acef9e8ad60098090996690b49babd09", - "reference": "67bdee05acef9e8ad60098090996690b49babd09", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/c5391ef7c979473b97d81329100bfa5fb018fa62", + "reference": "c5391ef7c979473b97d81329100bfa5fb018fa62", "shasum": "" }, "require": { @@ -143,9 +143,9 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.194.1" + "source": "https://github.com/aws/aws-sdk-php/tree/3.197.0" }, - "time": "2021-09-17T18:15:42+00:00" + "time": "2021-10-05T18:14:34+00:00" }, { "name": "bacon/bacon-qr-code", @@ -479,16 +479,16 @@ }, { "name": "doctrine/dbal", - "version": "2.13.3", + "version": "2.13.4", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "0d7adf4cadfee6f70850e5b163e6cdd706417838" + "reference": "2411a55a2a628e6d8dd598388ab13474802c7b6e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/0d7adf4cadfee6f70850e5b163e6cdd706417838", - "reference": "0d7adf4cadfee6f70850e5b163e6cdd706417838", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/2411a55a2a628e6d8dd598388ab13474802c7b6e", + "reference": "2411a55a2a628e6d8dd598388ab13474802c7b6e", "shasum": "" }, "require": { @@ -501,8 +501,8 @@ "require-dev": { "doctrine/coding-standard": "9.0.0", "jetbrains/phpstorm-stubs": "2021.1", - "phpstan/phpstan": "0.12.96", - "phpunit/phpunit": "^7.5.20|^8.5|9.5.5", + "phpstan/phpstan": "0.12.99", + "phpunit/phpunit": "^7.5.20|^8.5|9.5.10", "psalm/plugin-phpunit": "0.16.1", "squizlabs/php_codesniffer": "3.6.0", "symfony/cache": "^4.4", @@ -568,7 +568,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/2.13.3" + "source": "https://github.com/doctrine/dbal/tree/2.13.4" }, "funding": [ { @@ -584,7 +584,7 @@ "type": "tidelift" } ], - "time": "2021-09-12T19:11:48+00:00" + "time": "2021-10-02T15:59:26+00:00" }, { "name": "doctrine/deprecations", @@ -1356,21 +1356,21 @@ }, { "name": "filp/whoops", - "version": "2.14.1", + "version": "2.14.4", "source": { "type": "git", "url": "https://github.com/filp/whoops.git", - "reference": "15ead64e9828f0fc90932114429c4f7923570cb1" + "reference": "f056f1fe935d9ed86e698905a957334029899895" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/15ead64e9828f0fc90932114429c4f7923570cb1", - "reference": "15ead64e9828f0fc90932114429c4f7923570cb1", + "url": "https://api.github.com/repos/filp/whoops/zipball/f056f1fe935d9ed86e698905a957334029899895", + "reference": "f056f1fe935d9ed86e698905a957334029899895", "shasum": "" }, "require": { "php": "^5.5.9 || ^7.0 || ^8.0", - "psr/log": "^1.0.1" + "psr/log": "^1.0.1 || ^2.0 || ^3.0" }, "require-dev": { "mockery/mockery": "^0.9 || ^1.0", @@ -1415,7 +1415,7 @@ ], "support": { "issues": "https://github.com/filp/whoops/issues", - "source": "https://github.com/filp/whoops/tree/2.14.1" + "source": "https://github.com/filp/whoops/tree/2.14.4" }, "funding": [ { @@ -1423,7 +1423,7 @@ "type": "github" } ], - "time": "2021-08-29T12:00:00+00:00" + "time": "2021-10-03T12:00:00+00:00" }, { "name": "guzzlehttp/guzzle", @@ -1585,16 +1585,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "1.8.2", + "version": "1.8.3", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "dc960a912984efb74d0a90222870c72c87f10c91" + "reference": "1afdd860a2566ed3c2b0b4a3de6e23434a79ec85" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/dc960a912984efb74d0a90222870c72c87f10c91", - "reference": "dc960a912984efb74d0a90222870c72c87f10c91", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/1afdd860a2566ed3c2b0b4a3de6e23434a79ec85", + "reference": "1afdd860a2566ed3c2b0b4a3de6e23434a79ec85", "shasum": "" }, "require": { @@ -1631,13 +1631,34 @@ "MIT" ], "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, { "name": "Michael Dowling", "email": "mtdowling@gmail.com", "homepage": "https://github.com/mtdowling" }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, { "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", "homepage": "https://github.com/Tobion" } ], @@ -1654,22 +1675,36 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/1.8.2" + "source": "https://github.com/guzzle/psr7/tree/1.8.3" }, - "time": "2021-04-26T09:17:50+00:00" + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2021-10-05T13:56:00+00:00" }, { "name": "intervention/image", - "version": "2.6.1", + "version": "2.7.0", "source": { "type": "git", "url": "https://github.com/Intervention/image.git", - "reference": "0925f10b259679b5d8ca58f3a2add9255ffcda45" + "reference": "9a8cc99d30415ec0b3f7649e1647d03a55698545" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/image/zipball/0925f10b259679b5d8ca58f3a2add9255ffcda45", - "reference": "0925f10b259679b5d8ca58f3a2add9255ffcda45", + "url": "https://api.github.com/repos/Intervention/image/zipball/9a8cc99d30415ec0b3f7649e1647d03a55698545", + "reference": "9a8cc99d30415ec0b3f7649e1647d03a55698545", "shasum": "" }, "require": { @@ -1728,7 +1763,7 @@ ], "support": { "issues": "https://github.com/Intervention/image/issues", - "source": "https://github.com/Intervention/image/tree/2.6.1" + "source": "https://github.com/Intervention/image/tree/2.7.0" }, "funding": [ { @@ -1740,7 +1775,7 @@ "type": "github" } ], - "time": "2021-07-22T14:31:53+00:00" + "time": "2021-10-03T14:17:12+00:00" }, { "name": "knplabs/knp-snappy", @@ -1814,16 +1849,16 @@ }, { "name": "laravel/framework", - "version": "v6.20.34", + "version": "v6.20.35", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "72a6da88c90cee793513b3fe49cf0fcb368eefa0" + "reference": "5e55aa4063b9f7cf3249bfebcc37a6fbad4f159a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/72a6da88c90cee793513b3fe49cf0fcb368eefa0", - "reference": "72a6da88c90cee793513b3fe49cf0fcb368eefa0", + "url": "https://api.github.com/repos/laravel/framework/zipball/5e55aa4063b9f7cf3249bfebcc37a6fbad4f159a", + "reference": "5e55aa4063b9f7cf3249bfebcc37a6fbad4f159a", "shasum": "" }, "require": { @@ -1963,7 +1998,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2021-09-07T13:28:55+00:00" + "time": "2021-10-05T14:05:19+00:00" }, { "name": "laravel/socialite", @@ -2036,16 +2071,16 @@ }, { "name": "lcobucci/jwt", - "version": "3.3.2", + "version": "3.4.6", "source": { "type": "git", "url": "https://github.com/lcobucci/jwt.git", - "reference": "56f10808089e38623345e28af2f2d5e4eb579455" + "reference": "3ef8657a78278dfeae7707d51747251db4176240" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/lcobucci/jwt/zipball/56f10808089e38623345e28af2f2d5e4eb579455", - "reference": "56f10808089e38623345e28af2f2d5e4eb579455", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/3ef8657a78278dfeae7707d51747251db4176240", + "reference": "3ef8657a78278dfeae7707d51747251db4176240", "shasum": "" }, "require": { @@ -2060,6 +2095,9 @@ "phpunit/phpunit": "^5.7 || ^7.3", "squizlabs/php_codesniffer": "~2.3" }, + "suggest": { + "lcobucci/clock": "*" + }, "type": "library", "extra": { "branch-alias": { @@ -2069,7 +2107,12 @@ "autoload": { "psr-4": { "Lcobucci\\JWT\\": "src" - } + }, + "files": [ + "compat/class-aliases.php", + "compat/json-exception-polyfill.php", + "compat/lcobucci-clock-polyfill.php" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2087,6 +2130,10 @@ "JWS", "jwt" ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/3.4.6" + }, "funding": [ { "url": "https://github.com/lcobucci", @@ -2097,7 +2144,7 @@ "type": "patreon" } ], - "time": "2020-05-22T08:21:12+00:00" + "time": "2021-09-28T19:18:28+00:00" }, { "name": "league/commonmark", @@ -2436,16 +2483,16 @@ }, { "name": "league/mime-type-detection", - "version": "1.7.0", + "version": "1.8.0", "source": { "type": "git", "url": "https://github.com/thephpleague/mime-type-detection.git", - "reference": "3b9dff8aaf7323590c1d2e443db701eb1f9aa0d3" + "reference": "b38b25d7b372e9fddb00335400467b223349fd7e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/3b9dff8aaf7323590c1d2e443db701eb1f9aa0d3", - "reference": "3b9dff8aaf7323590c1d2e443db701eb1f9aa0d3", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/b38b25d7b372e9fddb00335400467b223349fd7e", + "reference": "b38b25d7b372e9fddb00335400467b223349fd7e", "shasum": "" }, "require": { @@ -2476,7 +2523,7 @@ "description": "Mime-type detection for Flysystem", "support": { "issues": "https://github.com/thephpleague/mime-type-detection/issues", - "source": "https://github.com/thephpleague/mime-type-detection/tree/1.7.0" + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.8.0" }, "funding": [ { @@ -2488,7 +2535,7 @@ "type": "tidelift" } ], - "time": "2021-01-18T20:58:21+00:00" + "time": "2021-09-25T08:23:19+00:00" }, { "name": "league/oauth1-client", @@ -2568,29 +2615,28 @@ }, { "name": "league/oauth2-client", - "version": "2.4.1", + "version": "2.6.0", "source": { "type": "git", "url": "https://github.com/thephpleague/oauth2-client.git", - "reference": "cc114abc622a53af969e8664722e84ca36257530" + "reference": "badb01e62383430706433191b82506b6df24ad98" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/cc114abc622a53af969e8664722e84ca36257530", - "reference": "cc114abc622a53af969e8664722e84ca36257530", + "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/badb01e62383430706433191b82506b6df24ad98", + "reference": "badb01e62383430706433191b82506b6df24ad98", "shasum": "" }, "require": { - "guzzlehttp/guzzle": "^6.0", - "paragonie/random_compat": "^1|^2|^9.99", - "php": "^5.6|^7.0" + "guzzlehttp/guzzle": "^6.0 || ^7.0", + "paragonie/random_compat": "^1 || ^2 || ^9.99", + "php": "^5.6 || ^7.0 || ^8.0" }, "require-dev": { - "eloquent/liberator": "^2.0", - "eloquent/phony-phpunit": "^1.0|^3.0", - "jakub-onderka/php-parallel-lint": "^0.9.2", - "phpunit/phpunit": "^5.7|^6.0", - "squizlabs/php_codesniffer": "^2.3|^3.0" + "mockery/mockery": "^1.3", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpunit/phpunit": "^5.7 || ^6.0 || ^9.3", + "squizlabs/php_codesniffer": "^2.3 || ^3.0" }, "type": "library", "extra": { @@ -2631,20 +2677,24 @@ "oauth2", "single sign on" ], - "time": "2018-11-22T18:33:57+00:00" + "support": { + "issues": "https://github.com/thephpleague/oauth2-client/issues", + "source": "https://github.com/thephpleague/oauth2-client/tree/2.6.0" + }, + "time": "2020-10-28T02:03:40+00:00" }, { "name": "monolog/monolog", - "version": "2.3.4", + "version": "2.3.5", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "437e7a1c50044b92773b361af77620efb76fff59" + "reference": "fd4380d6fc37626e2f799f29d91195040137eba9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/437e7a1c50044b92773b361af77620efb76fff59", - "reference": "437e7a1c50044b92773b361af77620efb76fff59", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/fd4380d6fc37626e2f799f29d91195040137eba9", + "reference": "fd4380d6fc37626e2f799f29d91195040137eba9", "shasum": "" }, "require": { @@ -2660,7 +2710,7 @@ "elasticsearch/elasticsearch": "^7", "graylog2/gelf-php": "^1.4.2", "mongodb/mongodb": "^1.8", - "php-amqplib/php-amqplib": "~2.4", + "php-amqplib/php-amqplib": "~2.4 || ^3", "php-console/php-console": "^3.1.3", "phpspec/prophecy": "^1.6.1", "phpstan/phpstan": "^0.12.91", @@ -2718,7 +2768,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/2.3.4" + "source": "https://github.com/Seldaek/monolog/tree/2.3.5" }, "funding": [ { @@ -2730,7 +2780,7 @@ "type": "tidelift" } ], - "time": "2021-09-15T11:27:21+00:00" + "time": "2021-10-01T21:08:31+00:00" }, { "name": "mtdowling/jmespath.php", @@ -3515,16 +3565,16 @@ }, { "name": "predis/predis", - "version": "v1.1.7", + "version": "v1.1.9", "source": { "type": "git", "url": "https://github.com/predis/predis.git", - "reference": "b240daa106d4e02f0c5b7079b41e31ddf66fddf8" + "reference": "c50c3393bb9f47fa012d0cdfb727a266b0818259" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/predis/predis/zipball/b240daa106d4e02f0c5b7079b41e31ddf66fddf8", - "reference": "b240daa106d4e02f0c5b7079b41e31ddf66fddf8", + "url": "https://api.github.com/repos/predis/predis/zipball/c50c3393bb9f47fa012d0cdfb727a266b0818259", + "reference": "c50c3393bb9f47fa012d0cdfb727a266b0818259", "shasum": "" }, "require": { @@ -3569,7 +3619,7 @@ ], "support": { "issues": "https://github.com/predis/predis/issues", - "source": "https://github.com/predis/predis/tree/v1.1.7" + "source": "https://github.com/predis/predis/tree/v1.1.9" }, "funding": [ { @@ -3577,7 +3627,7 @@ "type": "github" } ], - "time": "2021-04-04T19:34:46+00:00" + "time": "2021-10-05T19:02:38+00:00" }, { "name": "psr/container", @@ -3879,22 +3929,22 @@ }, { "name": "ramsey/uuid", - "version": "3.9.4", + "version": "3.9.6", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "be2451bef8147b7352a28fb4cddb08adc497ada3" + "reference": "ffa80ab953edd85d5b6c004f96181a538aad35a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/be2451bef8147b7352a28fb4cddb08adc497ada3", - "reference": "be2451bef8147b7352a28fb4cddb08adc497ada3", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/ffa80ab953edd85d5b6c004f96181a538aad35a3", + "reference": "ffa80ab953edd85d5b6c004f96181a538aad35a3", "shasum": "" }, "require": { "ext-json": "*", "paragonie/random_compat": "^1 | ^2 | ^9.99.99", - "php": "^5.4 | ^7 | ^8", + "php": "^5.4 | ^7.0 | ^8.0", "symfony/polyfill-ctype": "^1.8" }, "replace": { @@ -3903,14 +3953,16 @@ "require-dev": { "codeception/aspect-mock": "^1 | ^2", "doctrine/annotations": "^1.2", - "goaop/framework": "1.0.0-alpha.2 | ^1 | ^2.1", - "jakub-onderka/php-parallel-lint": "^1", + "goaop/framework": "1.0.0-alpha.2 | ^1 | >=2.1.0 <=2.3.2", "mockery/mockery": "^0.9.11 | ^1", "moontoast/math": "^1.1", + "nikic/php-parser": "<=4.5.0", "paragonie/random-lib": "^2", - "php-mock/php-mock-phpunit": "^0.3 | ^1.1", - "phpunit/phpunit": "^4.8 | ^5.4 | ^6.5", - "squizlabs/php_codesniffer": "^3.5" + "php-mock/php-mock-phpunit": "^0.3 | ^1.1 | ^2.6", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpunit/phpunit": ">=4.8.36 <9.0.0 | >=9.3.0", + "squizlabs/php_codesniffer": "^3.5", + "yoast/phpunit-polyfills": "^1.0" }, "suggest": { "ext-ctype": "Provides support for PHP Ctype functions", @@ -3978,7 +4030,7 @@ "type": "tidelift" } ], - "time": "2021-08-06T20:32:15+00:00" + "time": "2021-09-25T23:07:42+00:00" }, { "name": "robrichards/xmlseclibs", @@ -4553,59 +4605,11 @@ } ], "description": "OAuth2 OpenID Connect Client that utilizes the PHP Leagues OAuth2 Client", - "time": "2020-05-19T23:06:36+00:00" - }, - { - "name": "swiftmailer/swiftmailer", - "version": "v6.2.3", - "source": { - "type": "git", - "url": "https://github.com/ssddanbrown/HtmlDiff.git", - "reference": "f60d5cc278b60305ab980a6665f46117c5b589c0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/ssddanbrown/HtmlDiff/zipball/f60d5cc278b60305ab980a6665f46117c5b589c0", - "reference": "f60d5cc278b60305ab980a6665f46117c5b589c0", - "shasum": "" - }, - "require": { - "ext-mbstring": "*", - "php": ">=7.2" - }, - "require-dev": { - "phpunit/phpunit": "^8.5|^9.4.3" - }, - "type": "library", - "autoload": { - "psr-4": { - "Ssddanbrown\\HtmlDiff\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Dan Brown", - "email": "ssddanbrown@googlemail.com", - "role": "Developer" - } - ], - "description": "HTML Content Diff Generator", - "homepage": "https://github.com/ssddanbrown/htmldiff", "support": { - "issues": "https://github.com/ssddanbrown/HtmlDiff/issues", - "source": "https://github.com/ssddanbrown/HtmlDiff/tree/v1.0.1" + "issues": "https://github.com/steverhoades/oauth2-openid-connect-client/issues", + "source": "https://github.com/steverhoades/oauth2-openid-connect-client/tree/master" }, - "funding": [ - { - "url": "https://github.com/ssddanbrown", - "type": "github" - } - ], - "time": "2021-01-24T18:51:30+00:00" + "time": "2020-05-19T23:06:36+00:00" }, { "name": "swiftmailer/swiftmailer", @@ -4840,16 +4844,16 @@ }, { "name": "symfony/debug", - "version": "v4.4.27", + "version": "v4.4.31", "source": { "type": "git", "url": "https://github.com/symfony/debug.git", - "reference": "2f9160e92eb64c95da7368c867b663a8e34e980c" + "reference": "43ede438d4cb52cd589ae5dc070e9323866ba8e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/debug/zipball/2f9160e92eb64c95da7368c867b663a8e34e980c", - "reference": "2f9160e92eb64c95da7368c867b663a8e34e980c", + "url": "https://api.github.com/repos/symfony/debug/zipball/43ede438d4cb52cd589ae5dc070e9323866ba8e0", + "reference": "43ede438d4cb52cd589ae5dc070e9323866ba8e0", "shasum": "" }, "require": { @@ -4888,7 +4892,7 @@ "description": "Provides tools to ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/debug/tree/v4.4.27" + "source": "https://github.com/symfony/debug/tree/v4.4.31" }, "funding": [ { @@ -4904,7 +4908,7 @@ "type": "tidelift" } ], - "time": "2021-07-22T07:21:39+00:00" + "time": "2021-09-24T13:30:14+00:00" }, { "name": "symfony/deprecation-contracts", @@ -5414,16 +5418,16 @@ }, { "name": "symfony/http-kernel", - "version": "v4.4.30", + "version": "v4.4.32", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "87f7ea4a8a7a30c967e26001de99f12943bf57ae" + "reference": "f7bda3ea8f05ae90627400e58af5179b25ce0f38" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/87f7ea4a8a7a30c967e26001de99f12943bf57ae", - "reference": "87f7ea4a8a7a30c967e26001de99f12943bf57ae", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/f7bda3ea8f05ae90627400e58af5179b25ce0f38", + "reference": "f7bda3ea8f05ae90627400e58af5179b25ce0f38", "shasum": "" }, "require": { @@ -5498,7 +5502,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v4.4.30" + "source": "https://github.com/symfony/http-kernel/tree/v4.4.32" }, "funding": [ { @@ -5514,20 +5518,20 @@ "type": "tidelift" } ], - "time": "2021-08-30T12:27:20+00:00" + "time": "2021-09-28T10:20:04+00:00" }, { "name": "symfony/mime", - "version": "v5.3.7", + "version": "v5.3.8", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "ae887cb3b044658676129f5e97aeb7e9eb69c2d8" + "reference": "a756033d0a7e53db389618653ae991eba5a19a11" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/ae887cb3b044658676129f5e97aeb7e9eb69c2d8", - "reference": "ae887cb3b044658676129f5e97aeb7e9eb69c2d8", + "url": "https://api.github.com/repos/symfony/mime/zipball/a756033d0a7e53db389618653ae991eba5a19a11", + "reference": "a756033d0a7e53db389618653ae991eba5a19a11", "shasum": "" }, "require": { @@ -5581,7 +5585,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v5.3.7" + "source": "https://github.com/symfony/mime/tree/v5.3.8" }, "funding": [ { @@ -5597,7 +5601,7 @@ "type": "tidelift" } ], - "time": "2021-08-20T11:40:01+00:00" + "time": "2021-09-10T12:30:38+00:00" }, { "name": "symfony/polyfill-ctype", @@ -6479,7 +6483,7 @@ }, { "name": "symfony/translation", - "version": "v4.4.30", + "version": "v4.4.32", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", @@ -6548,7 +6552,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v4.4.30" + "source": "https://github.com/symfony/translation/tree/v4.4.32" }, "funding": [ { @@ -6646,16 +6650,16 @@ }, { "name": "symfony/var-dumper", - "version": "v4.4.30", + "version": "v4.4.31", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "7f65c44c2ce80d3a0fcdb6385ee0ad535e45660c" + "reference": "1f12cc0c2e880a5f39575c19af81438464717839" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/7f65c44c2ce80d3a0fcdb6385ee0ad535e45660c", - "reference": "7f65c44c2ce80d3a0fcdb6385ee0ad535e45660c", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/1f12cc0c2e880a5f39575c19af81438464717839", + "reference": "1f12cc0c2e880a5f39575c19af81438464717839", "shasum": "" }, "require": { @@ -6715,7 +6719,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v4.4.30" + "source": "https://github.com/symfony/var-dumper/tree/v4.4.31" }, "funding": [ { @@ -6731,7 +6735,7 @@ "type": "tidelift" } ], - "time": "2021-08-04T20:31:23+00:00" + "time": "2021-09-24T15:30:11+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -6788,16 +6792,16 @@ }, { "name": "vlucas/phpdotenv", - "version": "v3.6.8", + "version": "v3.6.9", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "5e679f7616db829358341e2d5cccbd18773bdab8" + "reference": "a1bf4c9853d90ade427b4efe35355fc41b3d6988" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/5e679f7616db829358341e2d5cccbd18773bdab8", - "reference": "5e679f7616db829358341e2d5cccbd18773bdab8", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/a1bf4c9853d90ade427b4efe35355fc41b3d6988", + "reference": "a1bf4c9853d90ade427b4efe35355fc41b3d6988", "shasum": "" }, "require": { @@ -6808,7 +6812,7 @@ "require-dev": { "ext-filter": "*", "ext-pcre": "*", - "phpunit/phpunit": "^4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20" + "phpunit/phpunit": "^4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.21" }, "suggest": { "ext-filter": "Required to use the boolean validator.", @@ -6832,13 +6836,11 @@ "authors": [ { "name": "Graham Campbell", - "email": "graham@alt-three.com", - "homepage": "https://gjcampbell.co.uk/" + "email": "hello@gjcampbell.co.uk" }, { "name": "Vance Lucas", - "email": "vance@vancelucas.com", - "homepage": "https://vancelucas.com/" + "email": "vance@vancelucas.com" } ], "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", @@ -6849,7 +6851,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v3.6.8" + "source": "https://github.com/vlucas/phpdotenv/tree/v3.6.9" }, "funding": [ { @@ -6861,7 +6863,7 @@ "type": "tidelift" } ], - "time": "2021-01-20T14:39:46+00:00" + "time": "2021-10-02T19:07:56+00:00" } ], "packages-dev": [ @@ -7090,16 +7092,16 @@ }, { "name": "composer/ca-bundle", - "version": "1.2.10", + "version": "1.2.11", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "9fdb22c2e97a614657716178093cd1da90a64aa8" + "reference": "0b072d51c5a9c6f3412f7ea3ab043d6603cb2582" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/9fdb22c2e97a614657716178093cd1da90a64aa8", - "reference": "9fdb22c2e97a614657716178093cd1da90a64aa8", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/0b072d51c5a9c6f3412f7ea3ab043d6603cb2582", + "reference": "0b072d51c5a9c6f3412f7ea3ab043d6603cb2582", "shasum": "" }, "require": { @@ -7111,7 +7113,7 @@ "phpstan/phpstan": "^0.12.55", "psr/log": "^1.0", "symfony/phpunit-bridge": "^4.2 || ^5", - "symfony/process": "^2.5 || ^3.0 || ^4.0 || ^5.0" + "symfony/process": "^2.5 || ^3.0 || ^4.0 || ^5.0 || ^6.0" }, "type": "library", "extra": { @@ -7146,7 +7148,7 @@ "support": { "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/ca-bundle/issues", - "source": "https://github.com/composer/ca-bundle/tree/1.2.10" + "source": "https://github.com/composer/ca-bundle/tree/1.2.11" }, "funding": [ { @@ -7162,20 +7164,20 @@ "type": "tidelift" } ], - "time": "2021-06-07T13:58:28+00:00" + "time": "2021-09-25T20:32:43+00:00" }, { "name": "composer/composer", - "version": "2.1.8", + "version": "2.1.9", "source": { "type": "git", "url": "https://github.com/composer/composer.git", - "reference": "24d38e9686092de05214cafa187dc282a5d89497" + "reference": "e558c88f28d102d497adec4852802c0dc14c7077" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/composer/zipball/24d38e9686092de05214cafa187dc282a5d89497", - "reference": "24d38e9686092de05214cafa187dc282a5d89497", + "url": "https://api.github.com/repos/composer/composer/zipball/e558c88f28d102d497adec4852802c0dc14c7077", + "reference": "e558c88f28d102d497adec4852802c0dc14c7077", "shasum": "" }, "require": { @@ -7244,7 +7246,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/composer/issues", - "source": "https://github.com/composer/composer/tree/2.1.8" + "source": "https://github.com/composer/composer/tree/2.1.9" }, "funding": [ { @@ -7260,7 +7262,7 @@ "type": "tidelift" } ], - "time": "2021-09-15T11:55:15+00:00" + "time": "2021-10-05T07:47:38+00:00" }, { "name": "composer/metadata-minifier", @@ -8007,16 +8009,16 @@ }, { "name": "nikic/php-parser", - "version": "v4.12.0", + "version": "v4.13.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "6608f01670c3cc5079e18c1dab1104e002579143" + "reference": "50953a2691a922aa1769461637869a0a2faa3f53" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/6608f01670c3cc5079e18c1dab1104e002579143", - "reference": "6608f01670c3cc5079e18c1dab1104e002579143", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/50953a2691a922aa1769461637869a0a2faa3f53", + "reference": "50953a2691a922aa1769461637869a0a2faa3f53", "shasum": "" }, "require": { @@ -8057,9 +8059,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.12.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v4.13.0" }, - "time": "2021-07-21T10:44:31+00:00" + "time": "2021-09-20T12:20:58+00:00" }, { "name": "phar-io/manifest", @@ -8283,16 +8285,16 @@ }, { "name": "phpdocumentor/type-resolver", - "version": "1.5.0", + "version": "1.5.1", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "30f38bffc6f24293dadd1823936372dfa9e86e2f" + "reference": "a12f7e301eb7258bb68acd89d4aefa05c2906cae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/30f38bffc6f24293dadd1823936372dfa9e86e2f", - "reference": "30f38bffc6f24293dadd1823936372dfa9e86e2f", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/a12f7e301eb7258bb68acd89d4aefa05c2906cae", + "reference": "a12f7e301eb7258bb68acd89d4aefa05c2906cae", "shasum": "" }, "require": { @@ -8327,9 +8329,9 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.5.0" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.5.1" }, - "time": "2021-09-17T15:28:14+00:00" + "time": "2021-10-02T14:08:47+00:00" }, { "name": "phpspec/prophecy", @@ -8718,16 +8720,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.5.9", + "version": "9.5.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "ea8c2dfb1065eb35a79b3681eee6e6fb0a6f273b" + "reference": "c814a05837f2edb0d1471d6e3f4ab3501ca3899a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ea8c2dfb1065eb35a79b3681eee6e6fb0a6f273b", - "reference": "ea8c2dfb1065eb35a79b3681eee6e6fb0a6f273b", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c814a05837f2edb0d1471d6e3f4ab3501ca3899a", + "reference": "c814a05837f2edb0d1471d6e3f4ab3501ca3899a", "shasum": "" }, "require": { @@ -8743,7 +8745,7 @@ "phar-io/version": "^3.0.2", "php": ">=7.3", "phpspec/prophecy": "^1.12.1", - "phpunit/php-code-coverage": "^9.2.3", + "phpunit/php-code-coverage": "^9.2.7", "phpunit/php-file-iterator": "^3.0.5", "phpunit/php-invoker": "^3.1.1", "phpunit/php-text-template": "^2.0.3", @@ -8805,7 +8807,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.9" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.10" }, "funding": [ { @@ -8817,7 +8819,7 @@ "type": "github" } ], - "time": "2021-08-31T06:47:40+00:00" + "time": "2021-09-25T07:38:51+00:00" }, { "name": "react/promise", From 41438adbd1dbe8688ff8ff7d3dbb835d1e9650e1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 6 Oct 2021 23:05:26 +0100 Subject: [PATCH 18/50] Continued review of #2169 - Removed uneeded custom refresh or logout actions for OIDC. - Restructured how the services and guards are setup for external auth systems. SAML2 and OIDC now directly share a lot more logic. - Renamed any OpenId references to OIDC or OpenIdConnect - Removed non-required CSRF excemption for OIDC Not tested, Come to roadblock due to lack of PHP8 support in upstream dependancies. Certificate was deemed to be non-valid on every test attempt due to changes in PHP8. --- ...alAuthService.php => GroupSyncService.php} | 46 +--- ....php => AsyncExternalBaseSessionGuard.php} | 2 +- app/Auth/Access/Guards/OpenIdSessionGuard.php | 79 ------ app/Auth/Access/LdapService.php | 16 +- app/Auth/Access/OpenIdConnectService.php | 164 +++++++++++ app/Auth/Access/OpenIdService.php | 257 ------------------ app/Auth/Access/RegistrationService.php | 26 ++ app/Auth/Access/Saml2Service.php | 36 ++- app/Config/auth.php | 10 +- app/Exceptions/OpenIdConnectException.php | 6 + app/Exceptions/OpenIdException.php | 6 - .../Auth/OpenIdConnectController.php | 56 ++++ .../Controllers/Auth/OpenIdController.php | 70 ----- app/Http/Middleware/VerifyCsrfToken.php | 1 - app/Providers/AuthServiceProvider.php | 19 +- resources/icons/oidc.svg | 4 + resources/lang/en/errors.php | 8 +- .../auth/parts/login-form-oidc.blade.php | 11 + .../auth/parts/login-form-openid.blade.php | 11 - resources/views/common/header.blade.php | 2 - resources/views/settings/index.blade.php | 2 +- .../views/settings/roles/parts/form.blade.php | 2 +- resources/views/users/parts/form.blade.php | 2 +- routes/web.php | 7 +- 24 files changed, 319 insertions(+), 524 deletions(-) rename app/Auth/Access/{ExternalAuthService.php => GroupSyncService.php} (63%) rename app/Auth/Access/Guards/{Saml2SessionGuard.php => AsyncExternalBaseSessionGuard.php} (92%) delete mode 100644 app/Auth/Access/Guards/OpenIdSessionGuard.php create mode 100644 app/Auth/Access/OpenIdConnectService.php delete mode 100644 app/Auth/Access/OpenIdService.php create mode 100644 app/Exceptions/OpenIdConnectException.php delete mode 100644 app/Exceptions/OpenIdException.php create mode 100644 app/Http/Controllers/Auth/OpenIdConnectController.php delete mode 100644 app/Http/Controllers/Auth/OpenIdController.php create mode 100644 resources/icons/oidc.svg create mode 100644 resources/views/auth/parts/login-form-oidc.blade.php delete mode 100644 resources/views/auth/parts/login-form-openid.blade.php diff --git a/app/Auth/Access/ExternalAuthService.php b/app/Auth/Access/GroupSyncService.php similarity index 63% rename from app/Auth/Access/ExternalAuthService.php rename to app/Auth/Access/GroupSyncService.php index b2b9302af..ddd539b77 100644 --- a/app/Auth/Access/ExternalAuthService.php +++ b/app/Auth/Access/GroupSyncService.php @@ -4,48 +4,10 @@ namespace BookStack\Auth\Access; use BookStack\Auth\Role; use BookStack\Auth\User; -use BookStack\Exceptions\UserRegistrationException; use Illuminate\Support\Collection; -use Illuminate\Support\Str; -class ExternalAuthService +class GroupSyncService { - protected $registrationService; - protected $user; - - /** - * ExternalAuthService base constructor. - */ - public function __construct(RegistrationService $registrationService, User $user) - { - $this->registrationService = $registrationService; - $this->user = $user; - } - - /** - * Get the user from the database for the specified details. - * @throws UserRegistrationException - */ - protected function getOrRegisterUser(array $userDetails): ?User - { - $user = User::query() - ->where('external_auth_id', '=', $userDetails['external_id']) - ->first(); - - if (is_null($user)) { - $userData = [ - 'name' => $userDetails['name'], - 'email' => $userDetails['email'], - 'password' => Str::random(32), - 'external_auth_id' => $userDetails['external_id'], - ]; - - $user = $this->registrationService->registerUser($userData, null, false); - } - - return $user; - } - /** * Check a role against an array of group names to see if it matches. * Checked against role 'external_auth_id' if set otherwise the name of the role. @@ -98,17 +60,17 @@ class ExternalAuthService /** * Sync the groups to the user roles for the current user. */ - public function syncWithGroups(User $user, array $userGroups): void + public function syncUserWithFoundGroups(User $user, array $userGroups, bool $detachExisting): void { // Get the ids for the roles from the names $groupsAsRoles = $this->matchGroupsToSystemsRoles($userGroups); // Sync groups - if ($this->config['remove_from_groups']) { + if ($detachExisting) { $user->roles()->sync($groupsAsRoles); $user->attachDefaultRole(); } else { $user->roles()->syncWithoutDetaching($groupsAsRoles); } } -} +} \ No newline at end of file diff --git a/app/Auth/Access/Guards/Saml2SessionGuard.php b/app/Auth/Access/Guards/AsyncExternalBaseSessionGuard.php similarity index 92% rename from app/Auth/Access/Guards/Saml2SessionGuard.php rename to app/Auth/Access/Guards/AsyncExternalBaseSessionGuard.php index eacd5d21e..6677f5b10 100644 --- a/app/Auth/Access/Guards/Saml2SessionGuard.php +++ b/app/Auth/Access/Guards/AsyncExternalBaseSessionGuard.php @@ -10,7 +10,7 @@ namespace BookStack\Auth\Access\Guards; * via the Saml2 controller & Saml2Service. This class provides a safer, thin * version of SessionGuard. */ -class Saml2SessionGuard extends ExternalBaseSessionGuard +class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard { /** * Validate a user's credentials. diff --git a/app/Auth/Access/Guards/OpenIdSessionGuard.php b/app/Auth/Access/Guards/OpenIdSessionGuard.php deleted file mode 100644 index 634464493..000000000 --- a/app/Auth/Access/Guards/OpenIdSessionGuard.php +++ /dev/null @@ -1,79 +0,0 @@ -openidService = $openidService; - parent::__construct($name, $provider, $session, $registrationService); - } - - /** - * Get the currently authenticated user. - * - * @return \Illuminate\Contracts\Auth\Authenticatable|null - */ - public function user() - { - // retrieve the current user - $user = parent::user(); - - // refresh the current user - if ($user && !$this->openidService->refresh()) { - $this->user = null; - } - - return $this->user; - } - - /** - * Validate a user's credentials. - * - * @param array $credentials - * @return bool - */ - public function validate(array $credentials = []) - { - return false; - } - - /** - * Attempt to authenticate a user using the given credentials. - * - * @param array $credentials - * @param bool $remember - * @return bool - */ - public function attempt(array $credentials = [], $remember = false) - { - return false; - } -} diff --git a/app/Auth/Access/LdapService.php b/app/Auth/Access/LdapService.php index 7bfdb5328..ddd6ada97 100644 --- a/app/Auth/Access/LdapService.php +++ b/app/Auth/Access/LdapService.php @@ -13,9 +13,10 @@ use Illuminate\Support\Facades\Log; * Class LdapService * Handles any app-specific LDAP tasks. */ -class LdapService extends ExternalAuthService +class LdapService { protected $ldap; + protected $groupSyncService; protected $ldapConnection; protected $userAvatars; protected $config; @@ -24,20 +25,19 @@ class LdapService extends ExternalAuthService /** * LdapService constructor. */ - public function __construct(Ldap $ldap, UserAvatars $userAvatars) + public function __construct(Ldap $ldap, UserAvatars $userAvatars, GroupSyncService $groupSyncService) { $this->ldap = $ldap; $this->userAvatars = $userAvatars; + $this->groupSyncService = $groupSyncService; $this->config = config('services.ldap'); $this->enabled = config('auth.method') === 'ldap'; } /** * Check if groups should be synced. - * - * @return bool */ - public function shouldSyncGroups() + public function shouldSyncGroups(): bool { return $this->enabled && $this->config['user_to_groups'] !== false; } @@ -285,9 +285,7 @@ class LdapService extends ExternalAuthService } $userGroups = $this->groupFilter($user); - $userGroups = $this->getGroupsRecursive($userGroups, []); - - return $userGroups; + return $this->getGroupsRecursive($userGroups, []); } /** @@ -374,7 +372,7 @@ class LdapService extends ExternalAuthService public function syncGroups(User $user, string $username) { $userLdapGroups = $this->getUserGroups($username); - $this->syncWithGroups($user, $userLdapGroups); + $this->groupSyncService->syncUserWithFoundGroups($user, $userLdapGroups, $this->config['remove_from_groups']); } /** diff --git a/app/Auth/Access/OpenIdConnectService.php b/app/Auth/Access/OpenIdConnectService.php new file mode 100644 index 000000000..2548aee6e --- /dev/null +++ b/app/Auth/Access/OpenIdConnectService.php @@ -0,0 +1,164 @@ +config = config('oidc'); + $this->registrationService = $registrationService; + $this->loginService = $loginService; + } + + /** + * Initiate an authorization flow. + * @return array{url: string, state: string} + */ + public function login(): array + { + $provider = $this->getProvider(); + return [ + 'url' => $provider->getAuthorizationUrl(), + 'state' => $provider->getState(), + ]; + } + + /** + * Process the Authorization response from the authorization server and + * return the matching, or new if registration active, user matched to + * the authorization server. + * Returns null if not authenticated. + * @throws Exception + */ + public function processAuthorizeResponse(?string $authorizationCode): ?User + { + $provider = $this->getProvider(); + + // Try to exchange authorization code for access token + $accessToken = $provider->getAccessToken('authorization_code', [ + 'code' => $authorizationCode, + ]); + + return $this->processAccessTokenCallback($accessToken); + } + + /** + * Load the underlying OpenID Connect Provider. + */ + protected function getProvider(): OpenIDConnectProvider + { + // Setup settings + $settings = [ + 'clientId' => $this->config['client_id'], + 'clientSecret' => $this->config['client_secret'], + 'idTokenIssuer' => $this->config['issuer'], + 'redirectUri' => url('/oidc/redirect'), + 'urlAuthorize' => $this->config['authorization_endpoint'], + 'urlAccessToken' => $this->config['token_endpoint'], + 'urlResourceOwnerDetails' => null, + 'publicKey' => $this->config['jwt_public_key'], + 'scopes' => 'profile email', + ]; + + // Setup services + $services = [ + 'signer' => new Sha256(), + ]; + + return new OpenIDConnectProvider($settings, $services); + } + + /** + * Calculate the display name + */ + protected function getUserDisplayName(Token $token, string $defaultValue): string + { + $displayNameAttr = $this->config['display_name_claims']; + + $displayName = []; + foreach ($displayNameAttr as $dnAttr) { + $dnComponent = $token->claims()->get($dnAttr, ''); + if ($dnComponent !== '') { + $displayName[] = $dnComponent; + } + } + + if (count($displayName) == 0) { + $displayName[] = $defaultValue; + } + + return implode(' ', $displayName); + } + + /** + * Extract the details of a user from an ID token. + * @return array{name: string, email: string, external_id: string} + */ + protected function getUserDetails(Token $token): array + { + $id = $token->claims()->get('sub'); + return [ + 'external_id' => $id, + 'email' => $token->claims()->get('email'), + 'name' => $this->getUserDisplayName($token, $id), + ]; + } + + /** + * Processes a received access token for a user. Login the user when + * they exist, optionally registering them automatically. + * @throws OpenIdConnectException + * @throws JsonDebugException + * @throws UserRegistrationException + * @throws StoppedAuthenticationException + */ + protected function processAccessTokenCallback(AccessToken $accessToken): User + { + $userDetails = $this->getUserDetails($accessToken->getIdToken()); + $isLoggedIn = auth()->check(); + + if ($this->config['dump_user_details']) { + throw new JsonDebugException($accessToken->jsonSerialize()); + } + + if ($userDetails['email'] === null) { + throw new OpenIdConnectException(trans('errors.oidc_no_email_address')); + } + + if ($isLoggedIn) { + throw new OpenIdConnectException(trans('errors.oidc_already_logged_in'), '/login'); + } + + $user = $this->registrationService->findOrRegister( + $userDetails['name'], $userDetails['email'], $userDetails['external_id'] + ); + + if ($user === null) { + throw new OpenIdConnectException(trans('errors.oidc_user_not_registered', ['name' => $userDetails['external_id']]), '/login'); + } + + $this->loginService->login($user, 'oidc'); + return $user; + } +} diff --git a/app/Auth/Access/OpenIdService.php b/app/Auth/Access/OpenIdService.php deleted file mode 100644 index f56ff0b91..000000000 --- a/app/Auth/Access/OpenIdService.php +++ /dev/null @@ -1,257 +0,0 @@ -config = config('oidc'); - } - - /** - * Initiate an authorization flow. - * @throws Exception - */ - public function login(): array - { - $provider = $this->getProvider(); - return [ - 'url' => $provider->getAuthorizationUrl(), - 'state' => $provider->getState(), - ]; - } - - /** - * Initiate a logout flow. - */ - public function logout(): array - { - $this->actionLogout(); - $url = '/'; - $id = null; - - return ['url' => $url, 'id' => $id]; - } - - /** - * Refresh the currently logged in user. - * @throws Exception - */ - public function refresh(): bool - { - // Retrieve access token for current session - $json = session()->get('openid_token'); - - // If no access token was found, reject the refresh - if (!$json) { - $this->actionLogout(); - return false; - } - - $accessToken = new AccessToken(json_decode($json, true) ?? []); - - // If the token is not expired, refreshing isn't necessary - if ($this->isUnexpired($accessToken)) { - return true; - } - - // Try to obtain refreshed access token - try { - $newAccessToken = $this->refreshAccessToken($accessToken); - } catch (Exception $e) { - // Log out if an unknown problem arises - $this->actionLogout(); - throw $e; - } - - // If a token was obtained, update the access token, otherwise log out - if ($newAccessToken !== null) { - session()->put('openid_token', json_encode($newAccessToken)); - return true; - } else { - $this->actionLogout(); - return false; - } - } - - /** - * Check whether an access token or OpenID token isn't expired. - */ - protected function isUnexpired(AccessToken $accessToken): bool - { - $idToken = $accessToken->getIdToken(); - - $accessTokenUnexpired = $accessToken->getExpires() && !$accessToken->hasExpired(); - $idTokenUnexpired = !$idToken || !$idToken->isExpired(); - - return $accessTokenUnexpired && $idTokenUnexpired; - } - - /** - * Generate an updated access token, through the associated refresh token. - * @throws Exception - */ - protected function refreshAccessToken(AccessToken $accessToken): ?AccessToken - { - // If no refresh token available, abort - if ($accessToken->getRefreshToken() === null) { - return null; - } - - // ID token or access token is expired, we refresh it using the refresh token - try { - return $this->getProvider()->getAccessToken('refresh_token', [ - 'refresh_token' => $accessToken->getRefreshToken(), - ]); - } catch (IdentityProviderException $e) { - // Refreshing failed - return null; - } - } - - /** - * Process the Authorization response from the authorization server and - * return the matching, or new if registration active, user matched to - * the authorization server. - * Returns null if not authenticated. - * @throws Exception - * @throws InvalidTokenException - */ - public function processAuthorizeResponse(?string $authorizationCode): ?User - { - $provider = $this->getProvider(); - - // Try to exchange authorization code for access token - $accessToken = $provider->getAccessToken('authorization_code', [ - 'code' => $authorizationCode, - ]); - - return $this->processAccessTokenCallback($accessToken); - } - - /** - * Do the required actions to log a user out. - */ - protected function actionLogout() - { - auth()->logout(); - session()->invalidate(); - } - - /** - * Load the underlying OpenID Connect Provider. - */ - protected function getProvider(): OpenIDConnectProvider - { - // Setup settings - $settings = [ - 'clientId' => $this->config['client_id'], - 'clientSecret' => $this->config['client_secret'], - 'idTokenIssuer' => $this->config['issuer'], - 'redirectUri' => url('/openid/redirect'), - 'urlAuthorize' => $this->config['authorization_endpoint'], - 'urlAccessToken' => $this->config['token_endpoint'], - 'urlResourceOwnerDetails' => null, - 'publicKey' => $this->config['jwt_public_key'], - 'scopes' => 'profile email', - ]; - - // Setup services - $services = [ - 'signer' => new Sha256(), - ]; - - return new OpenIDConnectProvider($settings, $services); - } - - /** - * Calculate the display name - */ - protected function getUserDisplayName(Token $token, string $defaultValue): string - { - $displayNameAttr = $this->config['display_name_claims']; - - $displayName = []; - foreach ($displayNameAttr as $dnAttr) { - $dnComponent = $token->claims()->get($dnAttr, ''); - if ($dnComponent !== '') { - $displayName[] = $dnComponent; - } - } - - if (count($displayName) == 0) { - $displayName[] = $defaultValue; - } - - return implode(' ', $displayName);; - } - - /** - * Extract the details of a user from an ID token. - */ - protected function getUserDetails(Token $token): array - { - $id = $token->claims()->get('sub'); - return [ - 'external_id' => $id, - 'email' => $token->claims()->get('email'), - 'name' => $this->getUserDisplayName($token, $id), - ]; - } - - /** - * Processes a received access token for a user. Login the user when - * they exist, optionally registering them automatically. - * @throws OpenIdException - * @throws JsonDebugException - * @throws UserRegistrationException - */ - public function processAccessTokenCallback(AccessToken $accessToken): User - { - $userDetails = $this->getUserDetails($accessToken->getIdToken()); - $isLoggedIn = auth()->check(); - - if ($this->config['dump_user_details']) { - throw new JsonDebugException($accessToken->jsonSerialize()); - } - - if ($userDetails['email'] === null) { - throw new OpenIdException(trans('errors.openid_no_email_address')); - } - - if ($isLoggedIn) { - throw new OpenIdException(trans('errors.openid_already_logged_in'), '/login'); - } - - $user = $this->getOrRegisterUser($userDetails); - if ($user === null) { - throw new OpenIdException(trans('errors.openid_user_not_registered', ['name' => $userDetails['external_id']]), '/login'); - } - - auth()->login($user); - session()->put('openid_token', json_encode($accessToken)); - return $user; - } -} diff --git a/app/Auth/Access/RegistrationService.php b/app/Auth/Access/RegistrationService.php index 16e3edbb4..48970bd2e 100644 --- a/app/Auth/Access/RegistrationService.php +++ b/app/Auth/Access/RegistrationService.php @@ -11,6 +11,7 @@ use BookStack\Facades\Activity; use BookStack\Facades\Theme; use BookStack\Theming\ThemeEvents; use Exception; +use Illuminate\Support\Str; class RegistrationService { @@ -50,6 +51,31 @@ class RegistrationService return in_array($authMethod, $authMethodsWithRegistration) && setting('registration-enabled'); } + /** + * Attempt to find a user in the system otherwise register them as a new + * user. For use with external auth systems since password is auto-generated. + * @throws UserRegistrationException + */ + public function findOrRegister(string $name, string $email, string $externalId): User + { + $user = User::query() + ->where('external_auth_id', '=', $externalId) + ->first(); + + if (is_null($user)) { + $userData = [ + 'name' => $name, + 'email' => $email, + 'password' => Str::random(32), + 'external_auth_id' => $externalId, + ]; + + $user = $this->registerUser($userData, null, false); + } + + return $user; + } + /** * The registrations flow for all users. * diff --git a/app/Auth/Access/Saml2Service.php b/app/Auth/Access/Saml2Service.php index b1489fbce..8e076f86c 100644 --- a/app/Auth/Access/Saml2Service.php +++ b/app/Auth/Access/Saml2Service.php @@ -17,22 +17,26 @@ use OneLogin\Saml2\ValidationError; * Class Saml2Service * Handles any app-specific SAML tasks. */ -class Saml2Service extends ExternalAuthService +class Saml2Service { protected $config; protected $registrationService; protected $loginService; + protected $groupSyncService; /** * Saml2Service constructor. */ - public function __construct(RegistrationService $registrationService, LoginService $loginService, User $user) + public function __construct( + RegistrationService $registrationService, + LoginService $loginService, + GroupSyncService $groupSyncService + ) { - parent::__construct($registrationService, $user); - $this->config = config('saml2'); $this->registrationService = $registrationService; $this->loginService = $loginService; + $this->groupSyncService = $groupSyncService; } /** @@ -47,7 +51,7 @@ class Saml2Service extends ExternalAuthService return [ 'url' => $toolKit->login($returnRoute, [], false, false, true), - 'id' => $toolKit->getLastRequestID(), + 'id' => $toolKit->getLastRequestID(), ]; } @@ -196,7 +200,7 @@ class Saml2Service extends ExternalAuthService protected function loadOneloginServiceProviderDetails(): array { $spDetails = [ - 'entityId' => url('/saml2/metadata'), + 'entityId' => url('/saml2/metadata'), 'assertionConsumerService' => [ 'url' => url('/saml2/acs'), ], @@ -207,7 +211,7 @@ class Saml2Service extends ExternalAuthService return [ 'baseurl' => url('/saml2'), - 'sp' => $spDetails, + 'sp' => $spDetails, ]; } @@ -259,6 +263,7 @@ class Saml2Service extends ExternalAuthService /** * Extract the details of a user from a SAML response. + * @return array{external_id: string, name: string, email: string, saml_id: string} */ protected function getUserDetails(string $samlID, $samlAttributes): array { @@ -270,9 +275,9 @@ class Saml2Service extends ExternalAuthService return [ 'external_id' => $externalId, - 'name' => $this->getUserDisplayName($samlAttributes, $externalId), - 'email' => $email, - 'saml_id' => $samlID, + 'name' => $this->getUserDisplayName($samlAttributes, $externalId), + 'email' => $email, + 'saml_id' => $samlID, ]; } @@ -339,8 +344,8 @@ class Saml2Service extends ExternalAuthService if ($this->config['dump_user_details']) { throw new JsonDebugException([ - 'id_from_idp' => $samlID, - 'attrs_from_idp' => $samlAttributes, + 'id_from_idp' => $samlID, + 'attrs_from_idp' => $samlAttributes, 'attrs_after_parsing' => $userDetails, ]); } @@ -353,14 +358,17 @@ class Saml2Service extends ExternalAuthService throw new SamlException(trans('errors.saml_already_logged_in'), '/login'); } - $user = $this->getOrRegisterUser($userDetails); + $user = $this->registrationService->findOrRegister( + $userDetails['name'], $userDetails['email'], $userDetails['external_id'] + ); + if ($user === null) { throw new SamlException(trans('errors.saml_user_not_registered', ['name' => $userDetails['external_id']]), '/login'); } if ($this->shouldSyncGroups()) { $groups = $this->getUserGroups($samlAttributes); - $this->syncWithGroups($user, $groups); + $this->groupSyncService->syncUserWithFoundGroups($user, $groups, $this->config['remove_from_groups']); } $this->loginService->login($user, 'saml2'); diff --git a/app/Config/auth.php b/app/Config/auth.php index 5b39bafed..5a5e727e5 100644 --- a/app/Config/auth.php +++ b/app/Config/auth.php @@ -11,7 +11,7 @@ return [ // Method of authentication to use - // Options: standard, ldap, saml2 + // Options: standard, ldap, saml2, oidc 'method' => env('AUTH_METHOD', 'standard'), // Authentication Defaults @@ -26,7 +26,7 @@ return [ // All authentication drivers have a user provider. This defines how the // users are actually retrieved out of your database or other storage // mechanisms used by this application to persist your user's data. - // Supported drivers: "session", "api-token", "ldap-session" + // Supported drivers: "session", "api-token", "ldap-session", "async-external-session" 'guards' => [ 'standard' => [ 'driver' => 'session', @@ -37,11 +37,11 @@ return [ 'provider' => 'external', ], 'saml2' => [ - 'driver' => 'saml2-session', + 'driver' => 'async-external-session', 'provider' => 'external', ], - 'openid' => [ - 'driver' => 'openid-session', + 'oidc' => [ + 'driver' => 'async-external-session', 'provider' => 'external', ], 'api' => [ diff --git a/app/Exceptions/OpenIdConnectException.php b/app/Exceptions/OpenIdConnectException.php new file mode 100644 index 000000000..d58585732 --- /dev/null +++ b/app/Exceptions/OpenIdConnectException.php @@ -0,0 +1,6 @@ +oidcService = $oidcService; + $this->middleware('guard:oidc'); + } + + /** + * Start the authorization login flow via OIDC. + */ + public function login() + { + $loginDetails = $this->oidcService->login(); + session()->flash('oidc_state', $loginDetails['state']); + + return redirect($loginDetails['url']); + } + + /** + * Authorization flow redirect. + * Processes authorization response from the OIDC Authorization Server. + */ + public function redirect(Request $request) + { + $storedState = session()->pull('oidc_state'); + $responseState = $request->query('state'); + + if ($storedState !== $responseState) { + $this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')])); + return redirect('/login'); + } + + $user = $this->oidcService->processAuthorizeResponse($request->query('code')); + if ($user === null) { + $this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')])); + return redirect('/login'); + } + + return redirect()->intended(); + } +} diff --git a/app/Http/Controllers/Auth/OpenIdController.php b/app/Http/Controllers/Auth/OpenIdController.php deleted file mode 100644 index 8e475ffdb..000000000 --- a/app/Http/Controllers/Auth/OpenIdController.php +++ /dev/null @@ -1,70 +0,0 @@ -openidService = $openidService; - $this->middleware('guard:openid'); - } - - /** - * Start the authorization login flow via OpenId Connect. - */ - public function login() - { - $loginDetails = $this->openidService->login(); - session()->flash('openid_state', $loginDetails['state']); - - return redirect($loginDetails['url']); - } - - /** - * Start the logout flow via OpenId Connect. - */ - public function logout() - { - $logoutDetails = $this->openidService->logout(); - - if ($logoutDetails['id']) { - session()->flash('saml2_logout_request_id', $logoutDetails['id']); - } - - return redirect($logoutDetails['url']); - } - - /** - * Authorization flow Redirect. - * Processes authorization response from the OpenId Connect Authorization Server. - */ - public function redirect() - { - $storedState = session()->pull('openid_state'); - $responseState = request()->query('state'); - - if ($storedState !== $responseState) { - $this->showErrorNotification(trans('errors.openid_fail_authed', ['system' => config('saml2.name')])); - return redirect('/login'); - } - - $user = $this->openidService->processAuthorizeResponse(request()->query('code')); - if ($user === null) { - $this->showErrorNotification(trans('errors.openid_fail_authed', ['system' => config('saml2.name')])); - return redirect('/login'); - } - - return redirect()->intended(); - } -} diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index a2e7f1dc1..804a22bc0 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -20,6 +20,5 @@ class VerifyCsrfToken extends Middleware */ protected $except = [ 'saml2/*', - 'openid/*', ]; } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index cd90cc849..bc7caa195 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -5,11 +5,9 @@ namespace BookStack\Providers; use BookStack\Api\ApiTokenGuard; use BookStack\Auth\Access\ExternalBaseUserProvider; use BookStack\Auth\Access\Guards\LdapSessionGuard; -use BookStack\Auth\Access\Guards\Saml2SessionGuard; -use BookStack\Auth\Access\Guards\OpenIdSessionGuard; +use BookStack\Auth\Access\Guards\AsyncExternalBaseSessionGuard; use BookStack\Auth\Access\LdapService; use BookStack\Auth\Access\LoginService; -use BookStack\Auth\Access\OpenIdService; use BookStack\Auth\Access\RegistrationService; use Illuminate\Support\Facades\Auth; use Illuminate\Support\ServiceProvider; @@ -39,27 +37,16 @@ class AuthServiceProvider extends ServiceProvider ); }); - Auth::extend('saml2-session', function ($app, $name, array $config) { + Auth::extend('async-external-session', function ($app, $name, array $config) { $provider = Auth::createUserProvider($config['provider']); - return new Saml2SessionGuard( + return new AsyncExternalBaseSessionGuard( $name, $provider, $app['session.store'], $app[RegistrationService::class] ); }); - - Auth::extend('openid-session', function ($app, $name, array $config) { - $provider = Auth::createUserProvider($config['provider']); - return new OpenIdSessionGuard( - $name, - $provider, - $this->app['session.store'], - $app[OpenIdService::class], - $app[RegistrationService::class] - ); - }); } /** diff --git a/resources/icons/oidc.svg b/resources/icons/oidc.svg new file mode 100644 index 000000000..a9a2994a7 --- /dev/null +++ b/resources/icons/oidc.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/resources/lang/en/errors.php b/resources/lang/en/errors.php index 44f0c25a0..f023b6bdf 100644 --- a/resources/lang/en/errors.php +++ b/resources/lang/en/errors.php @@ -23,10 +23,10 @@ return [ 'saml_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', 'saml_invalid_response_id' => 'The request from the external authentication system is not recognised by a process started by this application. Navigating back after a login could cause this issue.', 'saml_fail_authed' => 'Login using :system failed, system did not provide successful authorization', - 'openid_already_logged_in' => 'Already logged in', - 'openid_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', - 'openid_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', - 'openid_fail_authed' => 'Login using :system failed, system did not provide successful authorization', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'No action defined', 'social_login_bad_response' => "Error received during :socialAccount login: \n:error", 'social_account_in_use' => 'This :socialAccount account is already in use, Try logging in via the :socialAccount option.', diff --git a/resources/views/auth/parts/login-form-oidc.blade.php b/resources/views/auth/parts/login-form-oidc.blade.php new file mode 100644 index 000000000..e5e1b70fc --- /dev/null +++ b/resources/views/auth/parts/login-form-oidc.blade.php @@ -0,0 +1,11 @@ +
    + {!! csrf_field() !!} + +
    + +
    + +
    diff --git a/resources/views/auth/parts/login-form-openid.blade.php b/resources/views/auth/parts/login-form-openid.blade.php deleted file mode 100644 index ba975ebf4..000000000 --- a/resources/views/auth/parts/login-form-openid.blade.php +++ /dev/null @@ -1,11 +0,0 @@ -
    - {!! csrf_field() !!} - -
    - -
    - -
    diff --git a/resources/views/common/header.blade.php b/resources/views/common/header.blade.php index cac585a65..2311ce3e0 100644 --- a/resources/views/common/header.blade.php +++ b/resources/views/common/header.blade.php @@ -73,8 +73,6 @@
  • @if(config('auth.method') === 'saml2') @icon('logout'){{ trans('auth.logout') }} - @elseif(config('auth.method') === 'openid') - @icon('logout'){{ trans('auth.logout') }} @else @icon('logout'){{ trans('auth.logout') }} @endif diff --git a/resources/views/settings/index.blade.php b/resources/views/settings/index.blade.php index 8d63244e1..5fe5f3685 100644 --- a/resources/views/settings/index.blade.php +++ b/resources/views/settings/index.blade.php @@ -221,7 +221,7 @@ 'label' => trans('settings.reg_enable_toggle') ]) - @if(in_array(config('auth.method'), ['ldap', 'saml2', 'openid'])) + @if(in_array(config('auth.method'), ['ldap', 'saml2', 'oidc']))
    {{ trans('settings.reg_enable_external_warning') }}
    @endif diff --git a/resources/views/settings/roles/parts/form.blade.php b/resources/views/settings/roles/parts/form.blade.php index 3f7f8fd1f..9cea9e1fb 100644 --- a/resources/views/settings/roles/parts/form.blade.php +++ b/resources/views/settings/roles/parts/form.blade.php @@ -22,7 +22,7 @@ @include('form.checkbox', ['name' => 'mfa_enforced', 'label' => trans('settings.role_mfa_enforced') ]) - @if(in_array(config('auth.method'), ['ldap', 'saml2', 'openid'])) + @if(in_array(config('auth.method'), ['ldap', 'saml2', 'oidc']))
    @include('form.text', ['name' => 'external_auth_id']) diff --git a/resources/views/users/parts/form.blade.php b/resources/views/users/parts/form.blade.php index ef8c611ef..2a5002c3b 100644 --- a/resources/views/users/parts/form.blade.php +++ b/resources/views/users/parts/form.blade.php @@ -25,7 +25,7 @@
    -@if(in_array($authMethod, ['ldap', 'saml2', 'openid']) && userCan('users-manage')) +@if(in_array($authMethod, ['ldap', 'saml2', 'oidc']) && userCan('users-manage'))
    diff --git a/routes/web.php b/routes/web.php index fb4282539..72e0392cc 100644 --- a/routes/web.php +++ b/routes/web.php @@ -267,10 +267,9 @@ Route::get('/saml2/metadata', 'Auth\Saml2Controller@metadata'); Route::get('/saml2/sls', 'Auth\Saml2Controller@sls'); Route::post('/saml2/acs', 'Auth\Saml2Controller@acs'); -// OpenId routes -Route::post('/openid/login', 'Auth\OpenIdController@login'); -Route::get('/openid/logout', 'Auth\OpenIdController@logout'); -Route::get('/openid/redirect', 'Auth\OpenIdController@redirect'); +// OIDC routes +Route::post('/oidc/login', 'Auth\OpenIdConnectController@login'); +Route::get('/oidc/redirect', 'Auth\OpenIdConnectController@redirect'); // User invitation routes Route::get('/register/invite/{token}', 'Auth\UserInviteController@showSetPassword'); From 8ce696dff62afc9a366f95231fe03a25078b4ce1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 10 Oct 2021 19:14:08 +0100 Subject: [PATCH 19/50] Started on a custom oidc oauth provider --- .../Access/OpenIdConnectOAuthProvider.php | 109 ++++++++++++++++++ app/Auth/Access/OpenIdConnectService.php | 31 +++-- 2 files changed, 124 insertions(+), 16 deletions(-) create mode 100644 app/Auth/Access/OpenIdConnectOAuthProvider.php diff --git a/app/Auth/Access/OpenIdConnectOAuthProvider.php b/app/Auth/Access/OpenIdConnectOAuthProvider.php new file mode 100644 index 000000000..60ae2aa09 --- /dev/null +++ b/app/Auth/Access/OpenIdConnectOAuthProvider.php @@ -0,0 +1,109 @@ +authorizationEndpoint; + } + + /** + * Returns the base URL for requesting an access token. + */ + public function getBaseAccessTokenUrl(array $params): string + { + return $this->tokenEndpoint; + } + + /** + * Returns the URL for requesting the resource owner's details. + */ + public function getResourceOwnerDetailsUrl(AccessToken $token): string + { + return ''; + } + + /** + * Returns the default scopes used by this provider. + * + * This should only be the scopes that are required to request the details + * of the resource owner, rather than all the available scopes. + */ + protected function getDefaultScopes(): array + { + return ['openid', 'profile', 'email']; + } + + + /** + * Returns the string that should be used to separate scopes when building + * the URL for requesting an access token. + */ + protected function getScopeSeparator(): string + { + return ' '; + } + + /** + * Checks a provider response for errors. + * + * @param ResponseInterface $response + * @param array|string $data Parsed response data + * @return void + * @throws IdentityProviderException + */ + protected function checkResponse(ResponseInterface $response, $data) + { + if ($response->getStatusCode() >= 400 || isset($data['error'])) { + throw new IdentityProviderException( + $data['error'] ?? $response->getReasonPhrase(), + $response->getStatusCode(), + (string) $response->getBody() + ); + } + } + + /** + * Generates a resource owner object from a successful resource owner + * details request. + * + * @param array $response + * @param AccessToken $token + * @return ResourceOwnerInterface + */ + protected function createResourceOwner(array $response, AccessToken $token) + { + return new GenericResourceOwner($response, ''); + } +} \ No newline at end of file diff --git a/app/Auth/Access/OpenIdConnectService.php b/app/Auth/Access/OpenIdConnectService.php index 2548aee6e..01050b5e5 100644 --- a/app/Auth/Access/OpenIdConnectService.php +++ b/app/Auth/Access/OpenIdConnectService.php @@ -6,10 +6,8 @@ use BookStack\Exceptions\OpenIdConnectException; use BookStack\Exceptions\StoppedAuthenticationException; use BookStack\Exceptions\UserRegistrationException; use Exception; -use Lcobucci\JWT\Signer\Rsa\Sha256; use Lcobucci\JWT\Token; -use OpenIDConnectClient\AccessToken; -use OpenIDConnectClient\OpenIDConnectProvider; +use League\OAuth2\Client\Token\AccessToken; /** * Class OpenIdConnectService @@ -66,27 +64,18 @@ class OpenIdConnectService /** * Load the underlying OpenID Connect Provider. */ - protected function getProvider(): OpenIDConnectProvider + protected function getProvider(): OpenIdConnectOAuthProvider { // Setup settings $settings = [ 'clientId' => $this->config['client_id'], 'clientSecret' => $this->config['client_secret'], - 'idTokenIssuer' => $this->config['issuer'], 'redirectUri' => url('/oidc/redirect'), - 'urlAuthorize' => $this->config['authorization_endpoint'], - 'urlAccessToken' => $this->config['token_endpoint'], - 'urlResourceOwnerDetails' => null, - 'publicKey' => $this->config['jwt_public_key'], - 'scopes' => 'profile email', + 'authorizationEndpoint' => $this->config['authorization_endpoint'], + 'tokenEndpoint' => $this->config['token_endpoint'], ]; - // Setup services - $services = [ - 'signer' => new Sha256(), - ]; - - return new OpenIDConnectProvider($settings, $services); + return new OpenIdConnectOAuthProvider($settings); } /** @@ -135,6 +124,16 @@ class OpenIdConnectService */ protected function processAccessTokenCallback(AccessToken $accessToken): User { + dd($accessToken->getValues()); + // TODO - Create a class to manage token parsing and validation on this + // Using the config params: + // $this->config['jwt_public_key'] + // $this->config['issuer'] + // + // Ensure ID token validation is done: + // https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation + // To full affect and tested + $userDetails = $this->getUserDetails($accessToken->getIdToken()); $isLoggedIn = auth()->check(); From 8c01c55684e64dbcafd71b6bb9af12ef77b0e39f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 11 Oct 2021 19:05:16 +0100 Subject: [PATCH 20/50] Added token and key handling elements for oidc jwt - Got basic signing support and structure checking done. - Need to run through actual claim checking before providing details back to app. --- app/Auth/Access/LoginService.php | 2 +- .../OpenIdConnect/InvalidKeyException.php | 8 + .../OpenIdConnect/InvalidTokenException.php | 10 ++ .../Access/OpenIdConnect/JwtSigningKey.php | 76 ++++++++++ .../OpenIdConnectAccessToken.php | 54 +++++++ .../OpenIdConnect/OpenIdConnectIdToken.php | 143 ++++++++++++++++++ .../OpenIdConnectOAuthProvider.php | 20 ++- .../OpenIdConnectService.php | 26 +++- .../Auth/OpenIdConnectController.php | 2 +- composer.json | 1 + composer.lock | 113 +++++++++++++- 11 files changed, 444 insertions(+), 11 deletions(-) create mode 100644 app/Auth/Access/OpenIdConnect/InvalidKeyException.php create mode 100644 app/Auth/Access/OpenIdConnect/InvalidTokenException.php create mode 100644 app/Auth/Access/OpenIdConnect/JwtSigningKey.php create mode 100644 app/Auth/Access/OpenIdConnect/OpenIdConnectAccessToken.php create mode 100644 app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php rename app/Auth/Access/{ => OpenIdConnect}/OpenIdConnectOAuthProvider.php (84%) rename app/Auth/Access/{ => OpenIdConnect}/OpenIdConnectService.php (87%) diff --git a/app/Auth/Access/LoginService.php b/app/Auth/Access/LoginService.php index b36adb522..f41570417 100644 --- a/app/Auth/Access/LoginService.php +++ b/app/Auth/Access/LoginService.php @@ -47,7 +47,7 @@ class LoginService // Authenticate on all session guards if a likely admin if ($user->can('users-manage') && $user->can('user-roles-manage')) { - $guards = ['standard', 'ldap', 'saml2', 'openid']; + $guards = ['standard', 'ldap', 'saml2', 'oidc']; foreach ($guards as $guard) { auth($guard)->login($user); } diff --git a/app/Auth/Access/OpenIdConnect/InvalidKeyException.php b/app/Auth/Access/OpenIdConnect/InvalidKeyException.php new file mode 100644 index 000000000..85746cb6a --- /dev/null +++ b/app/Auth/Access/OpenIdConnect/InvalidKeyException.php @@ -0,0 +1,8 @@ + 'RSA', 'alg' => 'RS256', 'n' => 'abc123...'] + * @param array|string $jwkOrKeyPath + * @throws InvalidKeyException + */ + public function __construct($jwkOrKeyPath) + { + if (is_array($jwkOrKeyPath)) { + $this->loadFromJwkArray($jwkOrKeyPath); + } + } + + /** + * @throws InvalidKeyException + */ + protected function loadFromJwkArray(array $jwk) + { + if ($jwk['alg'] !== 'RS256') { + throw new InvalidKeyException("Only RS256 keys are currently supported. Found key using {$jwk['alg']}"); + } + + if ($jwk['use'] !== 'sig') { + throw new InvalidKeyException("Only signature keys are currently supported. Found key for use {$jwk['sig']}"); + } + + if (empty($jwk['e'] ?? '')) { + throw new InvalidKeyException('An "e" parameter on the provided key is expected'); + } + + if (empty($jwk['n'] ?? '')) { + throw new InvalidKeyException('A "n" parameter on the provided key is expected'); + } + + $n = strtr($jwk['n'] ?? '', '-_', '+/'); + + try { + /** @var RSA $key */ + $key = PublicKeyLoader::load([ + 'e' => new BigInteger(base64_decode($jwk['e']), 256), + 'n' => new BigInteger(base64_decode($n), 256), + ])->withPadding(RSA::SIGNATURE_PKCS1); + + $this->key = $key; + } catch (\Exception $exception) { + throw new InvalidKeyException("Failed to load key from JWK parameters with error: {$exception->getMessage()}"); + } + } + + /** + * Use this key to sign the given content and return the signature. + */ + public function verify(string $content, string $signature): bool + { + return $this->key->verify($content, $signature); + } + +} \ No newline at end of file diff --git a/app/Auth/Access/OpenIdConnect/OpenIdConnectAccessToken.php b/app/Auth/Access/OpenIdConnect/OpenIdConnectAccessToken.php new file mode 100644 index 000000000..6731ec4be --- /dev/null +++ b/app/Auth/Access/OpenIdConnect/OpenIdConnectAccessToken.php @@ -0,0 +1,54 @@ +validate($options); + } + + + /** + * Validate this access token response for OIDC. + * As per https://openid.net/specs/openid-connect-basic-1_0.html#TokenOK. + */ + private function validate(array $options): void + { + // access_token: REQUIRED. Access Token for the UserInfo Endpoint. + // Performed on the extended class + + // token_type: REQUIRED. OAuth 2.0 Token Type value. The value MUST be Bearer, as specified in OAuth 2.0 + // Bearer Token Usage [RFC6750], for Clients using this subset. + // Note that the token_type value is case-insensitive. + if (strtolower(($options['token_type'] ?? '')) !== 'bearer') { + throw new InvalidArgumentException('The response token type MUST be "Bearer"'); + } + + // id_token: REQUIRED. ID Token. + if (empty($options['id_token'])) { + throw new InvalidArgumentException('An "id_token" property must be provided'); + } + } + + /** + * Get the id token value from this access token response. + */ + public function getIdToken(): string + { + return $this->getValues()['id_token']; + } + +} \ No newline at end of file diff --git a/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php b/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php new file mode 100644 index 000000000..09527c3ed --- /dev/null +++ b/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php @@ -0,0 +1,143 @@ +keys = $keys; + $this->issuer = $issuer; + $this->parse($token); + } + + /** + * Parse the token content into its components. + */ + protected function parse(string $token): void + { + $this->tokenParts = explode('.', $token); + $this->header = $this->parseEncodedTokenPart($this->tokenParts[0]); + $this->payload = $this->parseEncodedTokenPart($this->tokenParts[1] ?? ''); + $this->signature = $this->base64UrlDecode($this->tokenParts[2] ?? '') ?: ''; + } + + /** + * Parse a Base64-JSON encoded token part. + * Returns the data as a key-value array or empty array upon error. + */ + protected function parseEncodedTokenPart(string $part): array + { + $json = $this->base64UrlDecode($part) ?: '{}'; + $decoded = json_decode($json, true); + return is_array($decoded) ? $decoded : []; + } + + /** + * Base64URL decode. Needs some character conversions to be compatible + * with PHP's default base64 handling. + */ + protected function base64UrlDecode(string $encoded): string + { + return base64_decode(strtr($encoded, '-_', '+/')); + } + + /** + * Validate all possible parts of the id token. + * @throws InvalidTokenException + */ + public function validate() + { + $this->validateTokenStructure(); + $this->validateTokenSignature(); + $this->validateTokenClaims(); + } + + /** + * Validate the structure of the given token and ensure we have the required pieces. + * As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2 + * @throws InvalidTokenException + */ + protected function validateTokenStructure(): void + { + foreach (['header', 'payload'] as $prop) { + if (empty($this->$prop) || !is_array($this->$prop)) { + throw new InvalidTokenException("Could not parse out a valid {$prop} within the provided token"); + } + } + + if (empty($this->signature) || !is_string($this->signature)) { + throw new InvalidTokenException("Could not parse out a valid signature within the provided token"); + } + } + + /** + * Validate the signature of the given token and ensure it validates against the provided key. + * @throws InvalidTokenException + */ + protected function validateTokenSignature(): void + { + if ($this->header['alg'] !== 'RS256') { + throw new InvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}"); + } + + $parsedKeys = array_map(function($key) { + try { + return new JwtSigningKey($key); + } catch (InvalidKeyException $e) { + return null; + } + }, $this->keys); + + $parsedKeys = array_filter($parsedKeys); + + $contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1]; + foreach ($parsedKeys as $parsedKey) { + if ($parsedKey->verify($contentToSign, $this->signature)) { + return; + } + } + + throw new InvalidTokenException('Token signature could not be validated using the provided keys.'); + } + + /** + * Validate the claims of the token. + * As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation + */ + protected function validateTokenClaims(): void + { + // TODO + } + +} \ No newline at end of file diff --git a/app/Auth/Access/OpenIdConnectOAuthProvider.php b/app/Auth/Access/OpenIdConnect/OpenIdConnectOAuthProvider.php similarity index 84% rename from app/Auth/Access/OpenIdConnectOAuthProvider.php rename to app/Auth/Access/OpenIdConnect/OpenIdConnectOAuthProvider.php index 60ae2aa09..074f463cc 100644 --- a/app/Auth/Access/OpenIdConnectOAuthProvider.php +++ b/app/Auth/Access/OpenIdConnect/OpenIdConnectOAuthProvider.php @@ -1,7 +1,8 @@ getValues()); + $idTokenText = $accessToken->getIdToken(); + $idToken = new OpenIdConnectIdToken( + $idTokenText, + $this->config['issuer'], + [$this->config['jwt_public_key']] + ); + // TODO - Create a class to manage token parsing and validation on this - // Using the config params: - // $this->config['jwt_public_key'] - // $this->config['issuer'] - // // Ensure ID token validation is done: // https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation // To full affect and tested + // JWT signature algorthims: + // https://datatracker.ietf.org/doc/html/rfc7518#section-3 $userDetails = $this->getUserDetails($accessToken->getIdToken()); $isLoggedIn = auth()->check(); diff --git a/app/Http/Controllers/Auth/OpenIdConnectController.php b/app/Http/Controllers/Auth/OpenIdConnectController.php index 23cfbbcbe..8156773b4 100644 --- a/app/Http/Controllers/Auth/OpenIdConnectController.php +++ b/app/Http/Controllers/Auth/OpenIdConnectController.php @@ -2,7 +2,7 @@ namespace BookStack\Http\Controllers\Auth; -use BookStack\Auth\Access\OpenIdConnectService; +use BookStack\Auth\Access\OpenIdConnect\OpenIdConnectService; use BookStack\Http\Controllers\Controller; use Illuminate\Http\Request; diff --git a/composer.json b/composer.json index 288f55991..e53d9d25a 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ "league/html-to-markdown": "^5.0.0", "nunomaduro/collision": "^3.1", "onelogin/php-saml": "^4.0", + "phpseclib/phpseclib": "~3.0", "pragmarx/google2fa": "^8.0", "predis/predis": "^1.1.6", "socialiteproviders/discord": "^4.1", diff --git a/composer.lock b/composer.lock index 9355deed3..62b2aa621 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "620412108a5d19ed91d9fe42418b63b5", + "content-hash": "5cbbf417bd19cd2164f91b9b2d38600c", "packages": [ { "name": "aws/aws-crt-php", @@ -3511,6 +3511,117 @@ ], "time": "2021-08-28T21:27:29+00:00" }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.10", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "62fcc5a94ac83b1506f52d7558d828617fac9187" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/62fcc5a94ac83b1506f52d7558d828617fac9187", + "reference": "62fcc5a94ac83b1506f52d7558d828617fac9187", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phing/phing": "~2.7", + "phpunit/phpunit": "^5.7|^6.0|^9.4", + "squizlabs/php_codesniffer": "~2.0" + }, + "suggest": { + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.10" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2021-08-16T04:24:45+00:00" + }, { "name": "pragmarx/google2fa", "version": "8.0.0", From 6b182a435a072666be3277a4f5da905f386217c1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 11 Oct 2021 23:00:45 +0100 Subject: [PATCH 21/50] Got OIDC custom solution to a functional state - Validation of all key/token elements now in place. - Signing key system updated to work with jwk-style array or with file:// path to pem key. --- .../Access/OpenIdConnect/JwtSigningKey.php | 34 ++- .../OpenIdConnect/OpenIdConnectIdToken.php | 98 ++++++++- .../OpenIdConnect/OpenIdConnectService.php | 35 ++-- app/Http/Controllers/Auth/LoginController.php | 3 +- composer.json | 3 +- composer.lock | 196 +----------------- 6 files changed, 143 insertions(+), 226 deletions(-) diff --git a/app/Auth/Access/OpenIdConnect/JwtSigningKey.php b/app/Auth/Access/OpenIdConnect/JwtSigningKey.php index c835c04c3..1687069a9 100644 --- a/app/Auth/Access/OpenIdConnect/JwtSigningKey.php +++ b/app/Auth/Access/OpenIdConnect/JwtSigningKey.php @@ -26,6 +26,28 @@ class JwtSigningKey { if (is_array($jwkOrKeyPath)) { $this->loadFromJwkArray($jwkOrKeyPath); + } else if (is_string($jwkOrKeyPath) && strpos($jwkOrKeyPath, 'file://') === 0) { + $this->loadFromPath($jwkOrKeyPath); + } else { + throw new InvalidKeyException('Unexpected type of key value provided'); + } + } + + /** + * @throws InvalidKeyException + */ + protected function loadFromPath(string $path) + { + try { + $this->key = PublicKeyLoader::load( + file_get_contents($path) + )->withPadding(RSA::SIGNATURE_PKCS1); + } catch (\Exception $exception) { + throw new InvalidKeyException("Failed to load key from file path with error: {$exception->getMessage()}"); + } + + if (!($this->key instanceof RSA)) { + throw new InvalidKeyException("Key loaded from file path is not an RSA key as expected"); } } @@ -54,12 +76,10 @@ class JwtSigningKey try { /** @var RSA $key */ - $key = PublicKeyLoader::load([ + $this->key = PublicKeyLoader::load([ 'e' => new BigInteger(base64_decode($jwk['e']), 256), 'n' => new BigInteger(base64_decode($n), 256), ])->withPadding(RSA::SIGNATURE_PKCS1); - - $this->key = $key; } catch (\Exception $exception) { throw new InvalidKeyException("Failed to load key from JWK parameters with error: {$exception->getMessage()}"); } @@ -73,4 +93,12 @@ class JwtSigningKey return $this->key->verify($content, $signature); } + /** + * Convert the key to a PEM encoded key string. + */ + public function toPem(): string + { + return $this->key->toString('PKCS8'); + } + } \ No newline at end of file diff --git a/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php b/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php index 09527c3ed..b5cef1772 100644 --- a/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php +++ b/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php @@ -76,11 +76,29 @@ class OpenIdConnectIdToken * Validate all possible parts of the id token. * @throws InvalidTokenException */ - public function validate() + public function validate(string $clientId) { $this->validateTokenStructure(); $this->validateTokenSignature(); - $this->validateTokenClaims(); + $this->validateTokenClaims($clientId); + } + + /** + * Fetch a specific claim from this token. + * Returns null if it is null or does not exist. + * @return mixed|null + */ + public function getClaim(string $claim) + { + return $this->payload[$claim] ?? null; + } + + /** + * Get all returned claims within the token. + */ + public function claims(): array + { + return $this->payload; } /** @@ -122,22 +140,92 @@ class OpenIdConnectIdToken $parsedKeys = array_filter($parsedKeys); $contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1]; + /** @var JwtSigningKey $parsedKey */ foreach ($parsedKeys as $parsedKey) { if ($parsedKey->verify($contentToSign, $this->signature)) { return; } } - throw new InvalidTokenException('Token signature could not be validated using the provided keys.'); + throw new InvalidTokenException('Token signature could not be validated using the provided keys'); } /** * Validate the claims of the token. * As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation + * @throws InvalidTokenException */ - protected function validateTokenClaims(): void + protected function validateTokenClaims(string $clientId): void { - // TODO + // 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery) + // MUST exactly match the value of the iss (issuer) Claim. + if (empty($this->payload['iss']) || $this->issuer !== $this->payload['iss']) { + throw new InvalidTokenException('Missing or non-matching token issuer value'); + } + + // 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered + // at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected + // if the ID Token does not list the Client as a valid audience, or if it contains additional + // audiences not trusted by the Client. + if (empty($this->payload['aud'])) { + throw new InvalidTokenException('Missing token audience value'); + } + + $aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud']; + if (count($aud) !== 1) { + throw new InvalidTokenException('Token audience value has ' . count($aud) . ' values. Expected 1.'); + } + + if ($aud[0] !== $clientId) { + throw new InvalidTokenException('Token audience value did not match the expected client_id'); + } + + // 3. If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present. + // NOTE: Addressed by enforcing a count of 1 above. + + // 4. If an azp (authorized party) Claim is present, the Client SHOULD verify that its client_id + // is the Claim Value. + if (isset($this->payload['azp']) && $this->payload['azp'] !== $clientId) { + throw new InvalidTokenException('Token authorized party exists but does not match the expected client_id'); + } + + // 5. The current time MUST be before the time represented by the exp Claim + // (possibly allowing for some small leeway to account for clock skew). + if (empty($this->payload['exp'])) { + throw new InvalidTokenException('Missing token expiration time value'); + } + + $skewSeconds = 120; + $now = time(); + if ($now >= (intval($this->payload['exp']) + $skewSeconds)) { + throw new InvalidTokenException('Token has expired'); + } + + // 6. The iat Claim can be used to reject tokens that were issued too far away from the current time, + // limiting the amount of time that nonces need to be stored to prevent attacks. + // The acceptable range is Client specific. + if (empty($this->payload['iat'])) { + throw new InvalidTokenException('Missing token issued at time value'); + } + + $dayAgo = time() - 86400; + $iat = intval($this->payload['iat']); + if ($iat > ($now + $skewSeconds) || $iat < $dayAgo) { + throw new InvalidTokenException('Token issue at time is not recent or is invalid'); + } + + // 7. If the acr Claim was requested, the Client SHOULD check that the asserted Claim Value is appropriate. + // The meaning and processing of acr Claim Values is out of scope for this document. + // NOTE: Not used for our case here. acr is not requested. + + // 8. When a max_age request is made, the Client SHOULD check the auth_time Claim value and request + // re-authentication if it determines too much time has elapsed since the last End-User authentication. + // NOTE: Not used for our case here. A max_age request is not made. + + // Custom: Ensure the "sub" (Subject) Claim exists and has a value. + if (empty($this->payload['sub'])) { + throw new InvalidTokenException('Missing token subject value'); + } } } \ No newline at end of file diff --git a/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php b/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php index 40369dee7..0f9fed006 100644 --- a/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php +++ b/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php @@ -1,7 +1,6 @@ config['display_name_claims']; $displayName = []; foreach ($displayNameAttr as $dnAttr) { - $dnComponent = $token->claims()->get($dnAttr, ''); + $dnComponent = $token->getClaim($dnAttr) ?? ''; if ($dnComponent !== '') { $displayName[] = $dnComponent; } @@ -112,12 +108,12 @@ class OpenIdConnectService * Extract the details of a user from an ID token. * @return array{name: string, email: string, external_id: string} */ - protected function getUserDetails(Token $token): array + protected function getUserDetails(OpenIdConnectIdToken $token): array { - $id = $token->claims()->get('sub'); + $id = $token->getClaim('sub'); return [ 'external_id' => $id, - 'email' => $token->claims()->get('email'), + 'email' => $token->getClaim('email'), 'name' => $this->getUserDisplayName($token, $id), ]; } @@ -139,20 +135,19 @@ class OpenIdConnectService [$this->config['jwt_public_key']] ); - // TODO - Create a class to manage token parsing and validation on this - // Ensure ID token validation is done: - // https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation - // To full affect and tested - // JWT signature algorthims: - // https://datatracker.ietf.org/doc/html/rfc7518#section-3 - - $userDetails = $this->getUserDetails($accessToken->getIdToken()); - $isLoggedIn = auth()->check(); - if ($this->config['dump_user_details']) { - throw new JsonDebugException($accessToken->jsonSerialize()); + throw new JsonDebugException($idToken->claims()); } + try { + $idToken->validate($this->config['client_id']); + } catch (InvalidTokenException $exception) { + throw new OpenIdConnectException("ID token validate failed with error: {$exception->getMessage()}"); + } + + $userDetails = $this->getUserDetails($idToken); + $isLoggedIn = auth()->check(); + if ($userDetails['email'] === null) { throw new OpenIdConnectException(trans('errors.oidc_no_email_address')); } diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 7c8eb2c86..d12d7c9bc 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -43,7 +43,8 @@ class LoginController extends Controller public function __construct(SocialAuthService $socialAuthService, LoginService $loginService) { $this->middleware('guest', ['only' => ['getLogin', 'login']]); - $this->middleware('guard:standard,ldap', ['only' => ['login', 'logout']]); + $this->middleware('guard:standard,ldap', ['only' => ['login']]); + $this->middleware('guard:standard,ldap,oidc', ['only' => ['logout']]); $this->socialAuthService = $socialAuthService; $this->loginService = $loginService; diff --git a/composer.json b/composer.json index e53d9d25a..066e67c4f 100644 --- a/composer.json +++ b/composer.json @@ -36,8 +36,7 @@ "socialiteproviders/okta": "^4.1", "socialiteproviders/slack": "^4.1", "socialiteproviders/twitch": "^5.3", - "ssddanbrown/htmldiff": "^v1.0.1", - "steverhoades/oauth2-openid-connect-client": "^0.3.0" + "ssddanbrown/htmldiff": "^v1.0.1" }, "require-dev": { "barryvdh/laravel-debugbar": "^3.5.1", diff --git a/composer.lock b/composer.lock index 62b2aa621..d64d8d640 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5cbbf417bd19cd2164f91b9b2d38600c", + "content-hash": "ef6a8bb7bc6e99c70eeabc7695fc56eb", "packages": [ { "name": "aws/aws-crt-php", @@ -2069,83 +2069,6 @@ }, "time": "2021-08-31T15:16:26+00:00" }, - { - "name": "lcobucci/jwt", - "version": "3.4.6", - "source": { - "type": "git", - "url": "https://github.com/lcobucci/jwt.git", - "reference": "3ef8657a78278dfeae7707d51747251db4176240" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/lcobucci/jwt/zipball/3ef8657a78278dfeae7707d51747251db4176240", - "reference": "3ef8657a78278dfeae7707d51747251db4176240", - "shasum": "" - }, - "require": { - "ext-mbstring": "*", - "ext-openssl": "*", - "php": "^5.6 || ^7.0" - }, - "require-dev": { - "mikey179/vfsstream": "~1.5", - "phpmd/phpmd": "~2.2", - "phpunit/php-invoker": "~1.1", - "phpunit/phpunit": "^5.7 || ^7.3", - "squizlabs/php_codesniffer": "~2.3" - }, - "suggest": { - "lcobucci/clock": "*" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.1-dev" - } - }, - "autoload": { - "psr-4": { - "Lcobucci\\JWT\\": "src" - }, - "files": [ - "compat/class-aliases.php", - "compat/json-exception-polyfill.php", - "compat/lcobucci-clock-polyfill.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Luís Otávio Cobucci Oblonczyk", - "email": "lcobucci@gmail.com", - "role": "Developer" - } - ], - "description": "A simple library to work with JSON Web Token and JSON Web Signature", - "keywords": [ - "JWS", - "jwt" - ], - "support": { - "issues": "https://github.com/lcobucci/jwt/issues", - "source": "https://github.com/lcobucci/jwt/tree/3.4.6" - }, - "funding": [ - { - "url": "https://github.com/lcobucci", - "type": "github" - }, - { - "url": "https://www.patreon.com/lcobucci", - "type": "patreon" - } - ], - "time": "2021-09-28T19:18:28+00:00" - }, { "name": "league/commonmark", "version": "1.6.6", @@ -2613,76 +2536,6 @@ }, "time": "2021-08-15T23:05:49+00:00" }, - { - "name": "league/oauth2-client", - "version": "2.6.0", - "source": { - "type": "git", - "url": "https://github.com/thephpleague/oauth2-client.git", - "reference": "badb01e62383430706433191b82506b6df24ad98" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/badb01e62383430706433191b82506b6df24ad98", - "reference": "badb01e62383430706433191b82506b6df24ad98", - "shasum": "" - }, - "require": { - "guzzlehttp/guzzle": "^6.0 || ^7.0", - "paragonie/random_compat": "^1 || ^2 || ^9.99", - "php": "^5.6 || ^7.0 || ^8.0" - }, - "require-dev": { - "mockery/mockery": "^1.3", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpunit/phpunit": "^5.7 || ^6.0 || ^9.3", - "squizlabs/php_codesniffer": "^2.3 || ^3.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-2.x": "2.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "League\\OAuth2\\Client\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Alex Bilbie", - "email": "hello@alexbilbie.com", - "homepage": "http://www.alexbilbie.com", - "role": "Developer" - }, - { - "name": "Woody Gilk", - "homepage": "https://github.com/shadowhand", - "role": "Contributor" - } - ], - "description": "OAuth 2.0 Client Library", - "keywords": [ - "Authentication", - "SSO", - "authorization", - "identity", - "idp", - "oauth", - "oauth2", - "single sign on" - ], - "support": { - "issues": "https://github.com/thephpleague/oauth2-client/issues", - "source": "https://github.com/thephpleague/oauth2-client/tree/2.6.0" - }, - "time": "2020-10-28T02:03:40+00:00" - }, { "name": "monolog/monolog", "version": "2.3.5", @@ -4675,53 +4528,6 @@ ], "time": "2021-01-24T18:51:30+00:00" }, - { - "name": "steverhoades/oauth2-openid-connect-client", - "version": "v0.3.0", - "source": { - "type": "git", - "url": "https://github.com/steverhoades/oauth2-openid-connect-client.git", - "reference": "0159471487540a4620b8d0b693f5f215503a8d75" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/steverhoades/oauth2-openid-connect-client/zipball/0159471487540a4620b8d0b693f5f215503a8d75", - "reference": "0159471487540a4620b8d0b693f5f215503a8d75", - "shasum": "" - }, - "require": { - "lcobucci/jwt": "^3.2", - "league/oauth2-client": "^2.0" - }, - "require-dev": { - "phpmd/phpmd": "~2.2", - "phpunit/php-invoker": "~1.1", - "phpunit/phpunit": "~4.5", - "squizlabs/php_codesniffer": "~2.3" - }, - "type": "library", - "autoload": { - "psr-4": { - "OpenIDConnectClient\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Steve Rhoades", - "email": "sedonami@gmail.com" - } - ], - "description": "OAuth2 OpenID Connect Client that utilizes the PHP Leagues OAuth2 Client", - "support": { - "issues": "https://github.com/steverhoades/oauth2-openid-connect-client/issues", - "source": "https://github.com/steverhoades/oauth2-openid-connect-client/tree/master" - }, - "time": "2020-05-19T23:06:36+00:00" - }, { "name": "swiftmailer/swiftmailer", "version": "v6.2.7", From f3d54e4a2dd0fe22f5c2e44f878d1bc6757aa230 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 12 Oct 2021 00:01:51 +0100 Subject: [PATCH 22/50] Added positive test case for OIDC implementation - To continue coverage and spec cases next. --- .../Access/OpenIdConnect/JwtSigningKey.php | 12 +- .../OpenIdConnect/OpenIdConnectIdToken.php | 3 +- tests/Unit/OpenIdConnectIdTokenTest.php | 118 ++++++++++++++++++ 3 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 tests/Unit/OpenIdConnectIdTokenTest.php diff --git a/app/Auth/Access/OpenIdConnect/JwtSigningKey.php b/app/Auth/Access/OpenIdConnect/JwtSigningKey.php index 1687069a9..ed823073a 100644 --- a/app/Auth/Access/OpenIdConnect/JwtSigningKey.php +++ b/app/Auth/Access/OpenIdConnect/JwtSigningKey.php @@ -60,15 +60,19 @@ class JwtSigningKey throw new InvalidKeyException("Only RS256 keys are currently supported. Found key using {$jwk['alg']}"); } - if ($jwk['use'] !== 'sig') { - throw new InvalidKeyException("Only signature keys are currently supported. Found key for use {$jwk['sig']}"); + if (empty($jwk['use'])) { + throw new InvalidKeyException('A "use" parameter on the provided key is expected'); } - if (empty($jwk['e'] ?? '')) { + if ($jwk['use'] !== 'sig') { + throw new InvalidKeyException("Only signature keys are currently supported. Found key for use {$jwk['use']}"); + } + + if (empty($jwk['e'])) { throw new InvalidKeyException('An "e" parameter on the provided key is expected'); } - if (empty($jwk['n'] ?? '')) { + if (empty($jwk['n'])) { throw new InvalidKeyException('A "n" parameter on the provided key is expected'); } diff --git a/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php b/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php index b5cef1772..0ee43e663 100644 --- a/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php +++ b/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php @@ -76,11 +76,12 @@ class OpenIdConnectIdToken * Validate all possible parts of the id token. * @throws InvalidTokenException */ - public function validate(string $clientId) + public function validate(string $clientId): bool { $this->validateTokenStructure(); $this->validateTokenSignature(); $this->validateTokenClaims($clientId); + return true; } /** diff --git a/tests/Unit/OpenIdConnectIdTokenTest.php b/tests/Unit/OpenIdConnectIdTokenTest.php new file mode 100644 index 000000000..fdde54f27 --- /dev/null +++ b/tests/Unit/OpenIdConnectIdTokenTest.php @@ -0,0 +1,118 @@ +getValidToken(), 'https://auth.example.com', [ + $this->jwkKeyArray() + ]); + + $this->assertTrue($token->validate('xxyyzz.aaa.bbccdd.123')); + } + + protected function getValidToken($payloadOverrides = []): string + { + $defaultPayload = [ + "sub" => "abc1234def", + "name" => "Barry Scott", + "email" => "bscott@example.com", + "ver" => 1, + "iss" => "https://auth.example.com", + "aud" => "xxyyzz.aaa.bbccdd.123", + "iat" => time(), + "exp" => time() + 720, + "jti" => "ID.AaaBBBbbCCCcccDDddddddEEEeeeeee", + "amr" => ["pwd"], + "idp" => "fghfghgfh546456dfgdfg", + "preferred_username" => "xXBazzaXx", + "auth_time" => time(), + "at_hash" => "sT4jbsdSGy9w12pq3iNYDA", + ]; + + $payload = array_merge($defaultPayload, $payloadOverrides); + $header = [ + 'kid' => 'xyz456', + 'alg' => 'RS256', + ]; + + $top = implode('.', [ + $this->base64UrlEncode(json_encode($header)), + $this->base64UrlEncode(json_encode($payload)), + ]); + + $privateKey = RSA::loadPrivateKey($this->privatePemKey())->withPadding(RSA::SIGNATURE_PKCS1); + $signature = $privateKey->sign($top); + + return $top . '.' . $this->base64UrlEncode($signature); + } + + protected function base64UrlEncode(string $decoded): string + { + return strtr(base64_encode($decoded), '+/', '-_'); + } + + protected function pemKey(): string + { + return "-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqo1OmfNKec5S2zQC4SP9 +DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvm +zXL16c93Obn7G8x8A3ao6yN5qKO5S5+CETqOZfKN/g75Xlz7VsC3igOhgsXnPx6i +iM6sbYbk0U/XpFaT84LXKI8VTIPUo7gTeZN1pTET//i9FlzAOzX+xfWBKdOqlEzl ++zihMHCZUUvQu99P+o0MDR0lMUT+vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNk +WvsLta1+jNUee+8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw +3wIDAQAB +-----END PUBLIC KEY-----"; + } + + protected function privatePemKey(): string + { + return "-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCqjU6Z80p5zlLb +NALhI/0Ose5RHRWAKLqipwYRHPvOo7fqGqTcDdHdoKAmQSN+ducy6zNFEqzjk1te +g6n2m+bNcvXpz3c5ufsbzHwDdqjrI3moo7lLn4IROo5l8o3+DvleXPtWwLeKA6GC +xec/HqKIzqxthuTRT9ekVpPzgtcojxVMg9SjuBN5k3WlMRP/+L0WXMA7Nf7F9YEp +06qUTOX7OKEwcJlRS9C730/6jQwNHSUxRP688npIl5F+egd7HC3ptkWI2exkgQvT +dtfhA2Ra+wu1rX6M1R577wg9WHMI7xu8zzo3MtopQniTo1nkhWuZ0IWkWyMJYHI6 +sMbzB3DfAgMBAAECggEADm7K2ghWoxwsstQh8j+DaLzx9/dIHIJV2PHdd5FGVeRQ +6gS7MswQmHrBUrtsb4VMZ2iz/AJqkw+jScpGldH3pCc4XELsSfxNHbseO4TNIqjr +4LOKOLYU4bRc3I+8KGXIAI5JzrucTJemEVUCDrte8cjbmqExt+zTyNpyxsapworF +v+vnSdv40d62f+cS1xvwB+ymLK/B/wZ/DemDCi8jsi7ou/M7l5xNCzjH4iMSLtOW +fgEhejIBG9miMJWPiVpTXE3tMdNuN3OsWc4XXm2t4VRovlZdu30Fax1xWB+Locsv +HlHKLOFc8g+jZh0TL2KCNjPffMcC7kHhW3afshpIsQKBgQDhyWUnkqd6FzbwIX70 +SnaMgKoUv5W/K5T+Sv/PA2CyN8Gu8ih/OsoNZSnI0uqe3XQIvvgN/Fq3wO1ttLzf +z5B6ZC7REfTgcR0190gihk6f5rtcj7d6Fy/oG2CE8sDSXgPnpEaBjvJVgN5v/U2s +HpVaidmHTyGLCfEszoeoy8jyrQKBgQDBX8caGylmzQLc6XNntZChlt3e18Nj8MPA +DxWLcoqgdDoofLDQAmLl+vPKyDmhQjos5eas1jgmVVEM4ge+MysaVezvuLBsSnOh +ihc0i63USU6i7YDE83DrCewCthpFHi/wW1S5FoCAzpVy8y99vwcqO4kOXcmf4O6Y +uW6sMsjvOwKBgQDbFtqB+MtsLCSSBF61W6AHHD5tna4H75lG2623yXZF2NanFLF5 +K6muL9DI3ujtOMQETJJUt9+rWJjLEEsJ/dYa/SV0l7D/LKOEnyuu3JZkkLaTzZzi +6qcA2bfhqdCzEKlHV99WjkfV8hNlpex9rLuOPB8JLh7FVONicBGxF/UojQKBgDXs +IlYaSuI6utilVKQP0kPtEPOKERc2VS+iRSy8hQGXR3xwwNFQSQm+f+sFCGT6VcSd +W0TI+6Fc2xwPj38vP465dTentbKM1E+wdSYW6SMwSfhO6ECDbfJsst5Sr2Kkt1N7 +9FUkfDLu6GfEfnK/KR1SurZB2u51R7NYyg7EnplvAoGAT0aTtOcck0oYN30g5mdf +efqXPwg2wAPYeiec49EbfnteQQKAkqNfJ9K69yE2naf6bw3/5mCBsq/cXeuaBMII +ylysUIRBqt2J0kWm2yCpFWR7H+Ilhdx9A7ZLCqYVt8e+vjO/BOI3cQDe2VPOLPSl +q/1PY4iJviGKddtmfClH3v4= +-----END PRIVATE KEY-----"; + } + + protected function jwkKeyArray(): array + { + return [ + 'kty' => 'RSA', + 'alg' => 'RS256', + 'kid' => '066e52af-8884-4926-801d-032a276f9f2a', + 'use' => 'sig', + 'e' => 'AQAB', + 'n' => 'qo1OmfNKec5S2zQC4SP9DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvmzXL16c93Obn7G8x8A3ao6yN5qKO5S5-CETqOZfKN_g75Xlz7VsC3igOhgsXnPx6iiM6sbYbk0U_XpFaT84LXKI8VTIPUo7gTeZN1pTET__i9FlzAOzX-xfWBKdOqlEzl-zihMHCZUUvQu99P-o0MDR0lMUT-vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNkWvsLta1-jNUee-8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw3w', + ]; + } +} \ No newline at end of file From 790723dfc52ca2169234bb86450e03aea51b3676 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 12 Oct 2021 16:48:54 +0100 Subject: [PATCH 23/50] Added further OIDC core class testing --- .../OpenIdConnect/OpenIdConnectIdToken.php | 4 +- .../OpenIdConnect/OpenIdConnectService.php | 2 +- tests/Unit/OpenIdConnectIdTokenTest.php | 174 +++++++++++++++++- 3 files changed, 168 insertions(+), 12 deletions(-) diff --git a/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php b/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php index 0ee43e663..1c1d0913d 100644 --- a/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php +++ b/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php @@ -97,7 +97,7 @@ class OpenIdConnectIdToken /** * Get all returned claims within the token. */ - public function claims(): array + public function getAllClaims(): array { return $this->payload; } @@ -174,7 +174,7 @@ class OpenIdConnectIdToken $aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud']; if (count($aud) !== 1) { - throw new InvalidTokenException('Token audience value has ' . count($aud) . ' values. Expected 1.'); + throw new InvalidTokenException('Token audience value has ' . count($aud) . ' values, Expected 1'); } if ($aud[0] !== $clientId) { diff --git a/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php b/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php index 0f9fed006..7471a5007 100644 --- a/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php +++ b/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php @@ -136,7 +136,7 @@ class OpenIdConnectService ); if ($this->config['dump_user_details']) { - throw new JsonDebugException($idToken->claims()); + throw new JsonDebugException($idToken->getAllClaims()); } try { diff --git a/tests/Unit/OpenIdConnectIdTokenTest.php b/tests/Unit/OpenIdConnectIdTokenTest.php index fdde54f27..d3394daf6 100644 --- a/tests/Unit/OpenIdConnectIdTokenTest.php +++ b/tests/Unit/OpenIdConnectIdTokenTest.php @@ -2,25 +2,169 @@ namespace Tests\Unit; +use BookStack\Auth\Access\OpenIdConnect\InvalidTokenException; use BookStack\Auth\Access\OpenIdConnect\OpenIdConnectIdToken; use phpseclib3\Crypt\RSA; use Tests\TestCase; class OpenIdConnectIdTokenTest extends TestCase { - public function test_valid_token_passes_validation() { - $token = new OpenIdConnectIdToken($this->getValidToken(), 'https://auth.example.com', [ + $token = new OpenIdConnectIdToken($this->idToken(), 'https://auth.example.com', [ $this->jwkKeyArray() ]); $this->assertTrue($token->validate('xxyyzz.aaa.bbccdd.123')); } - protected function getValidToken($payloadOverrides = []): string + public function test_get_claim_returns_value_if_existing() { - $defaultPayload = [ + $token = new OpenIdConnectIdToken($this->idToken(), 'https://auth.example.com', []); + $this->assertEquals('bscott@example.com', $token->getClaim('email')); + } + + public function test_get_claim_returns_null_if_not_existing() + { + $token = new OpenIdConnectIdToken($this->idToken(), 'https://auth.example.com', []); + $this->assertEquals(null, $token->getClaim('emails')); + } + + public function test_get_all_claims_returns_all_payload_claims() + { + $defaultPayload = $this->getDefaultPayload(); + $token = new OpenIdConnectIdToken($this->idToken($defaultPayload), 'https://auth.example.com', []); + $this->assertEquals($defaultPayload, $token->getAllClaims()); + } + + public function test_token_structure_error_cases() + { + $idToken = $this->idToken(); + $idTokenExploded = explode('.', $idToken); + + $messagesAndTokenValues = [ + ['Could not parse out a valid header within the provided token', ''], + ['Could not parse out a valid header within the provided token', 'cat'], + ['Could not parse out a valid payload within the provided token', $idTokenExploded[0]], + ['Could not parse out a valid payload within the provided token', $idTokenExploded[0] . '.' . 'dog'], + ['Could not parse out a valid signature within the provided token', $idTokenExploded[0] . '.' . $idTokenExploded[1]], + ['Could not parse out a valid signature within the provided token', $idTokenExploded[0] . '.' . $idTokenExploded[1] . '.' . '@$%'], + ]; + + foreach ($messagesAndTokenValues as [$message, $tokenValue]) { + $token = new OpenIdConnectIdToken($tokenValue, 'https://auth.example.com', []); + $err = null; + try { + $token->validate('abc'); + } catch (\Exception $exception) { + $err = $exception; + } + + $this->assertInstanceOf(InvalidTokenException::class, $err, $message); + $this->assertEquals($message, $err->getMessage()); + } + } + + public function test_error_thrown_if_token_signature_not_validated_from_no_keys() + { + $token = new OpenIdConnectIdToken($this->idToken(), 'https://auth.example.com', []); + $this->expectException(InvalidTokenException::class); + $this->expectExceptionMessage('Token signature could not be validated using the provided keys'); + $token->validate('abc'); + } + + public function test_error_thrown_if_token_signature_not_validated_from_non_matching_key() + { + $token = new OpenIdConnectIdToken($this->idToken(), 'https://auth.example.com', [ + array_merge($this->jwkKeyArray(), [ + 'n' => 'iqK-1QkICMf_cusNLpeNnN-bhT0-9WLBvzgwKLALRbrevhdi5ttrLHIQshaSL0DklzfyG2HWRmAnJ9Q7sweEjuRiiqRcSUZbYu8cIv2hLWYu7K_NH67D2WUjl0EnoHEuiVLsZhQe1CmdyLdx087j5nWkd64K49kXRSdxFQUlj8W3NeK3CjMEUdRQ3H4RZzJ4b7uuMiFA29S2ZhMNG20NPbkUVsFL-jiwTd10KSsPT8yBYipI9O7mWsUWt_8KZs1y_vpM_k3SyYihnWpssdzDm1uOZ8U3mzFr1xsLAO718GNUSXk6npSDzLl59HEqa6zs4O9awO2qnSHvcmyELNk31w' + ]) + ]); + $this->expectException(InvalidTokenException::class); + $this->expectExceptionMessage('Token signature could not be validated using the provided keys'); + $token->validate('abc'); + } + + public function test_error_thrown_if_token_signature_not_validated_from_invalid_key() + { + $token = new OpenIdConnectIdToken($this->idToken(), 'https://auth.example.com', ['url://example.com']); + $this->expectException(InvalidTokenException::class); + $this->expectExceptionMessage('Token signature could not be validated using the provided keys'); + $token->validate('abc'); + } + + public function test_error_thrown_if_token_algorithm_is_not_rs256() + { + $token = new OpenIdConnectIdToken($this->idToken([], ['alg' => 'HS256']), 'https://auth.example.com', []); + $this->expectException(InvalidTokenException::class); + $this->expectExceptionMessage("Only RS256 signature validation is supported. Token reports using HS256"); + $token->validate('abc'); + } + + public function test_token_claim_error_cases() + { + /** @var array $claimOverridesByErrorMessage */ + $claimOverridesByErrorMessage = [ + // 1. iss claim present + ['Missing or non-matching token issuer value', ['iss' => null]], + // 1. iss claim matches provided issuer + ['Missing or non-matching token issuer value', ['iss' => 'https://auth.example.co.uk']], + // 2. aud claim present + ['Missing token audience value', ['aud' => null]], + // 2. aud claim validates all values against those expected (Only expect single) + ['Token audience value has 2 values, Expected 1', ['aud' => ['abc', 'def']]], + // 2. aud claim matches client id + ['Token audience value did not match the expected client_id', ['aud' => 'xxyyzz.aaa.bbccdd.456']], + // 4. azp claim matches client id if present + ['Token authorized party exists but does not match the expected client_id', ['azp' => 'xxyyzz.aaa.bbccdd.456']], + // 5. exp claim present + ['Missing token expiration time value', ['exp' => null]], + // 5. exp claim not expired + ['Token has expired', ['exp' => time() - 360]], + // 6. iat claim present + ['Missing token issued at time value', ['iat' => null]], + // 6. iat claim too far in the future + ['Token issue at time is not recent or is invalid', ['iat' => time() + 600]], + // 6. iat claim too far in the past + ['Token issue at time is not recent or is invalid', ['iat' => time() - 172800]], + + // Custom: sub is present + ['Missing token subject value', ['sub' => null]], + ]; + + foreach ($claimOverridesByErrorMessage as [$message, $overrides]) { + $token = new OpenIdConnectIdToken($this->idToken($overrides), 'https://auth.example.com', [ + $this->jwkKeyArray() + ]); + + $err = null; + try { + $token->validate('xxyyzz.aaa.bbccdd.123'); + } catch (\Exception $exception) { + $err = $exception; + } + + $this->assertInstanceOf(InvalidTokenException::class, $err, $message); + $this->assertEquals($message, $err->getMessage()); + } + } + + public function test_keys_can_be_a_local_file_reference_to_pem_key() + { + $file = tmpfile(); + $testFilePath = 'file://' . stream_get_meta_data($file)['uri']; + file_put_contents($testFilePath, $this->pemKey()); + $token = new OpenIdConnectIdToken($this->idToken(), 'https://auth.example.com', [ + $testFilePath + ]); + + $this->assertTrue($token->validate('xxyyzz.aaa.bbccdd.123')); + unlink($testFilePath); + } + + protected function getDefaultPayload(): array + { + return [ "sub" => "abc1234def", "name" => "Barry Scott", "email" => "bscott@example.com", @@ -36,24 +180,36 @@ class OpenIdConnectIdTokenTest extends TestCase "auth_time" => time(), "at_hash" => "sT4jbsdSGy9w12pq3iNYDA", ]; + } - $payload = array_merge($defaultPayload, $payloadOverrides); - $header = [ + protected function idToken($payloadOverrides = [], $headerOverrides = []): string + { + $payload = array_merge($this->getDefaultPayload(), $payloadOverrides); + $header = array_merge([ 'kid' => 'xyz456', 'alg' => 'RS256', - ]; + ], $headerOverrides); $top = implode('.', [ $this->base64UrlEncode(json_encode($header)), $this->base64UrlEncode(json_encode($payload)), ]); - $privateKey = RSA::loadPrivateKey($this->privatePemKey())->withPadding(RSA::SIGNATURE_PKCS1); + $privateKey = $this->getPrivateKey(); $signature = $privateKey->sign($top); - return $top . '.' . $this->base64UrlEncode($signature); } + protected function getPrivateKey() + { + static $key; + if (is_null($key)) { + $key = RSA::loadPrivateKey($this->privatePemKey())->withPadding(RSA::SIGNATURE_PKCS1); + } + + return $key; + } + protected function base64UrlEncode(string $decoded): string { return strtr(base64_encode($decoded), '+/', '-_'); From 06a0d829c883f04da4365ea6a0cbb49cc7ef70c9 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 12 Oct 2021 23:00:52 +0100 Subject: [PATCH 24/50] Added OIDC basic autodiscovery support --- .env.example.complete | 1 + .../IssuerDiscoveryException.php | 8 + .../OpenIdConnectProviderSettings.php | 198 ++++++++++++++++++ .../OpenIdConnect/OpenIdConnectService.php | 53 +++-- app/Config/oidc.php | 7 +- composer.json | 1 + composer.lock | 72 ++++++- 7 files changed, 325 insertions(+), 15 deletions(-) create mode 100644 app/Auth/Access/OpenIdConnect/IssuerDiscoveryException.php create mode 100644 app/Auth/Access/OpenIdConnect/OpenIdConnectProviderSettings.php diff --git a/.env.example.complete b/.env.example.complete index e92eb5099..418875165 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -245,6 +245,7 @@ OIDC_DISPLAY_NAME_CLAIMS=name OIDC_CLIENT_ID=null OIDC_CLIENT_SECRET=null OIDC_ISSUER=null +OIDC_ISSUER_DISCOVER=false OIDC_PUBLIC_KEY=null OIDC_AUTH_ENDPOINT=null OIDC_TOKEN_ENDPOINT=null diff --git a/app/Auth/Access/OpenIdConnect/IssuerDiscoveryException.php b/app/Auth/Access/OpenIdConnect/IssuerDiscoveryException.php new file mode 100644 index 000000000..26dfca1fe --- /dev/null +++ b/app/Auth/Access/OpenIdConnect/IssuerDiscoveryException.php @@ -0,0 +1,8 @@ +applySettingsFromArray($settings); + $this->validateInitial(); + } + + /** + * Apply an array of settings to populate setting properties within this class. + */ + protected function applySettingsFromArray(array $settingsArray) + { + foreach ($settingsArray as $key => $value) { + if (property_exists($this, $key)) { + $this->$key = $value; + } + } + } + + /** + * Validate any core, required properties have been set. + * @throws InvalidArgumentException + */ + protected function validateInitial() + { + $required = ['clientId', 'clientSecret', 'redirectUri', 'issuer']; + foreach ($required as $prop) { + if (empty($this->$prop)) { + throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value"); + } + } + + if (strpos($this->issuer, 'https://') !== 0) { + throw new InvalidArgumentException("Issuer value must start with https://"); + } + } + + /** + * Perform a full validation on these settings. + * @throws InvalidArgumentException + */ + public function validate(): void + { + $this->validateInitial(); + $required = ['keys', 'tokenEndpoint', 'authorizationEndpoint']; + foreach ($required as $prop) { + if (empty($this->$prop)) { + throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value"); + } + } + } + + /** + * Discover and autoload settings from the configured issuer. + * @throws IssuerDiscoveryException + */ + public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes) + { + try { + $cacheKey = 'oidc-discovery::' . $this->issuer; + $discoveredSettings = $cache->remember($cacheKey, $cacheMinutes * 60, function() use ($httpClient) { + return $this->loadSettingsFromIssuerDiscovery($httpClient); + }); + $this->applySettingsFromArray($discoveredSettings); + } catch (ClientExceptionInterface $exception) { + throw new IssuerDiscoveryException("HTTP request failed during discovery with error: {$exception->getMessage()}"); + } + } + + /** + * @throws IssuerDiscoveryException + * @throws ClientExceptionInterface + */ + protected function loadSettingsFromIssuerDiscovery(ClientInterface $httpClient): array + { + $issuerUrl = rtrim($this->issuer, '/') . '/.well-known/openid-configuration'; + $request = new Request('GET', $issuerUrl); + $response = $httpClient->sendRequest($request); + $result = json_decode($response->getBody()->getContents(), true); + + if (empty($result) || !is_array($result)) { + throw new IssuerDiscoveryException("Error discovering provider settings from issuer at URL {$issuerUrl}"); + } + + if ($result['issuer'] !== $this->issuer) { + throw new IssuerDiscoveryException("Unexpected issuer value found on discovery response"); + } + + $discoveredSettings = []; + + if (!empty($result['authorization_endpoint'])) { + $discoveredSettings['authorizationEndpoint'] = $result['authorization_endpoint']; + } + + if (!empty($result['token_endpoint'])) { + $discoveredSettings['tokenEndpoint'] = $result['token_endpoint']; + } + + if (!empty($result['jwks_uri'])) { + $keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient); + $discoveredSettings['keys'] = array_filter($keys); + } + + return $discoveredSettings; + } + + /** + * Filter the given JWK keys down to just those we support. + */ + protected function filterKeys(array $keys): array + { + return array_filter($keys, function(array $key) { + return $key['key'] === 'RSA' && $key['use'] === 'sig' && $key['alg'] === 'RS256'; + }); + } + + /** + * Return an array of jwks as PHP key=>value arrays. + * @throws ClientExceptionInterface + * @throws IssuerDiscoveryException + */ + protected function loadKeysFromUri(string $uri, ClientInterface $httpClient): array + { + $request = new Request('GET', $uri); + $response = $httpClient->sendRequest($request); + $result = json_decode($response->getBody()->getContents(), true); + + if (empty($result) || !is_array($result) || !isset($result['keys'])) { + throw new IssuerDiscoveryException("Error reading keys from issuer jwks_uri"); + } + + return $result['keys']; + } + + /** + * Get the settings needed by an OAuth provider, as a key=>value array. + */ + public function arrayForProvider(): array + { + $settingKeys = ['clientId', 'clientSecret', 'redirectUri', 'authorizationEndpoint', 'tokenEndpoint']; + $settings = []; + foreach ($settingKeys as $setting) { + $settings[$setting] = $this->$setting; + } + return $settings; + } +} \ No newline at end of file diff --git a/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php b/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php index 7471a5007..57c9d1238 100644 --- a/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php +++ b/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php @@ -8,6 +8,9 @@ use BookStack\Exceptions\OpenIdConnectException; use BookStack\Exceptions\StoppedAuthenticationException; use BookStack\Exceptions\UserRegistrationException; use Exception; +use GuzzleHttp\Client; +use Illuminate\Support\Facades\Cache; +use Psr\Http\Client\ClientExceptionInterface; use function auth; use function config; use function trans; @@ -39,7 +42,8 @@ class OpenIdConnectService */ public function login(): array { - $provider = $this->getProvider(); + $settings = $this->getProviderSettings(); + $provider = $this->getProvider($settings); return [ 'url' => $provider->getAuthorizationUrl(), 'state' => $provider->getState(), @@ -52,34 +56,57 @@ class OpenIdConnectService * the authorization server. * Returns null if not authenticated. * @throws Exception + * @throws ClientExceptionInterface */ public function processAuthorizeResponse(?string $authorizationCode): ?User { - $provider = $this->getProvider(); + $settings = $this->getProviderSettings(); + $provider = $this->getProvider($settings); // Try to exchange authorization code for access token $accessToken = $provider->getAccessToken('authorization_code', [ 'code' => $authorizationCode, ]); - return $this->processAccessTokenCallback($accessToken); + return $this->processAccessTokenCallback($accessToken, $settings); } /** - * Load the underlying OpenID Connect Provider. + * @throws IssuerDiscoveryException + * @throws ClientExceptionInterface */ - protected function getProvider(): OpenIdConnectOAuthProvider + protected function getProviderSettings(): OpenIdConnectProviderSettings { - // Setup settings - $settings = [ + $settings = new OpenIdConnectProviderSettings([ + 'issuer' => $this->config['issuer'], 'clientId' => $this->config['client_id'], 'clientSecret' => $this->config['client_secret'], 'redirectUri' => url('/oidc/redirect'), 'authorizationEndpoint' => $this->config['authorization_endpoint'], 'tokenEndpoint' => $this->config['token_endpoint'], - ]; + ]); - return new OpenIdConnectOAuthProvider($settings); + // Use keys if configured + if (!empty($this->config['jwt_public_key'])) { + $settings->keys = [$this->config['jwt_public_key']]; + } + + // Run discovery + if ($this->config['discover'] ?? false) { + $settings->discoverFromIssuer(new Client(['timeout' => 3]), Cache::store(null), 15); + } + + $settings->validate(); + + return $settings; + } + + /** + * Load the underlying OpenID Connect Provider. + */ + protected function getProvider(OpenIdConnectProviderSettings $settings): OpenIdConnectOAuthProvider + { + return new OpenIdConnectOAuthProvider($settings->arrayForProvider()); } /** @@ -126,13 +153,13 @@ class OpenIdConnectService * @throws UserRegistrationException * @throws StoppedAuthenticationException */ - protected function processAccessTokenCallback(OpenIdConnectAccessToken $accessToken): User + protected function processAccessTokenCallback(OpenIdConnectAccessToken $accessToken, OpenIdConnectProviderSettings $settings): User { $idTokenText = $accessToken->getIdToken(); $idToken = new OpenIdConnectIdToken( $idTokenText, - $this->config['issuer'], - [$this->config['jwt_public_key']] + $settings->issuer, + $settings->keys, ); if ($this->config['dump_user_details']) { @@ -140,7 +167,7 @@ class OpenIdConnectService } try { - $idToken->validate($this->config['client_id']); + $idToken->validate($settings->clientId); } catch (InvalidTokenException $exception) { throw new OpenIdConnectException("ID token validate failed with error: {$exception->getMessage()}"); } diff --git a/app/Config/oidc.php b/app/Config/oidc.php index 43e8678ad..1b50d9d66 100644 --- a/app/Config/oidc.php +++ b/app/Config/oidc.php @@ -17,9 +17,14 @@ return [ // OAuth2/OpenId client secret, as configured in your Authorization server. 'client_secret' => env('OIDC_CLIENT_SECRET', null), - // The issuer of the identity token (id_token) this will be compared with what is returned in the token. + // The issuer of the identity token (id_token) this will be compared with + // what is returned in the token. 'issuer' => env('OIDC_ISSUER', null), + // Auto-discover the relevant endpoints and keys from the issuer. + // Fetched details are cached for 15 minutes. + 'discover' => env('OIDC_ISSUER_DISCOVER', false), + // Public key that's used to verify the JWT token with. // Can be the key value itself or a local 'file://public.key' reference. 'jwt_public_key' => env('OIDC_PUBLIC_KEY', null), diff --git a/composer.json b/composer.json index 066e67c4f..dc281e5a8 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,7 @@ "league/commonmark": "^1.5", "league/flysystem-aws-s3-v3": "^1.0.29", "league/html-to-markdown": "^5.0.0", + "league/oauth2-client": "^2.6", "nunomaduro/collision": "^3.1", "onelogin/php-saml": "^4.0", "phpseclib/phpseclib": "~3.0", diff --git a/composer.lock b/composer.lock index d64d8d640..89e408eb9 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ef6a8bb7bc6e99c70eeabc7695fc56eb", + "content-hash": "b82cfdfe8bb32847ba2188804858d5fd", "packages": [ { "name": "aws/aws-crt-php", @@ -2536,6 +2536,76 @@ }, "time": "2021-08-15T23:05:49+00:00" }, + { + "name": "league/oauth2-client", + "version": "2.6.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-client.git", + "reference": "badb01e62383430706433191b82506b6df24ad98" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/badb01e62383430706433191b82506b6df24ad98", + "reference": "badb01e62383430706433191b82506b6df24ad98", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^6.0 || ^7.0", + "paragonie/random_compat": "^1 || ^2 || ^9.99", + "php": "^5.6 || ^7.0 || ^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.3", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpunit/phpunit": "^5.7 || ^6.0 || ^9.3", + "squizlabs/php_codesniffer": "^2.3 || ^3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\OAuth2\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Bilbie", + "email": "hello@alexbilbie.com", + "homepage": "http://www.alexbilbie.com", + "role": "Developer" + }, + { + "name": "Woody Gilk", + "homepage": "https://github.com/shadowhand", + "role": "Contributor" + } + ], + "description": "OAuth 2.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "identity", + "idp", + "oauth", + "oauth2", + "single sign on" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth2-client/issues", + "source": "https://github.com/thephpleague/oauth2-client/tree/2.6.0" + }, + "time": "2020-10-28T02:03:40+00:00" + }, { "name": "monolog/monolog", "version": "2.3.5", From c167f40af32a45f0905c6d2961865fbe8c52d996 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 12 Oct 2021 23:04:28 +0100 Subject: [PATCH 25/50] Renamed OIDC files to all be aligned --- .../OidcAccessToken.php} | 4 +- .../OidcIdToken.php} | 46 +++++++++---------- .../Access/Oidc/OidcInvalidKeyException.php | 8 ++++ .../Access/Oidc/OidcInvalidTokenException.php | 10 ++++ .../Oidc/OidcIssuerDiscoveryException.php | 8 ++++ .../OidcJwtSigningKey.php} | 28 +++++------ .../OidcOAuthProvider.php} | 8 ++-- .../OidcProviderSettings.php} | 18 ++++---- .../OidcService.php} | 24 +++++----- .../OpenIdConnect/InvalidKeyException.php | 8 ---- .../OpenIdConnect/InvalidTokenException.php | 10 ---- .../IssuerDiscoveryException.php | 8 ---- .../Auth/OpenIdConnectController.php | 4 +- ...ectIdTokenTest.php => OidcIdTokenTest.php} | 40 ++++++++-------- 14 files changed, 112 insertions(+), 112 deletions(-) rename app/Auth/Access/{OpenIdConnect/OpenIdConnectAccessToken.php => Oidc/OidcAccessToken.php} (94%) rename app/Auth/Access/{OpenIdConnect/OpenIdConnectIdToken.php => Oidc/OidcIdToken.php} (78%) create mode 100644 app/Auth/Access/Oidc/OidcInvalidKeyException.php create mode 100644 app/Auth/Access/Oidc/OidcInvalidTokenException.php create mode 100644 app/Auth/Access/Oidc/OidcIssuerDiscoveryException.php rename app/Auth/Access/{OpenIdConnect/JwtSigningKey.php => Oidc/OidcJwtSigningKey.php} (65%) rename app/Auth/Access/{OpenIdConnect/OpenIdConnectOAuthProvider.php => Oidc/OidcOAuthProvider.php} (94%) rename app/Auth/Access/{OpenIdConnect/OpenIdConnectProviderSettings.php => Oidc/OidcProviderSettings.php} (89%) rename app/Auth/Access/{OpenIdConnect/OpenIdConnectService.php => Oidc/OidcService.php} (86%) delete mode 100644 app/Auth/Access/OpenIdConnect/InvalidKeyException.php delete mode 100644 app/Auth/Access/OpenIdConnect/InvalidTokenException.php delete mode 100644 app/Auth/Access/OpenIdConnect/IssuerDiscoveryException.php rename tests/Unit/{OpenIdConnectIdTokenTest.php => OidcIdTokenTest.php} (86%) diff --git a/app/Auth/Access/OpenIdConnect/OpenIdConnectAccessToken.php b/app/Auth/Access/Oidc/OidcAccessToken.php similarity index 94% rename from app/Auth/Access/OpenIdConnect/OpenIdConnectAccessToken.php rename to app/Auth/Access/Oidc/OidcAccessToken.php index 6731ec4be..63853e08a 100644 --- a/app/Auth/Access/OpenIdConnect/OpenIdConnectAccessToken.php +++ b/app/Auth/Access/Oidc/OidcAccessToken.php @@ -1,11 +1,11 @@ $prop) || !is_array($this->$prop)) { - throw new InvalidTokenException("Could not parse out a valid {$prop} within the provided token"); + throw new OidcInvalidTokenException("Could not parse out a valid {$prop} within the provided token"); } } if (empty($this->signature) || !is_string($this->signature)) { - throw new InvalidTokenException("Could not parse out a valid signature within the provided token"); + throw new OidcInvalidTokenException("Could not parse out a valid signature within the provided token"); } } /** * Validate the signature of the given token and ensure it validates against the provided key. - * @throws InvalidTokenException + * @throws OidcInvalidTokenException */ protected function validateTokenSignature(): void { if ($this->header['alg'] !== 'RS256') { - throw new InvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}"); + throw new OidcInvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}"); } $parsedKeys = array_map(function($key) { try { - return new JwtSigningKey($key); - } catch (InvalidKeyException $e) { + return new OidcJwtSigningKey($key); + } catch (OidcInvalidKeyException $e) { return null; } }, $this->keys); @@ -141,27 +141,27 @@ class OpenIdConnectIdToken $parsedKeys = array_filter($parsedKeys); $contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1]; - /** @var JwtSigningKey $parsedKey */ + /** @var OidcJwtSigningKey $parsedKey */ foreach ($parsedKeys as $parsedKey) { if ($parsedKey->verify($contentToSign, $this->signature)) { return; } } - throw new InvalidTokenException('Token signature could not be validated using the provided keys'); + throw new OidcInvalidTokenException('Token signature could not be validated using the provided keys'); } /** * Validate the claims of the token. * As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation - * @throws InvalidTokenException + * @throws OidcInvalidTokenException */ protected function validateTokenClaims(string $clientId): void { // 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery) // MUST exactly match the value of the iss (issuer) Claim. if (empty($this->payload['iss']) || $this->issuer !== $this->payload['iss']) { - throw new InvalidTokenException('Missing or non-matching token issuer value'); + throw new OidcInvalidTokenException('Missing or non-matching token issuer value'); } // 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered @@ -169,16 +169,16 @@ class OpenIdConnectIdToken // if the ID Token does not list the Client as a valid audience, or if it contains additional // audiences not trusted by the Client. if (empty($this->payload['aud'])) { - throw new InvalidTokenException('Missing token audience value'); + throw new OidcInvalidTokenException('Missing token audience value'); } $aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud']; if (count($aud) !== 1) { - throw new InvalidTokenException('Token audience value has ' . count($aud) . ' values, Expected 1'); + throw new OidcInvalidTokenException('Token audience value has ' . count($aud) . ' values, Expected 1'); } if ($aud[0] !== $clientId) { - throw new InvalidTokenException('Token audience value did not match the expected client_id'); + throw new OidcInvalidTokenException('Token audience value did not match the expected client_id'); } // 3. If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present. @@ -187,32 +187,32 @@ class OpenIdConnectIdToken // 4. If an azp (authorized party) Claim is present, the Client SHOULD verify that its client_id // is the Claim Value. if (isset($this->payload['azp']) && $this->payload['azp'] !== $clientId) { - throw new InvalidTokenException('Token authorized party exists but does not match the expected client_id'); + throw new OidcInvalidTokenException('Token authorized party exists but does not match the expected client_id'); } // 5. The current time MUST be before the time represented by the exp Claim // (possibly allowing for some small leeway to account for clock skew). if (empty($this->payload['exp'])) { - throw new InvalidTokenException('Missing token expiration time value'); + throw new OidcInvalidTokenException('Missing token expiration time value'); } $skewSeconds = 120; $now = time(); if ($now >= (intval($this->payload['exp']) + $skewSeconds)) { - throw new InvalidTokenException('Token has expired'); + throw new OidcInvalidTokenException('Token has expired'); } // 6. The iat Claim can be used to reject tokens that were issued too far away from the current time, // limiting the amount of time that nonces need to be stored to prevent attacks. // The acceptable range is Client specific. if (empty($this->payload['iat'])) { - throw new InvalidTokenException('Missing token issued at time value'); + throw new OidcInvalidTokenException('Missing token issued at time value'); } $dayAgo = time() - 86400; $iat = intval($this->payload['iat']); if ($iat > ($now + $skewSeconds) || $iat < $dayAgo) { - throw new InvalidTokenException('Token issue at time is not recent or is invalid'); + throw new OidcInvalidTokenException('Token issue at time is not recent or is invalid'); } // 7. If the acr Claim was requested, the Client SHOULD check that the asserted Claim Value is appropriate. @@ -225,7 +225,7 @@ class OpenIdConnectIdToken // Custom: Ensure the "sub" (Subject) Claim exists and has a value. if (empty($this->payload['sub'])) { - throw new InvalidTokenException('Missing token subject value'); + throw new OidcInvalidTokenException('Missing token subject value'); } } diff --git a/app/Auth/Access/Oidc/OidcInvalidKeyException.php b/app/Auth/Access/Oidc/OidcInvalidKeyException.php new file mode 100644 index 000000000..17c32f416 --- /dev/null +++ b/app/Auth/Access/Oidc/OidcInvalidKeyException.php @@ -0,0 +1,8 @@ + 'RSA', 'alg' => 'RS256', 'n' => 'abc123...'] * @param array|string $jwkOrKeyPath - * @throws InvalidKeyException + * @throws OidcInvalidKeyException */ public function __construct($jwkOrKeyPath) { @@ -29,12 +29,12 @@ class JwtSigningKey } else if (is_string($jwkOrKeyPath) && strpos($jwkOrKeyPath, 'file://') === 0) { $this->loadFromPath($jwkOrKeyPath); } else { - throw new InvalidKeyException('Unexpected type of key value provided'); + throw new OidcInvalidKeyException('Unexpected type of key value provided'); } } /** - * @throws InvalidKeyException + * @throws OidcInvalidKeyException */ protected function loadFromPath(string $path) { @@ -43,37 +43,37 @@ class JwtSigningKey file_get_contents($path) )->withPadding(RSA::SIGNATURE_PKCS1); } catch (\Exception $exception) { - throw new InvalidKeyException("Failed to load key from file path with error: {$exception->getMessage()}"); + throw new OidcInvalidKeyException("Failed to load key from file path with error: {$exception->getMessage()}"); } if (!($this->key instanceof RSA)) { - throw new InvalidKeyException("Key loaded from file path is not an RSA key as expected"); + throw new OidcInvalidKeyException("Key loaded from file path is not an RSA key as expected"); } } /** - * @throws InvalidKeyException + * @throws OidcInvalidKeyException */ protected function loadFromJwkArray(array $jwk) { if ($jwk['alg'] !== 'RS256') { - throw new InvalidKeyException("Only RS256 keys are currently supported. Found key using {$jwk['alg']}"); + throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$jwk['alg']}"); } if (empty($jwk['use'])) { - throw new InvalidKeyException('A "use" parameter on the provided key is expected'); + throw new OidcInvalidKeyException('A "use" parameter on the provided key is expected'); } if ($jwk['use'] !== 'sig') { - throw new InvalidKeyException("Only signature keys are currently supported. Found key for use {$jwk['use']}"); + throw new OidcInvalidKeyException("Only signature keys are currently supported. Found key for use {$jwk['use']}"); } if (empty($jwk['e'])) { - throw new InvalidKeyException('An "e" parameter on the provided key is expected'); + throw new OidcInvalidKeyException('An "e" parameter on the provided key is expected'); } if (empty($jwk['n'])) { - throw new InvalidKeyException('A "n" parameter on the provided key is expected'); + throw new OidcInvalidKeyException('A "n" parameter on the provided key is expected'); } $n = strtr($jwk['n'] ?? '', '-_', '+/'); @@ -85,7 +85,7 @@ class JwtSigningKey 'n' => new BigInteger(base64_decode($n), 256), ])->withPadding(RSA::SIGNATURE_PKCS1); } catch (\Exception $exception) { - throw new InvalidKeyException("Failed to load key from JWK parameters with error: {$exception->getMessage()}"); + throw new OidcInvalidKeyException("Failed to load key from JWK parameters with error: {$exception->getMessage()}"); } } diff --git a/app/Auth/Access/OpenIdConnect/OpenIdConnectOAuthProvider.php b/app/Auth/Access/Oidc/OidcOAuthProvider.php similarity index 94% rename from app/Auth/Access/OpenIdConnect/OpenIdConnectOAuthProvider.php rename to app/Auth/Access/Oidc/OidcOAuthProvider.php index 074f463cc..03230e373 100644 --- a/app/Auth/Access/OpenIdConnect/OpenIdConnectOAuthProvider.php +++ b/app/Auth/Access/Oidc/OidcOAuthProvider.php @@ -1,6 +1,6 @@ applySettingsFromArray($discoveredSettings); } catch (ClientExceptionInterface $exception) { - throw new IssuerDiscoveryException("HTTP request failed during discovery with error: {$exception->getMessage()}"); + throw new OidcIssuerDiscoveryException("HTTP request failed during discovery with error: {$exception->getMessage()}"); } } /** - * @throws IssuerDiscoveryException + * @throws OidcIssuerDiscoveryException * @throws ClientExceptionInterface */ protected function loadSettingsFromIssuerDiscovery(ClientInterface $httpClient): array @@ -130,11 +130,11 @@ class OpenIdConnectProviderSettings $result = json_decode($response->getBody()->getContents(), true); if (empty($result) || !is_array($result)) { - throw new IssuerDiscoveryException("Error discovering provider settings from issuer at URL {$issuerUrl}"); + throw new OidcIssuerDiscoveryException("Error discovering provider settings from issuer at URL {$issuerUrl}"); } if ($result['issuer'] !== $this->issuer) { - throw new IssuerDiscoveryException("Unexpected issuer value found on discovery response"); + throw new OidcIssuerDiscoveryException("Unexpected issuer value found on discovery response"); } $discoveredSettings = []; @@ -168,7 +168,7 @@ class OpenIdConnectProviderSettings /** * Return an array of jwks as PHP key=>value arrays. * @throws ClientExceptionInterface - * @throws IssuerDiscoveryException + * @throws OidcIssuerDiscoveryException */ protected function loadKeysFromUri(string $uri, ClientInterface $httpClient): array { @@ -177,7 +177,7 @@ class OpenIdConnectProviderSettings $result = json_decode($response->getBody()->getContents(), true); if (empty($result) || !is_array($result) || !isset($result['keys'])) { - throw new IssuerDiscoveryException("Error reading keys from issuer jwks_uri"); + throw new OidcIssuerDiscoveryException("Error reading keys from issuer jwks_uri"); } return $result['keys']; diff --git a/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php b/app/Auth/Access/Oidc/OidcService.php similarity index 86% rename from app/Auth/Access/OpenIdConnect/OpenIdConnectService.php rename to app/Auth/Access/Oidc/OidcService.php index 57c9d1238..be6a5c3c4 100644 --- a/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php +++ b/app/Auth/Access/Oidc/OidcService.php @@ -1,4 +1,4 @@ - $this->config['issuer'], 'clientId' => $this->config['client_id'], 'clientSecret' => $this->config['client_secret'], @@ -104,15 +104,15 @@ class OpenIdConnectService /** * Load the underlying OpenID Connect Provider. */ - protected function getProvider(OpenIdConnectProviderSettings $settings): OpenIdConnectOAuthProvider + protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider { - return new OpenIdConnectOAuthProvider($settings->arrayForProvider()); + return new OidcOAuthProvider($settings->arrayForProvider()); } /** * Calculate the display name */ - protected function getUserDisplayName(OpenIdConnectIdToken $token, string $defaultValue): string + protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string { $displayNameAttr = $this->config['display_name_claims']; @@ -135,7 +135,7 @@ class OpenIdConnectService * Extract the details of a user from an ID token. * @return array{name: string, email: string, external_id: string} */ - protected function getUserDetails(OpenIdConnectIdToken $token): array + protected function getUserDetails(OidcIdToken $token): array { $id = $token->getClaim('sub'); return [ @@ -153,10 +153,10 @@ class OpenIdConnectService * @throws UserRegistrationException * @throws StoppedAuthenticationException */ - protected function processAccessTokenCallback(OpenIdConnectAccessToken $accessToken, OpenIdConnectProviderSettings $settings): User + protected function processAccessTokenCallback(OidcAccessToken $accessToken, OidcProviderSettings $settings): User { $idTokenText = $accessToken->getIdToken(); - $idToken = new OpenIdConnectIdToken( + $idToken = new OidcIdToken( $idTokenText, $settings->issuer, $settings->keys, @@ -168,7 +168,7 @@ class OpenIdConnectService try { $idToken->validate($settings->clientId); - } catch (InvalidTokenException $exception) { + } catch (OidcInvalidTokenException $exception) { throw new OpenIdConnectException("ID token validate failed with error: {$exception->getMessage()}"); } diff --git a/app/Auth/Access/OpenIdConnect/InvalidKeyException.php b/app/Auth/Access/OpenIdConnect/InvalidKeyException.php deleted file mode 100644 index 85746cb6a..000000000 --- a/app/Auth/Access/OpenIdConnect/InvalidKeyException.php +++ /dev/null @@ -1,8 +0,0 @@ -oidcService = $oidcService; $this->middleware('guard:oidc'); diff --git a/tests/Unit/OpenIdConnectIdTokenTest.php b/tests/Unit/OidcIdTokenTest.php similarity index 86% rename from tests/Unit/OpenIdConnectIdTokenTest.php rename to tests/Unit/OidcIdTokenTest.php index d3394daf6..abc811f75 100644 --- a/tests/Unit/OpenIdConnectIdTokenTest.php +++ b/tests/Unit/OidcIdTokenTest.php @@ -2,16 +2,16 @@ namespace Tests\Unit; -use BookStack\Auth\Access\OpenIdConnect\InvalidTokenException; -use BookStack\Auth\Access\OpenIdConnect\OpenIdConnectIdToken; +use BookStack\Auth\Access\Oidc\OidcInvalidTokenException; +use BookStack\Auth\Access\Oidc\OidcIdToken; use phpseclib3\Crypt\RSA; use Tests\TestCase; -class OpenIdConnectIdTokenTest extends TestCase +class OidcIdTokenTest extends TestCase { public function test_valid_token_passes_validation() { - $token = new OpenIdConnectIdToken($this->idToken(), 'https://auth.example.com', [ + $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', [ $this->jwkKeyArray() ]); @@ -20,20 +20,20 @@ class OpenIdConnectIdTokenTest extends TestCase public function test_get_claim_returns_value_if_existing() { - $token = new OpenIdConnectIdToken($this->idToken(), 'https://auth.example.com', []); + $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', []); $this->assertEquals('bscott@example.com', $token->getClaim('email')); } public function test_get_claim_returns_null_if_not_existing() { - $token = new OpenIdConnectIdToken($this->idToken(), 'https://auth.example.com', []); + $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', []); $this->assertEquals(null, $token->getClaim('emails')); } public function test_get_all_claims_returns_all_payload_claims() { $defaultPayload = $this->getDefaultPayload(); - $token = new OpenIdConnectIdToken($this->idToken($defaultPayload), 'https://auth.example.com', []); + $token = new OidcIdToken($this->idToken($defaultPayload), 'https://auth.example.com', []); $this->assertEquals($defaultPayload, $token->getAllClaims()); } @@ -52,7 +52,7 @@ class OpenIdConnectIdTokenTest extends TestCase ]; foreach ($messagesAndTokenValues as [$message, $tokenValue]) { - $token = new OpenIdConnectIdToken($tokenValue, 'https://auth.example.com', []); + $token = new OidcIdToken($tokenValue, 'https://auth.example.com', []); $err = null; try { $token->validate('abc'); @@ -60,43 +60,43 @@ class OpenIdConnectIdTokenTest extends TestCase $err = $exception; } - $this->assertInstanceOf(InvalidTokenException::class, $err, $message); + $this->assertInstanceOf(OidcInvalidTokenException::class, $err, $message); $this->assertEquals($message, $err->getMessage()); } } public function test_error_thrown_if_token_signature_not_validated_from_no_keys() { - $token = new OpenIdConnectIdToken($this->idToken(), 'https://auth.example.com', []); - $this->expectException(InvalidTokenException::class); + $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', []); + $this->expectException(OidcInvalidTokenException::class); $this->expectExceptionMessage('Token signature could not be validated using the provided keys'); $token->validate('abc'); } public function test_error_thrown_if_token_signature_not_validated_from_non_matching_key() { - $token = new OpenIdConnectIdToken($this->idToken(), 'https://auth.example.com', [ + $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', [ array_merge($this->jwkKeyArray(), [ 'n' => 'iqK-1QkICMf_cusNLpeNnN-bhT0-9WLBvzgwKLALRbrevhdi5ttrLHIQshaSL0DklzfyG2HWRmAnJ9Q7sweEjuRiiqRcSUZbYu8cIv2hLWYu7K_NH67D2WUjl0EnoHEuiVLsZhQe1CmdyLdx087j5nWkd64K49kXRSdxFQUlj8W3NeK3CjMEUdRQ3H4RZzJ4b7uuMiFA29S2ZhMNG20NPbkUVsFL-jiwTd10KSsPT8yBYipI9O7mWsUWt_8KZs1y_vpM_k3SyYihnWpssdzDm1uOZ8U3mzFr1xsLAO718GNUSXk6npSDzLl59HEqa6zs4O9awO2qnSHvcmyELNk31w' ]) ]); - $this->expectException(InvalidTokenException::class); + $this->expectException(OidcInvalidTokenException::class); $this->expectExceptionMessage('Token signature could not be validated using the provided keys'); $token->validate('abc'); } public function test_error_thrown_if_token_signature_not_validated_from_invalid_key() { - $token = new OpenIdConnectIdToken($this->idToken(), 'https://auth.example.com', ['url://example.com']); - $this->expectException(InvalidTokenException::class); + $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', ['url://example.com']); + $this->expectException(OidcInvalidTokenException::class); $this->expectExceptionMessage('Token signature could not be validated using the provided keys'); $token->validate('abc'); } public function test_error_thrown_if_token_algorithm_is_not_rs256() { - $token = new OpenIdConnectIdToken($this->idToken([], ['alg' => 'HS256']), 'https://auth.example.com', []); - $this->expectException(InvalidTokenException::class); + $token = new OidcIdToken($this->idToken([], ['alg' => 'HS256']), 'https://auth.example.com', []); + $this->expectException(OidcInvalidTokenException::class); $this->expectExceptionMessage("Only RS256 signature validation is supported. Token reports using HS256"); $token->validate('abc'); } @@ -133,7 +133,7 @@ class OpenIdConnectIdTokenTest extends TestCase ]; foreach ($claimOverridesByErrorMessage as [$message, $overrides]) { - $token = new OpenIdConnectIdToken($this->idToken($overrides), 'https://auth.example.com', [ + $token = new OidcIdToken($this->idToken($overrides), 'https://auth.example.com', [ $this->jwkKeyArray() ]); @@ -144,7 +144,7 @@ class OpenIdConnectIdTokenTest extends TestCase $err = $exception; } - $this->assertInstanceOf(InvalidTokenException::class, $err, $message); + $this->assertInstanceOf(OidcInvalidTokenException::class, $err, $message); $this->assertEquals($message, $err->getMessage()); } } @@ -154,7 +154,7 @@ class OpenIdConnectIdTokenTest extends TestCase $file = tmpfile(); $testFilePath = 'file://' . stream_get_meta_data($file)['uri']; file_put_contents($testFilePath, $this->pemKey()); - $token = new OpenIdConnectIdToken($this->idToken(), 'https://auth.example.com', [ + $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', [ $testFilePath ]); From a5d72aa45840621c7bc7a00b2335f1bbea6c5c96 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 13 Oct 2021 16:51:27 +0100 Subject: [PATCH 26/50] Fleshed out testing for OIDC system --- app/Auth/Access/Oidc/OidcIdToken.php | 2 +- app/Auth/Access/Oidc/OidcService.php | 49 ++- ...nnectController.php => OidcController.php} | 13 +- app/Providers/AppServiceProvider.php | 6 + routes/web.php | 4 +- tests/Auth/AuthTest.php | 4 +- tests/Auth/OidcTest.php | 381 ++++++++++++++++++ tests/Auth/OpenIdTest.php | 112 ----- tests/Helpers/OidcJwtHelper.php | 132 ++++++ tests/SharedTestHelpers.php | 30 ++ tests/Unit/OidcIdTokenTest.php | 150 +------ 11 files changed, 609 insertions(+), 274 deletions(-) rename app/Http/Controllers/Auth/{OpenIdConnectController.php => OidcController.php} (73%) create mode 100644 tests/Auth/OidcTest.php delete mode 100644 tests/Auth/OpenIdTest.php create mode 100644 tests/Helpers/OidcJwtHelper.php diff --git a/app/Auth/Access/Oidc/OidcIdToken.php b/app/Auth/Access/Oidc/OidcIdToken.php index aa68e29a9..de9c42ab2 100644 --- a/app/Auth/Access/Oidc/OidcIdToken.php +++ b/app/Auth/Access/Oidc/OidcIdToken.php @@ -134,7 +134,7 @@ class OidcIdToken try { return new OidcJwtSigningKey($key); } catch (OidcInvalidKeyException $e) { - return null; + throw new OidcInvalidTokenException('Failed to read signing key with error: ' . $e->getMessage()); } }, $this->keys); diff --git a/app/Auth/Access/Oidc/OidcService.php b/app/Auth/Access/Oidc/OidcService.php index be6a5c3c4..d59d274e8 100644 --- a/app/Auth/Access/Oidc/OidcService.php +++ b/app/Auth/Access/Oidc/OidcService.php @@ -8,9 +8,10 @@ use BookStack\Exceptions\OpenIdConnectException; use BookStack\Exceptions\StoppedAuthenticationException; use BookStack\Exceptions\UserRegistrationException; use Exception; -use GuzzleHttp\Client; use Illuminate\Support\Facades\Cache; +use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider; use Psr\Http\Client\ClientExceptionInterface; +use Psr\Http\Client\ClientInterface as HttpClient; use function auth; use function config; use function trans; @@ -24,16 +25,16 @@ class OidcService { protected $registrationService; protected $loginService; - protected $config; + protected $httpClient; /** * OpenIdService constructor. */ - public function __construct(RegistrationService $registrationService, LoginService $loginService) + public function __construct(RegistrationService $registrationService, LoginService $loginService, HttpClient $httpClient) { - $this->config = config('oidc'); $this->registrationService = $registrationService; $this->loginService = $loginService; + $this->httpClient = $httpClient; } /** @@ -77,23 +78,24 @@ class OidcService */ protected function getProviderSettings(): OidcProviderSettings { + $config = $this->config(); $settings = new OidcProviderSettings([ - 'issuer' => $this->config['issuer'], - 'clientId' => $this->config['client_id'], - 'clientSecret' => $this->config['client_secret'], - 'redirectUri' => url('/oidc/redirect'), - 'authorizationEndpoint' => $this->config['authorization_endpoint'], - 'tokenEndpoint' => $this->config['token_endpoint'], + 'issuer' => $config['issuer'], + 'clientId' => $config['client_id'], + 'clientSecret' => $config['client_secret'], + 'redirectUri' => url('/oidc/callback'), + 'authorizationEndpoint' => $config['authorization_endpoint'], + 'tokenEndpoint' => $config['token_endpoint'], ]); // Use keys if configured - if (!empty($this->config['jwt_public_key'])) { - $settings->keys = [$this->config['jwt_public_key']]; + if (!empty($config['jwt_public_key'])) { + $settings->keys = [$config['jwt_public_key']]; } // Run discovery - if ($this->config['discover'] ?? false) { - $settings->discoverFromIssuer(new Client(['timeout' => 3]), Cache::store(null), 15); + if ($config['discover'] ?? false) { + $settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15); } $settings->validate(); @@ -106,7 +108,10 @@ class OidcService */ protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider { - return new OidcOAuthProvider($settings->arrayForProvider()); + return new OidcOAuthProvider($settings->arrayForProvider(), [ + 'httpClient' => $this->httpClient, + 'optionProvider' => new HttpBasicAuthOptionProvider(), + ]); } /** @@ -114,7 +119,7 @@ class OidcService */ protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string { - $displayNameAttr = $this->config['display_name_claims']; + $displayNameAttr = $this->config()['display_name_claims']; $displayName = []; foreach ($displayNameAttr as $dnAttr) { @@ -162,7 +167,7 @@ class OidcService $settings->keys, ); - if ($this->config['dump_user_details']) { + if ($this->config()['dump_user_details']) { throw new JsonDebugException($idToken->getAllClaims()); } @@ -175,7 +180,7 @@ class OidcService $userDetails = $this->getUserDetails($idToken); $isLoggedIn = auth()->check(); - if ($userDetails['email'] === null) { + if (empty($userDetails['email'])) { throw new OpenIdConnectException(trans('errors.oidc_no_email_address')); } @@ -194,4 +199,12 @@ class OidcService $this->loginService->login($user, 'oidc'); return $user; } + + /** + * Get the OIDC config from the application. + */ + protected function config(): array + { + return config('oidc'); + } } diff --git a/app/Http/Controllers/Auth/OpenIdConnectController.php b/app/Http/Controllers/Auth/OidcController.php similarity index 73% rename from app/Http/Controllers/Auth/OpenIdConnectController.php rename to app/Http/Controllers/Auth/OidcController.php index 03638847b..f4103cb0a 100644 --- a/app/Http/Controllers/Auth/OpenIdConnectController.php +++ b/app/Http/Controllers/Auth/OidcController.php @@ -6,7 +6,7 @@ use BookStack\Auth\Access\Oidc\OidcService; use BookStack\Http\Controllers\Controller; use Illuminate\Http\Request; -class OpenIdConnectController extends Controller +class OidcController extends Controller { protected $oidcService; @@ -32,10 +32,10 @@ class OpenIdConnectController extends Controller } /** - * Authorization flow redirect. + * Authorization flow redirect callback. * Processes authorization response from the OIDC Authorization Server. */ - public function redirect(Request $request) + public function callback(Request $request) { $storedState = session()->pull('oidc_state'); $responseState = $request->query('state'); @@ -45,12 +45,7 @@ class OpenIdConnectController extends Controller return redirect('/login'); } - $user = $this->oidcService->processAuthorizeResponse($request->query('code')); - if ($user === null) { - $this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')])); - return redirect('/login'); - } - + $this->oidcService->processAuthorizeResponse($request->query('code')); return redirect()->intended(); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8334bb179..18e1fb627 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -12,6 +12,7 @@ use BookStack\Entities\Models\Page; use BookStack\Settings\Setting; use BookStack\Settings\SettingService; use BookStack\Util\CspService; +use GuzzleHttp\Client; use Illuminate\Contracts\Cache\Repository; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\Blade; @@ -20,6 +21,7 @@ use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; use Laravel\Socialite\Contracts\Factory as SocialiteFactory; +use Psr\Http\Client\ClientInterface as HttpClientInterface; class AppServiceProvider extends ServiceProvider { @@ -76,5 +78,9 @@ class AppServiceProvider extends ServiceProvider $this->app->singleton(CspService::class, function ($app) { return new CspService(); }); + + $this->app->bind(HttpClientInterface::class, function($app) { + return new Client(['timeout' => 3]); + }); } } diff --git a/routes/web.php b/routes/web.php index 72e0392cc..254076451 100644 --- a/routes/web.php +++ b/routes/web.php @@ -268,8 +268,8 @@ Route::get('/saml2/sls', 'Auth\Saml2Controller@sls'); Route::post('/saml2/acs', 'Auth\Saml2Controller@acs'); // OIDC routes -Route::post('/oidc/login', 'Auth\OpenIdConnectController@login'); -Route::get('/oidc/redirect', 'Auth\OpenIdConnectController@redirect'); +Route::post('/oidc/login', 'Auth\OidcController@login'); +Route::get('/oidc/callback', 'Auth\OidcController@callback'); // User invitation routes Route::get('/register/invite/{token}', 'Auth\UserInviteController@showSetPassword'); diff --git a/tests/Auth/AuthTest.php b/tests/Auth/AuthTest.php index 1ffcc0815..d83f25557 100644 --- a/tests/Auth/AuthTest.php +++ b/tests/Auth/AuthTest.php @@ -318,7 +318,7 @@ class AuthTest extends TestCase $this->assertTrue(auth()->check()); $this->assertTrue(auth('ldap')->check()); $this->assertTrue(auth('saml2')->check()); - $this->assertTrue(auth('openid')->check()); + $this->assertTrue(auth('oidc')->check()); } public function test_login_authenticates_nonadmins_on_default_guard_only() @@ -331,7 +331,7 @@ class AuthTest extends TestCase $this->assertTrue(auth()->check()); $this->assertFalse(auth('ldap')->check()); $this->assertFalse(auth('saml2')->check()); - $this->assertFalse(auth('openid')->check()); + $this->assertFalse(auth('oidc')->check()); } public function test_failed_logins_are_logged_when_message_configured() diff --git a/tests/Auth/OidcTest.php b/tests/Auth/OidcTest.php new file mode 100644 index 000000000..cf04080fc --- /dev/null +++ b/tests/Auth/OidcTest.php @@ -0,0 +1,381 @@ +keyFile = tmpfile(); + $this->keyFilePath = 'file://' . stream_get_meta_data($this->keyFile)['uri']; + file_put_contents($this->keyFilePath, OidcJwtHelper::publicPemKey()); + + config()->set([ + 'auth.method' => 'oidc', + 'auth.defaults.guard' => 'oidc', + 'oidc.name' => 'SingleSignOn-Testing', + 'oidc.display_name_claims' => ['name'], + 'oidc.client_id' => OidcJwtHelper::defaultClientId(), + 'oidc.client_secret' => 'testpass', + 'oidc.jwt_public_key' => $this->keyFilePath, + 'oidc.issuer' => OidcJwtHelper::defaultIssuer(), + 'oidc.authorization_endpoint' => 'https://oidc.local/auth', + 'oidc.token_endpoint' => 'https://oidc.local/token', + 'oidc.discover' => false, + 'oidc.dump_user_details' => false, + ]); + } + + public function tearDown(): void + { + parent::tearDown(); + if (file_exists($this->keyFilePath)) { + unlink($this->keyFilePath); + } + } + + public function test_login_option_shows_on_login_page() + { + $req = $this->get('/login'); + $req->assertSeeText('SingleSignOn-Testing'); + $req->assertElementExists('form[action$="/oidc/login"][method=POST] button'); + } + + public function test_oidc_routes_are_only_active_if_oidc_enabled() + { + config()->set(['auth.method' => 'standard']); + $routes = ['/login' => 'post', '/callback' => 'get']; + foreach ($routes as $uri => $method) { + $req = $this->call($method, '/oidc' . $uri); + $this->assertPermissionError($req); + } + } + + public function test_forgot_password_routes_inaccessible() + { + $resp = $this->get('/password/email'); + $this->assertPermissionError($resp); + + $resp = $this->post('/password/email'); + $this->assertPermissionError($resp); + + $resp = $this->get('/password/reset/abc123'); + $this->assertPermissionError($resp); + + $resp = $this->post('/password/reset'); + $this->assertPermissionError($resp); + } + + public function test_standard_login_routes_inaccessible() + { + $resp = $this->post('/login'); + $this->assertPermissionError($resp); + } + + public function test_logout_route_functions() + { + $this->actingAs($this->getEditor()); + $this->get('/logout'); + $this->assertFalse(auth()->check()); + } + + public function test_user_invite_routes_inaccessible() + { + $resp = $this->get('/register/invite/abc123'); + $this->assertPermissionError($resp); + + $resp = $this->post('/register/invite/abc123'); + $this->assertPermissionError($resp); + } + + public function test_user_register_routes_inaccessible() + { + $resp = $this->get('/register'); + $this->assertPermissionError($resp); + + $resp = $this->post('/register'); + $this->assertPermissionError($resp); + } + + public function test_login() + { + $req = $this->post('/oidc/login'); + $redirect = $req->headers->get('location'); + + $this->assertStringStartsWith('https://oidc.local/auth', $redirect, 'Login redirects to SSO location'); + $this->assertFalse($this->isAuthenticated()); + $this->assertStringContainsString('scope=openid%20profile%20email', $redirect); + $this->assertStringContainsString('client_id=' . OidcJwtHelper::defaultClientId(), $redirect); + $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $redirect); + } + + public function test_login_success_flow() + { + // Start auth + $this->post('/oidc/login'); + $state = session()->get('oidc_state'); + + $transactions = &$this->mockHttpClient([$this->getMockAuthorizationResponse([ + 'email' => 'benny@example.com', + 'sub' => 'benny1010101' + ])]); + + // Callback from auth provider + // App calls token endpoint to get id token + $resp = $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state); + $resp->assertRedirect('/'); + $this->assertCount(1, $transactions); + /** @var Request $tokenRequest */ + $tokenRequest = $transactions[0]['request']; + $this->assertEquals('https://oidc.local/token', (string) $tokenRequest->getUri()); + $this->assertEquals('POST', $tokenRequest->getMethod()); + $this->assertEquals('Basic ' . base64_encode(OidcJwtHelper::defaultClientId() . ':testpass'), $tokenRequest->getHeader('Authorization')[0]); + $this->assertStringContainsString('grant_type=authorization_code', $tokenRequest->getBody()); + $this->assertStringContainsString('code=SplxlOBeZQQYbYS6WxSbIA', $tokenRequest->getBody()); + $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $tokenRequest->getBody()); + + + $this->assertTrue(auth()->check()); + $this->assertDatabaseHas('users', [ + 'email' => 'benny@example.com', + 'external_auth_id' => 'benny1010101', + 'email_confirmed' => false, + ]); + + $user = User::query()->where('email', '=', 'benny@example.com')->first(); + $this->assertActivityExists(ActivityType::AUTH_LOGIN, null, "oidc; ({$user->id}) Barry Scott"); + } + + public function test_callback_fails_if_no_state_present_or_matching() + { + $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124'); + $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization'); + + $this->post('/oidc/login'); + $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124'); + $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization'); + } + + public function test_dump_user_details_option_outputs_as_expected() + { + config()->set('oidc.dump_user_details', true); + + $resp = $this->runLogin([ + 'email' => 'benny@example.com', + 'sub' => 'benny505' + ]); + + $resp->assertStatus(200); + $resp->assertJson([ + 'email' => 'benny@example.com', + 'sub' => 'benny505', + "iss" => OidcJwtHelper::defaultIssuer(), + "aud" => OidcJwtHelper::defaultClientId(), + ]); + $this->assertFalse(auth()->check()); + } + + public function test_auth_fails_if_no_email_exists_in_user_data() + { + $this->runLogin([ + 'email' => '', + 'sub' => 'benny505' + ]); + + $this->assertSessionError('Could not find an email address, for this user, in the data provided by the external authentication system'); + } + + public function test_auth_fails_if_already_logged_in() + { + $this->asEditor(); + + $this->runLogin([ + 'email' => 'benny@example.com', + 'sub' => 'benny505' + ]); + + $this->assertSessionError('Already logged in'); + } + + public function test_auth_login_as_existing_user() + { + $editor = $this->getEditor(); + $editor->external_auth_id = 'benny505'; + $editor->save(); + + $this->assertFalse(auth()->check()); + + $this->runLogin([ + 'email' => 'benny@example.com', + 'sub' => 'benny505' + ]); + + $this->assertTrue(auth()->check()); + $this->assertEquals($editor->id, auth()->user()->id); + } + + public function test_auth_login_as_existing_user_email_with_different_auth_id_fails() + { + $editor = $this->getEditor(); + $editor->external_auth_id = 'editor101'; + $editor->save(); + + $this->assertFalse(auth()->check()); + + $this->runLogin([ + 'email' => $editor->email, + 'sub' => 'benny505' + ]); + + $this->assertSessionError('A user with the email ' . $editor->email . ' already exists but with different credentials.'); + $this->assertFalse(auth()->check()); + } + + public function test_auth_login_with_invalid_token_fails() + { + $this->runLogin([ + 'sub' => null, + ]); + + $this->assertSessionError('ID token validate failed with error: Missing token subject value'); + $this->assertFalse(auth()->check()); + } + + public function test_auth_login_with_autodiscovery() + { + $this->withAutodiscovery(); + + $transactions = &$this->mockHttpClient([ + $this->getAutoDiscoveryResponse(), + $this->getJwksResponse(), + ]); + + $this->assertFalse(auth()->check()); + + $this->runLogin(); + + $this->assertTrue(auth()->check()); + /** @var Request $discoverRequest */ + $discoverRequest = $transactions[0]['request']; + /** @var Request $discoverRequest */ + $keysRequest = $transactions[1]['request']; + + $this->assertEquals('GET', $keysRequest->getMethod()); + $this->assertEquals('GET', $discoverRequest->getMethod()); + $this->assertEquals(OidcJwtHelper::defaultIssuer() . '/.well-known/openid-configuration', $discoverRequest->getUri()); + $this->assertEquals(OidcJwtHelper::defaultIssuer() . '/oidc/keys', $keysRequest->getUri()); + } + + public function test_auth_fails_if_autodiscovery_fails() + { + $this->withAutodiscovery(); + $this->mockHttpClient([ + new Response(404, [], 'Not found'), + ]); + + $this->runLogin(); + $this->assertFalse(auth()->check()); + $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization'); + } + + public function test_autodiscovery_calls_are_cached() + { + $this->withAutodiscovery(); + + $transactions = &$this->mockHttpClient([ + $this->getAutoDiscoveryResponse(), + $this->getJwksResponse(), + $this->getAutoDiscoveryResponse([ + 'issuer' => 'https://auto.example.com' + ]), + $this->getJwksResponse(), + ]); + + // Initial run + $this->post('/oidc/login'); + $this->assertCount(2, $transactions); + // Second run, hits cache + $this->post('/oidc/login'); + $this->assertCount(2, $transactions); + + // Third run, different issuer, new cache key + config()->set(['oidc.issuer' => 'https://auto.example.com']); + $this->post('/oidc/login'); + $this->assertCount(4, $transactions); + } + + protected function withAutodiscovery() + { + config()->set([ + 'oidc.issuer' => OidcJwtHelper::defaultIssuer(), + 'oidc.discover' => true, + 'oidc.authorization_endpoint' => null, + 'oidc.token_endpoint' => null, + 'oidc.jwt_public_key' => null, + ]); + } + + protected function runLogin($claimOverrides = []): TestResponse + { + $this->post('/oidc/login'); + $state = session()->get('oidc_state'); + $this->mockHttpClient([$this->getMockAuthorizationResponse($claimOverrides)]); + + return $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state); + } + + protected function getAutoDiscoveryResponse($responseOverrides = []): Response + { + return new Response(200, [ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-cache, no-store', + 'Pragma' => 'no-cache' + ], json_encode(array_merge([ + 'token_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/token', + 'authorization_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/authorize', + 'jwks_uri' => OidcJwtHelper::defaultIssuer() . '/oidc/keys', + 'issuer' => OidcJwtHelper::defaultIssuer() + ], $responseOverrides))); + } + + protected function getJwksResponse(): Response + { + return new Response(200, [ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-cache, no-store', + 'Pragma' => 'no-cache' + ], json_encode([ + 'keys' => [ + OidcJwtHelper::publicJwkKeyArray() + ] + ])); + } + + protected function getMockAuthorizationResponse($claimOverrides = []): Response + { + return new Response(200, [ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-cache, no-store', + 'Pragma' => 'no-cache' + ], json_encode([ + 'access_token' => 'abc123', + 'token_type' => 'Bearer', + 'expires_in' => 3600, + 'id_token' => OidcJwtHelper::idToken($claimOverrides) + ])); + } +} diff --git a/tests/Auth/OpenIdTest.php b/tests/Auth/OpenIdTest.php deleted file mode 100644 index 9ad90fb5b..000000000 --- a/tests/Auth/OpenIdTest.php +++ /dev/null @@ -1,112 +0,0 @@ -set([ - 'auth.method' => 'openid', - 'auth.defaults.guard' => 'openid', - 'openid.name' => 'SingleSignOn-Testing', - 'openid.email_attribute' => 'email', - 'openid.display_name_attributes' => ['given_name', 'family_name'], - 'openid.external_id_attribute' => 'uid', - 'openid.openid_overrides' => null, - 'openid.openid.clientId' => 'testapp', - 'openid.openid.clientSecret' => 'testpass', - 'openid.openid.publicKey' => $this->testCert, - 'openid.openid.idTokenIssuer' => 'https://openid.local', - 'openid.openid.urlAuthorize' => 'https://openid.local/auth', - 'openid.openid.urlAccessToken' => 'https://openid.local/token', - ]); - } - - public function test_openid_overrides_functions_as_expected() - { - $json = '{"urlAuthorize": "https://openid.local/custom"}'; - config()->set(['openid.openid_overrides' => $json]); - - $req = $this->get('/openid/login'); - $redirect = $req->headers->get('location'); - $this->assertStringStartsWith('https://openid.local/custom', $redirect, 'Login redirects to SSO location'); - } - - public function test_login_option_shows_on_login_page() - { - $req = $this->get('/login'); - $req->assertSeeText('SingleSignOn-Testing'); - $req->assertElementExists('form[action$="/openid/login"][method=POST] button'); - } - - public function test_login() - { - $req = $this->post('/openid/login'); - $redirect = $req->headers->get('location'); - - $this->assertStringStartsWith('https://openid.local/auth', $redirect, 'Login redirects to SSO location'); - $this->assertFalse($this->isAuthenticated()); - } - - public function test_openid_routes_are_only_active_if_openid_enabled() - { - config()->set(['auth.method' => 'standard']); - $getRoutes = ['/logout', '/metadata', '/sls']; - foreach ($getRoutes as $route) { - $req = $this->get('/openid' . $route); - $this->assertPermissionError($req); - } - - $postRoutes = ['/login', '/acs']; - foreach ($postRoutes as $route) { - $req = $this->post('/openid' . $route); - $this->assertPermissionError($req); - } - } - - public function test_forgot_password_routes_inaccessible() - { - $resp = $this->get('/password/email'); - $this->assertPermissionError($resp); - - $resp = $this->post('/password/email'); - $this->assertPermissionError($resp); - - $resp = $this->get('/password/reset/abc123'); - $this->assertPermissionError($resp); - - $resp = $this->post('/password/reset'); - $this->assertPermissionError($resp); - } - - public function test_standard_login_routes_inaccessible() - { - $resp = $this->post('/login'); - $this->assertPermissionError($resp); - - $resp = $this->get('/logout'); - $this->assertPermissionError($resp); - } - - public function test_user_invite_routes_inaccessible() - { - $resp = $this->get('/register/invite/abc123'); - $this->assertPermissionError($resp); - - $resp = $this->post('/register/invite/abc123'); - $this->assertPermissionError($resp); - } - - public function test_user_register_routes_inaccessible() - { - $resp = $this->get('/register'); - $this->assertPermissionError($resp); - - $resp = $this->post('/register'); - $this->assertPermissionError($resp); - } -} diff --git a/tests/Helpers/OidcJwtHelper.php b/tests/Helpers/OidcJwtHelper.php new file mode 100644 index 000000000..0c44efb01 --- /dev/null +++ b/tests/Helpers/OidcJwtHelper.php @@ -0,0 +1,132 @@ + "abc1234def", + "name" => "Barry Scott", + "email" => "bscott@example.com", + "ver" => 1, + "iss" => static::defaultIssuer(), + "aud" => static::defaultClientId(), + "iat" => time(), + "exp" => time() + 720, + "jti" => "ID.AaaBBBbbCCCcccDDddddddEEEeeeeee", + "amr" => ["pwd"], + "idp" => "fghfghgfh546456dfgdfg", + "preferred_username" => "xXBazzaXx", + "auth_time" => time(), + "at_hash" => "sT4jbsdSGy9w12pq3iNYDA", + ]; + } + + public static function idToken($payloadOverrides = [], $headerOverrides = []): string + { + $payload = array_merge(static::defaultPayload(), $payloadOverrides); + $header = array_merge([ + 'kid' => 'xyz456', + 'alg' => 'RS256', + ], $headerOverrides); + + $top = implode('.', [ + static::base64UrlEncode(json_encode($header)), + static::base64UrlEncode(json_encode($payload)), + ]); + + $privateKey = static::privateKeyInstance(); + $signature = $privateKey->sign($top); + return $top . '.' . static::base64UrlEncode($signature); + } + + public static function privateKeyInstance() + { + static $key; + if (is_null($key)) { + $key = RSA::loadPrivateKey(static::privatePemKey())->withPadding(RSA::SIGNATURE_PKCS1); + } + + return $key; + } + + public static function base64UrlEncode(string $decoded): string + { + return strtr(base64_encode($decoded), '+/', '-_'); + } + + public static function publicPemKey(): string + { + return "-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqo1OmfNKec5S2zQC4SP9 +DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvm +zXL16c93Obn7G8x8A3ao6yN5qKO5S5+CETqOZfKN/g75Xlz7VsC3igOhgsXnPx6i +iM6sbYbk0U/XpFaT84LXKI8VTIPUo7gTeZN1pTET//i9FlzAOzX+xfWBKdOqlEzl ++zihMHCZUUvQu99P+o0MDR0lMUT+vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNk +WvsLta1+jNUee+8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw +3wIDAQAB +-----END PUBLIC KEY-----"; + } + + public static function privatePemKey(): string + { + return "-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCqjU6Z80p5zlLb +NALhI/0Ose5RHRWAKLqipwYRHPvOo7fqGqTcDdHdoKAmQSN+ducy6zNFEqzjk1te +g6n2m+bNcvXpz3c5ufsbzHwDdqjrI3moo7lLn4IROo5l8o3+DvleXPtWwLeKA6GC +xec/HqKIzqxthuTRT9ekVpPzgtcojxVMg9SjuBN5k3WlMRP/+L0WXMA7Nf7F9YEp +06qUTOX7OKEwcJlRS9C730/6jQwNHSUxRP688npIl5F+egd7HC3ptkWI2exkgQvT +dtfhA2Ra+wu1rX6M1R577wg9WHMI7xu8zzo3MtopQniTo1nkhWuZ0IWkWyMJYHI6 +sMbzB3DfAgMBAAECggEADm7K2ghWoxwsstQh8j+DaLzx9/dIHIJV2PHdd5FGVeRQ +6gS7MswQmHrBUrtsb4VMZ2iz/AJqkw+jScpGldH3pCc4XELsSfxNHbseO4TNIqjr +4LOKOLYU4bRc3I+8KGXIAI5JzrucTJemEVUCDrte8cjbmqExt+zTyNpyxsapworF +v+vnSdv40d62f+cS1xvwB+ymLK/B/wZ/DemDCi8jsi7ou/M7l5xNCzjH4iMSLtOW +fgEhejIBG9miMJWPiVpTXE3tMdNuN3OsWc4XXm2t4VRovlZdu30Fax1xWB+Locsv +HlHKLOFc8g+jZh0TL2KCNjPffMcC7kHhW3afshpIsQKBgQDhyWUnkqd6FzbwIX70 +SnaMgKoUv5W/K5T+Sv/PA2CyN8Gu8ih/OsoNZSnI0uqe3XQIvvgN/Fq3wO1ttLzf +z5B6ZC7REfTgcR0190gihk6f5rtcj7d6Fy/oG2CE8sDSXgPnpEaBjvJVgN5v/U2s +HpVaidmHTyGLCfEszoeoy8jyrQKBgQDBX8caGylmzQLc6XNntZChlt3e18Nj8MPA +DxWLcoqgdDoofLDQAmLl+vPKyDmhQjos5eas1jgmVVEM4ge+MysaVezvuLBsSnOh +ihc0i63USU6i7YDE83DrCewCthpFHi/wW1S5FoCAzpVy8y99vwcqO4kOXcmf4O6Y +uW6sMsjvOwKBgQDbFtqB+MtsLCSSBF61W6AHHD5tna4H75lG2623yXZF2NanFLF5 +K6muL9DI3ujtOMQETJJUt9+rWJjLEEsJ/dYa/SV0l7D/LKOEnyuu3JZkkLaTzZzi +6qcA2bfhqdCzEKlHV99WjkfV8hNlpex9rLuOPB8JLh7FVONicBGxF/UojQKBgDXs +IlYaSuI6utilVKQP0kPtEPOKERc2VS+iRSy8hQGXR3xwwNFQSQm+f+sFCGT6VcSd +W0TI+6Fc2xwPj38vP465dTentbKM1E+wdSYW6SMwSfhO6ECDbfJsst5Sr2Kkt1N7 +9FUkfDLu6GfEfnK/KR1SurZB2u51R7NYyg7EnplvAoGAT0aTtOcck0oYN30g5mdf +efqXPwg2wAPYeiec49EbfnteQQKAkqNfJ9K69yE2naf6bw3/5mCBsq/cXeuaBMII +ylysUIRBqt2J0kWm2yCpFWR7H+Ilhdx9A7ZLCqYVt8e+vjO/BOI3cQDe2VPOLPSl +q/1PY4iJviGKddtmfClH3v4= +-----END PRIVATE KEY-----"; + } + + public static function publicJwkKeyArray(): array + { + return [ + 'kty' => 'RSA', + 'alg' => 'RS256', + 'kid' => '066e52af-8884-4926-801d-032a276f9f2a', + 'use' => 'sig', + 'e' => 'AQAB', + 'n' => 'qo1OmfNKec5S2zQC4SP9DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvmzXL16c93Obn7G8x8A3ao6yN5qKO5S5-CETqOZfKN_g75Xlz7VsC3igOhgsXnPx6iiM6sbYbk0U_XpFaT84LXKI8VTIPUo7gTeZN1pTET__i9FlzAOzX-xfWBKdOqlEzl-zihMHCZUUvQu99P-o0MDR0lMUT-vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNkWvsLta1-jNUee-8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw3w', + ]; + } +} \ No newline at end of file diff --git a/tests/SharedTestHelpers.php b/tests/SharedTestHelpers.php index e4d27c849..606a3cd9e 100644 --- a/tests/SharedTestHelpers.php +++ b/tests/SharedTestHelpers.php @@ -18,6 +18,10 @@ use BookStack\Entities\Repos\ChapterRepo; use BookStack\Entities\Repos\PageRepo; use BookStack\Settings\SettingService; use BookStack\Uploads\HttpFetcher; +use GuzzleHttp\Client; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Middleware; use Illuminate\Foundation\Testing\Assert as PHPUnit; use Illuminate\Http\JsonResponse; use Illuminate\Support\Env; @@ -25,6 +29,7 @@ use Illuminate\Support\Facades\Log; use Mockery; use Monolog\Handler\TestHandler; use Monolog\Logger; +use Psr\Http\Client\ClientInterface; trait SharedTestHelpers { @@ -244,6 +249,22 @@ trait SharedTestHelpers ->andReturn($returnData); } + /** + * Mock the http client used in BookStack. + * Returns a reference to the container which holds all history of http transactions. + * @link https://docs.guzzlephp.org/en/stable/testing.html#history-middleware + */ + protected function &mockHttpClient(array $responses = []): array + { + $container = []; + $history = Middleware::history($container); + $mock = new MockHandler($responses); + $handlerStack = new HandlerStack($mock); + $handlerStack->push($history); + $this->app[ClientInterface::class] = new Client(['handler' => $handlerStack]); + return $container; + } + /** * Run a set test with the given env variable. * Remembers the original and resets the value after test. @@ -323,6 +344,15 @@ trait SharedTestHelpers ); } + /** + * Assert that the session has a particular error notification message set. + */ + protected function assertSessionError(string $message) + { + $error = session()->get('error'); + PHPUnit::assertTrue($error === $message, "Failed asserting the session contains an error. \nFound: {$error}\nExpecting: {$message}"); + } + /** * Set a test handler as the logging interface for the application. * Allows capture of logs for checking against during tests. diff --git a/tests/Unit/OidcIdTokenTest.php b/tests/Unit/OidcIdTokenTest.php index abc811f75..b08d578b3 100644 --- a/tests/Unit/OidcIdTokenTest.php +++ b/tests/Unit/OidcIdTokenTest.php @@ -4,15 +4,15 @@ namespace Tests\Unit; use BookStack\Auth\Access\Oidc\OidcInvalidTokenException; use BookStack\Auth\Access\Oidc\OidcIdToken; -use phpseclib3\Crypt\RSA; +use Tests\Helpers\OidcJwtHelper; use Tests\TestCase; class OidcIdTokenTest extends TestCase { public function test_valid_token_passes_validation() { - $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', [ - $this->jwkKeyArray() + $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), [ + OidcJwtHelper::publicJwkKeyArray() ]); $this->assertTrue($token->validate('xxyyzz.aaa.bbccdd.123')); @@ -20,26 +20,26 @@ class OidcIdTokenTest extends TestCase public function test_get_claim_returns_value_if_existing() { - $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', []); + $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), []); $this->assertEquals('bscott@example.com', $token->getClaim('email')); } public function test_get_claim_returns_null_if_not_existing() { - $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', []); + $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), []); $this->assertEquals(null, $token->getClaim('emails')); } public function test_get_all_claims_returns_all_payload_claims() { - $defaultPayload = $this->getDefaultPayload(); - $token = new OidcIdToken($this->idToken($defaultPayload), 'https://auth.example.com', []); + $defaultPayload = OidcJwtHelper::defaultPayload(); + $token = new OidcIdToken(OidcJwtHelper::idToken($defaultPayload), OidcJwtHelper::defaultIssuer(), []); $this->assertEquals($defaultPayload, $token->getAllClaims()); } public function test_token_structure_error_cases() { - $idToken = $this->idToken(); + $idToken = OidcJwtHelper::idToken(); $idTokenExploded = explode('.', $idToken); $messagesAndTokenValues = [ @@ -52,7 +52,7 @@ class OidcIdTokenTest extends TestCase ]; foreach ($messagesAndTokenValues as [$message, $tokenValue]) { - $token = new OidcIdToken($tokenValue, 'https://auth.example.com', []); + $token = new OidcIdToken($tokenValue, OidcJwtHelper::defaultIssuer(), []); $err = null; try { $token->validate('abc'); @@ -67,7 +67,7 @@ class OidcIdTokenTest extends TestCase public function test_error_thrown_if_token_signature_not_validated_from_no_keys() { - $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', []); + $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), []); $this->expectException(OidcInvalidTokenException::class); $this->expectExceptionMessage('Token signature could not be validated using the provided keys'); $token->validate('abc'); @@ -75,8 +75,8 @@ class OidcIdTokenTest extends TestCase public function test_error_thrown_if_token_signature_not_validated_from_non_matching_key() { - $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', [ - array_merge($this->jwkKeyArray(), [ + $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), [ + array_merge(OidcJwtHelper::publicJwkKeyArray(), [ 'n' => 'iqK-1QkICMf_cusNLpeNnN-bhT0-9WLBvzgwKLALRbrevhdi5ttrLHIQshaSL0DklzfyG2HWRmAnJ9Q7sweEjuRiiqRcSUZbYu8cIv2hLWYu7K_NH67D2WUjl0EnoHEuiVLsZhQe1CmdyLdx087j5nWkd64K49kXRSdxFQUlj8W3NeK3CjMEUdRQ3H4RZzJ4b7uuMiFA29S2ZhMNG20NPbkUVsFL-jiwTd10KSsPT8yBYipI9O7mWsUWt_8KZs1y_vpM_k3SyYihnWpssdzDm1uOZ8U3mzFr1xsLAO718GNUSXk6npSDzLl59HEqa6zs4O9awO2qnSHvcmyELNk31w' ]) ]); @@ -85,17 +85,17 @@ class OidcIdTokenTest extends TestCase $token->validate('abc'); } - public function test_error_thrown_if_token_signature_not_validated_from_invalid_key() + public function test_error_thrown_if_invalid_key_provided() { - $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', ['url://example.com']); + $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), ['url://example.com']); $this->expectException(OidcInvalidTokenException::class); - $this->expectExceptionMessage('Token signature could not be validated using the provided keys'); + $this->expectExceptionMessage('Unexpected type of key value provided'); $token->validate('abc'); } public function test_error_thrown_if_token_algorithm_is_not_rs256() { - $token = new OidcIdToken($this->idToken([], ['alg' => 'HS256']), 'https://auth.example.com', []); + $token = new OidcIdToken(OidcJwtHelper::idToken([], ['alg' => 'HS256']), OidcJwtHelper::defaultIssuer(), []); $this->expectException(OidcInvalidTokenException::class); $this->expectExceptionMessage("Only RS256 signature validation is supported. Token reports using HS256"); $token->validate('abc'); @@ -133,8 +133,8 @@ class OidcIdTokenTest extends TestCase ]; foreach ($claimOverridesByErrorMessage as [$message, $overrides]) { - $token = new OidcIdToken($this->idToken($overrides), 'https://auth.example.com', [ - $this->jwkKeyArray() + $token = new OidcIdToken(OidcJwtHelper::idToken($overrides), OidcJwtHelper::defaultIssuer(), [ + OidcJwtHelper::publicJwkKeyArray() ]); $err = null; @@ -153,122 +153,12 @@ class OidcIdTokenTest extends TestCase { $file = tmpfile(); $testFilePath = 'file://' . stream_get_meta_data($file)['uri']; - file_put_contents($testFilePath, $this->pemKey()); - $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', [ + file_put_contents($testFilePath, OidcJwtHelper::publicPemKey()); + $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), [ $testFilePath ]); $this->assertTrue($token->validate('xxyyzz.aaa.bbccdd.123')); unlink($testFilePath); } - - protected function getDefaultPayload(): array - { - return [ - "sub" => "abc1234def", - "name" => "Barry Scott", - "email" => "bscott@example.com", - "ver" => 1, - "iss" => "https://auth.example.com", - "aud" => "xxyyzz.aaa.bbccdd.123", - "iat" => time(), - "exp" => time() + 720, - "jti" => "ID.AaaBBBbbCCCcccDDddddddEEEeeeeee", - "amr" => ["pwd"], - "idp" => "fghfghgfh546456dfgdfg", - "preferred_username" => "xXBazzaXx", - "auth_time" => time(), - "at_hash" => "sT4jbsdSGy9w12pq3iNYDA", - ]; - } - - protected function idToken($payloadOverrides = [], $headerOverrides = []): string - { - $payload = array_merge($this->getDefaultPayload(), $payloadOverrides); - $header = array_merge([ - 'kid' => 'xyz456', - 'alg' => 'RS256', - ], $headerOverrides); - - $top = implode('.', [ - $this->base64UrlEncode(json_encode($header)), - $this->base64UrlEncode(json_encode($payload)), - ]); - - $privateKey = $this->getPrivateKey(); - $signature = $privateKey->sign($top); - return $top . '.' . $this->base64UrlEncode($signature); - } - - protected function getPrivateKey() - { - static $key; - if (is_null($key)) { - $key = RSA::loadPrivateKey($this->privatePemKey())->withPadding(RSA::SIGNATURE_PKCS1); - } - - return $key; - } - - protected function base64UrlEncode(string $decoded): string - { - return strtr(base64_encode($decoded), '+/', '-_'); - } - - protected function pemKey(): string - { - return "-----BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqo1OmfNKec5S2zQC4SP9 -DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvm -zXL16c93Obn7G8x8A3ao6yN5qKO5S5+CETqOZfKN/g75Xlz7VsC3igOhgsXnPx6i -iM6sbYbk0U/XpFaT84LXKI8VTIPUo7gTeZN1pTET//i9FlzAOzX+xfWBKdOqlEzl -+zihMHCZUUvQu99P+o0MDR0lMUT+vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNk -WvsLta1+jNUee+8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw -3wIDAQAB ------END PUBLIC KEY-----"; - } - - protected function privatePemKey(): string - { - return "-----BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCqjU6Z80p5zlLb -NALhI/0Ose5RHRWAKLqipwYRHPvOo7fqGqTcDdHdoKAmQSN+ducy6zNFEqzjk1te -g6n2m+bNcvXpz3c5ufsbzHwDdqjrI3moo7lLn4IROo5l8o3+DvleXPtWwLeKA6GC -xec/HqKIzqxthuTRT9ekVpPzgtcojxVMg9SjuBN5k3WlMRP/+L0WXMA7Nf7F9YEp -06qUTOX7OKEwcJlRS9C730/6jQwNHSUxRP688npIl5F+egd7HC3ptkWI2exkgQvT -dtfhA2Ra+wu1rX6M1R577wg9WHMI7xu8zzo3MtopQniTo1nkhWuZ0IWkWyMJYHI6 -sMbzB3DfAgMBAAECggEADm7K2ghWoxwsstQh8j+DaLzx9/dIHIJV2PHdd5FGVeRQ -6gS7MswQmHrBUrtsb4VMZ2iz/AJqkw+jScpGldH3pCc4XELsSfxNHbseO4TNIqjr -4LOKOLYU4bRc3I+8KGXIAI5JzrucTJemEVUCDrte8cjbmqExt+zTyNpyxsapworF -v+vnSdv40d62f+cS1xvwB+ymLK/B/wZ/DemDCi8jsi7ou/M7l5xNCzjH4iMSLtOW -fgEhejIBG9miMJWPiVpTXE3tMdNuN3OsWc4XXm2t4VRovlZdu30Fax1xWB+Locsv -HlHKLOFc8g+jZh0TL2KCNjPffMcC7kHhW3afshpIsQKBgQDhyWUnkqd6FzbwIX70 -SnaMgKoUv5W/K5T+Sv/PA2CyN8Gu8ih/OsoNZSnI0uqe3XQIvvgN/Fq3wO1ttLzf -z5B6ZC7REfTgcR0190gihk6f5rtcj7d6Fy/oG2CE8sDSXgPnpEaBjvJVgN5v/U2s -HpVaidmHTyGLCfEszoeoy8jyrQKBgQDBX8caGylmzQLc6XNntZChlt3e18Nj8MPA -DxWLcoqgdDoofLDQAmLl+vPKyDmhQjos5eas1jgmVVEM4ge+MysaVezvuLBsSnOh -ihc0i63USU6i7YDE83DrCewCthpFHi/wW1S5FoCAzpVy8y99vwcqO4kOXcmf4O6Y -uW6sMsjvOwKBgQDbFtqB+MtsLCSSBF61W6AHHD5tna4H75lG2623yXZF2NanFLF5 -K6muL9DI3ujtOMQETJJUt9+rWJjLEEsJ/dYa/SV0l7D/LKOEnyuu3JZkkLaTzZzi -6qcA2bfhqdCzEKlHV99WjkfV8hNlpex9rLuOPB8JLh7FVONicBGxF/UojQKBgDXs -IlYaSuI6utilVKQP0kPtEPOKERc2VS+iRSy8hQGXR3xwwNFQSQm+f+sFCGT6VcSd -W0TI+6Fc2xwPj38vP465dTentbKM1E+wdSYW6SMwSfhO6ECDbfJsst5Sr2Kkt1N7 -9FUkfDLu6GfEfnK/KR1SurZB2u51R7NYyg7EnplvAoGAT0aTtOcck0oYN30g5mdf -efqXPwg2wAPYeiec49EbfnteQQKAkqNfJ9K69yE2naf6bw3/5mCBsq/cXeuaBMII -ylysUIRBqt2J0kWm2yCpFWR7H+Ilhdx9A7ZLCqYVt8e+vjO/BOI3cQDe2VPOLPSl -q/1PY4iJviGKddtmfClH3v4= ------END PRIVATE KEY-----"; - } - - protected function jwkKeyArray(): array - { - return [ - 'kty' => 'RSA', - 'alg' => 'RS256', - 'kid' => '066e52af-8884-4926-801d-032a276f9f2a', - 'use' => 'sig', - 'e' => 'AQAB', - 'n' => 'qo1OmfNKec5S2zQC4SP9DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvmzXL16c93Obn7G8x8A3ao6yN5qKO5S5-CETqOZfKN_g75Xlz7VsC3igOhgsXnPx6iiM6sbYbk0U_XpFaT84LXKI8VTIPUo7gTeZN1pTET__i9FlzAOzX-xfWBKdOqlEzl-zihMHCZUUvQu99P-o0MDR0lMUT-vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNkWvsLta1-jNUee-8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw3w', - ]; - } } \ No newline at end of file From 855409bc4f839cde7c31ced8aac26cc924b5a223 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 14 Oct 2021 13:37:55 +0100 Subject: [PATCH 27/50] Fixed lack of oidc discovery filtering during testing Tested oidc system on okta, Keycloak & Auth0 --- app/Auth/Access/Oidc/OidcProviderSettings.php | 4 ++-- app/Providers/AppServiceProvider.php | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/Auth/Access/Oidc/OidcProviderSettings.php b/app/Auth/Access/Oidc/OidcProviderSettings.php index f1b530667..2b72c54b0 100644 --- a/app/Auth/Access/Oidc/OidcProviderSettings.php +++ b/app/Auth/Access/Oidc/OidcProviderSettings.php @@ -149,7 +149,7 @@ class OidcProviderSettings if (!empty($result['jwks_uri'])) { $keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient); - $discoveredSettings['keys'] = array_filter($keys); + $discoveredSettings['keys'] = $this->filterKeys($keys); } return $discoveredSettings; @@ -161,7 +161,7 @@ class OidcProviderSettings protected function filterKeys(array $keys): array { return array_filter($keys, function(array $key) { - return $key['key'] === 'RSA' && $key['use'] === 'sig' && $key['alg'] === 'RS256'; + return $key['kty'] === 'RSA' && $key['use'] === 'sig' && $key['alg'] === 'RS256'; }); } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 18e1fb627..5fce642cf 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -80,7 +80,9 @@ class AppServiceProvider extends ServiceProvider }); $this->app->bind(HttpClientInterface::class, function($app) { - return new Client(['timeout' => 3]); + return new Client([ + 'timeout' => 3, + ]); }); } } From 6e325de226fd930dae481a13ba80e002c6214a74 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 16 Oct 2021 16:01:59 +0100 Subject: [PATCH 28/50] Applied latest styles changes from style CI --- app/Auth/Access/GroupSyncService.php | 2 +- app/Auth/Access/LdapService.php | 1 + app/Auth/Access/Oidc/OidcAccessToken.php | 7 +- app/Auth/Access/Oidc/OidcIdToken.php | 18 ++-- .../Access/Oidc/OidcInvalidKeyException.php | 3 +- .../Access/Oidc/OidcInvalidTokenException.php | 3 +- .../Oidc/OidcIssuerDiscoveryException.php | 3 +- app/Auth/Access/Oidc/OidcJwtSigningKey.php | 11 +-- app/Auth/Access/Oidc/OidcOAuthProvider.php | 18 ++-- app/Auth/Access/Oidc/OidcProviderSettings.php | 17 ++-- app/Auth/Access/Oidc/OidcService.php | 39 +++++--- app/Auth/Access/RegistrationService.php | 1 + app/Auth/Access/Saml2Service.php | 28 +++--- app/Config/auth.php | 2 +- app/Config/oidc.php | 2 +- app/Exceptions/OpenIdConnectException.php | 5 +- app/Http/Controllers/Auth/OidcController.php | 3 +- app/Providers/AppServiceProvider.php | 4 +- app/Providers/AuthServiceProvider.php | 2 +- tests/Auth/OidcTest.php | 88 +++++++++---------- tests/Helpers/OidcJwtHelper.php | 47 +++++----- tests/SharedTestHelpers.php | 2 + tests/Unit/OidcIdTokenTest.php | 18 ++-- 23 files changed, 177 insertions(+), 147 deletions(-) diff --git a/app/Auth/Access/GroupSyncService.php b/app/Auth/Access/GroupSyncService.php index ddd539b77..db19b007a 100644 --- a/app/Auth/Access/GroupSyncService.php +++ b/app/Auth/Access/GroupSyncService.php @@ -73,4 +73,4 @@ class GroupSyncService $user->roles()->syncWithoutDetaching($groupsAsRoles); } } -} \ No newline at end of file +} diff --git a/app/Auth/Access/LdapService.php b/app/Auth/Access/LdapService.php index ddd6ada97..e3a38537a 100644 --- a/app/Auth/Access/LdapService.php +++ b/app/Auth/Access/LdapService.php @@ -285,6 +285,7 @@ class LdapService } $userGroups = $this->groupFilter($user); + return $this->getGroupsRecursive($userGroups, []); } diff --git a/app/Auth/Access/Oidc/OidcAccessToken.php b/app/Auth/Access/Oidc/OidcAccessToken.php index 63853e08a..520966f6a 100644 --- a/app/Auth/Access/Oidc/OidcAccessToken.php +++ b/app/Auth/Access/Oidc/OidcAccessToken.php @@ -11,7 +11,8 @@ class OidcAccessToken extends AccessToken * Constructs an access token. * * @param array $options An array of options returned by the service provider - * in the access token request. The `access_token` option is required. + * in the access token request. The `access_token` option is required. + * * @throws InvalidArgumentException if `access_token` is not provided in `$options`. */ public function __construct(array $options = []) @@ -20,7 +21,6 @@ class OidcAccessToken extends AccessToken $this->validate($options); } - /** * Validate this access token response for OIDC. * As per https://openid.net/specs/openid-connect-basic-1_0.html#TokenOK. @@ -50,5 +50,4 @@ class OidcAccessToken extends AccessToken { return $this->getValues()['id_token']; } - -} \ No newline at end of file +} diff --git a/app/Auth/Access/Oidc/OidcIdToken.php b/app/Auth/Access/Oidc/OidcIdToken.php index de9c42ab2..c955c3b09 100644 --- a/app/Auth/Access/Oidc/OidcIdToken.php +++ b/app/Auth/Access/Oidc/OidcIdToken.php @@ -60,6 +60,7 @@ class OidcIdToken { $json = $this->base64UrlDecode($part) ?: '{}'; $decoded = json_decode($json, true); + return is_array($decoded) ? $decoded : []; } @@ -74,6 +75,7 @@ class OidcIdToken /** * Validate all possible parts of the id token. + * * @throws OidcInvalidTokenException */ public function validate(string $clientId): bool @@ -81,12 +83,14 @@ class OidcIdToken $this->validateTokenStructure(); $this->validateTokenSignature(); $this->validateTokenClaims($clientId); + return true; } /** * Fetch a specific claim from this token. * Returns null if it is null or does not exist. + * * @return mixed|null */ public function getClaim(string $claim) @@ -104,7 +108,8 @@ class OidcIdToken /** * Validate the structure of the given token and ensure we have the required pieces. - * As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2 + * As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2. + * * @throws OidcInvalidTokenException */ protected function validateTokenStructure(): void @@ -116,12 +121,13 @@ class OidcIdToken } if (empty($this->signature) || !is_string($this->signature)) { - throw new OidcInvalidTokenException("Could not parse out a valid signature within the provided token"); + throw new OidcInvalidTokenException('Could not parse out a valid signature within the provided token'); } } /** * Validate the signature of the given token and ensure it validates against the provided key. + * * @throws OidcInvalidTokenException */ protected function validateTokenSignature(): void @@ -130,7 +136,7 @@ class OidcIdToken throw new OidcInvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}"); } - $parsedKeys = array_map(function($key) { + $parsedKeys = array_map(function ($key) { try { return new OidcJwtSigningKey($key); } catch (OidcInvalidKeyException $e) { @@ -153,7 +159,8 @@ class OidcIdToken /** * Validate the claims of the token. - * As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation + * As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation. + * * @throws OidcInvalidTokenException */ protected function validateTokenClaims(string $clientId): void @@ -228,5 +235,4 @@ class OidcIdToken throw new OidcInvalidTokenException('Missing token subject value'); } } - -} \ No newline at end of file +} diff --git a/app/Auth/Access/Oidc/OidcInvalidKeyException.php b/app/Auth/Access/Oidc/OidcInvalidKeyException.php index 17c32f416..1b3310e06 100644 --- a/app/Auth/Access/Oidc/OidcInvalidKeyException.php +++ b/app/Auth/Access/Oidc/OidcInvalidKeyException.php @@ -4,5 +4,4 @@ namespace BookStack\Auth\Access\Oidc; class OidcInvalidKeyException extends \Exception { - -} \ No newline at end of file +} diff --git a/app/Auth/Access/Oidc/OidcInvalidTokenException.php b/app/Auth/Access/Oidc/OidcInvalidTokenException.php index 30ac79280..4f47eb08f 100644 --- a/app/Auth/Access/Oidc/OidcInvalidTokenException.php +++ b/app/Auth/Access/Oidc/OidcInvalidTokenException.php @@ -6,5 +6,4 @@ use Exception; class OidcInvalidTokenException extends Exception { - -} \ No newline at end of file +} diff --git a/app/Auth/Access/Oidc/OidcIssuerDiscoveryException.php b/app/Auth/Access/Oidc/OidcIssuerDiscoveryException.php index 47c49c625..e2f364e89 100644 --- a/app/Auth/Access/Oidc/OidcIssuerDiscoveryException.php +++ b/app/Auth/Access/Oidc/OidcIssuerDiscoveryException.php @@ -4,5 +4,4 @@ namespace BookStack\Auth\Access\Oidc; class OidcIssuerDiscoveryException extends \Exception { - -} \ No newline at end of file +} diff --git a/app/Auth/Access/Oidc/OidcJwtSigningKey.php b/app/Auth/Access/Oidc/OidcJwtSigningKey.php index 3e77cf331..9a5b3833a 100644 --- a/app/Auth/Access/Oidc/OidcJwtSigningKey.php +++ b/app/Auth/Access/Oidc/OidcJwtSigningKey.php @@ -18,15 +18,17 @@ class OidcJwtSigningKey * Can be created either from a JWK parameter array or local file path to load a certificate from. * Examples: * 'file:///var/www/cert.pem' - * ['kty' => 'RSA', 'alg' => 'RS256', 'n' => 'abc123...'] + * ['kty' => 'RSA', 'alg' => 'RS256', 'n' => 'abc123...']. + * * @param array|string $jwkOrKeyPath + * * @throws OidcInvalidKeyException */ public function __construct($jwkOrKeyPath) { if (is_array($jwkOrKeyPath)) { $this->loadFromJwkArray($jwkOrKeyPath); - } else if (is_string($jwkOrKeyPath) && strpos($jwkOrKeyPath, 'file://') === 0) { + } elseif (is_string($jwkOrKeyPath) && strpos($jwkOrKeyPath, 'file://') === 0) { $this->loadFromPath($jwkOrKeyPath); } else { throw new OidcInvalidKeyException('Unexpected type of key value provided'); @@ -47,7 +49,7 @@ class OidcJwtSigningKey } if (!($this->key instanceof RSA)) { - throw new OidcInvalidKeyException("Key loaded from file path is not an RSA key as expected"); + throw new OidcInvalidKeyException('Key loaded from file path is not an RSA key as expected'); } } @@ -104,5 +106,4 @@ class OidcJwtSigningKey { return $this->key->toString('PKCS8'); } - -} \ No newline at end of file +} diff --git a/app/Auth/Access/Oidc/OidcOAuthProvider.php b/app/Auth/Access/Oidc/OidcOAuthProvider.php index 03230e373..9b9d0524c 100644 --- a/app/Auth/Access/Oidc/OidcOAuthProvider.php +++ b/app/Auth/Access/Oidc/OidcOAuthProvider.php @@ -30,7 +30,6 @@ class OidcOAuthProvider extends AbstractProvider */ protected $tokenEndpoint; - /** * Returns the base URL for authorizing a client. */ @@ -66,7 +65,6 @@ class OidcOAuthProvider extends AbstractProvider return ['openid', 'profile', 'email']; } - /** * Returns the string that should be used to separate scopes when building * the URL for requesting an access token. @@ -80,9 +78,11 @@ class OidcOAuthProvider extends AbstractProvider * Checks a provider response for errors. * * @param ResponseInterface $response - * @param array|string $data Parsed response data - * @return void + * @param array|string $data Parsed response data + * * @throws IdentityProviderException + * + * @return void */ protected function checkResponse(ResponseInterface $response, $data) { @@ -99,8 +99,9 @@ class OidcOAuthProvider extends AbstractProvider * Generates a resource owner object from a successful resource owner * details request. * - * @param array $response + * @param array $response * @param AccessToken $token + * * @return ResourceOwnerInterface */ protected function createResourceOwner(array $response, AccessToken $token) @@ -114,14 +115,13 @@ class OidcOAuthProvider extends AbstractProvider * The grant that was used to fetch the response can be used to provide * additional context. * - * @param array $response + * @param array $response * @param AbstractGrant $grant + * * @return OidcAccessToken */ protected function createAccessToken(array $response, AbstractGrant $grant) { return new OidcAccessToken($response); } - - -} \ No newline at end of file +} diff --git a/app/Auth/Access/Oidc/OidcProviderSettings.php b/app/Auth/Access/Oidc/OidcProviderSettings.php index 2b72c54b0..32946d058 100644 --- a/app/Auth/Access/Oidc/OidcProviderSettings.php +++ b/app/Auth/Access/Oidc/OidcProviderSettings.php @@ -70,6 +70,7 @@ class OidcProviderSettings /** * Validate any core, required properties have been set. + * * @throws InvalidArgumentException */ protected function validateInitial() @@ -82,12 +83,13 @@ class OidcProviderSettings } if (strpos($this->issuer, 'https://') !== 0) { - throw new InvalidArgumentException("Issuer value must start with https://"); + throw new InvalidArgumentException('Issuer value must start with https://'); } } /** * Perform a full validation on these settings. + * * @throws InvalidArgumentException */ public function validate(): void @@ -103,13 +105,14 @@ class OidcProviderSettings /** * Discover and autoload settings from the configured issuer. + * * @throws OidcIssuerDiscoveryException */ public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes) { try { $cacheKey = 'oidc-discovery::' . $this->issuer; - $discoveredSettings = $cache->remember($cacheKey, $cacheMinutes * 60, function() use ($httpClient) { + $discoveredSettings = $cache->remember($cacheKey, $cacheMinutes * 60, function () use ($httpClient) { return $this->loadSettingsFromIssuerDiscovery($httpClient); }); $this->applySettingsFromArray($discoveredSettings); @@ -134,7 +137,7 @@ class OidcProviderSettings } if ($result['issuer'] !== $this->issuer) { - throw new OidcIssuerDiscoveryException("Unexpected issuer value found on discovery response"); + throw new OidcIssuerDiscoveryException('Unexpected issuer value found on discovery response'); } $discoveredSettings = []; @@ -160,13 +163,14 @@ class OidcProviderSettings */ protected function filterKeys(array $keys): array { - return array_filter($keys, function(array $key) { + return array_filter($keys, function (array $key) { return $key['kty'] === 'RSA' && $key['use'] === 'sig' && $key['alg'] === 'RS256'; }); } /** * Return an array of jwks as PHP key=>value arrays. + * * @throws ClientExceptionInterface * @throws OidcIssuerDiscoveryException */ @@ -177,7 +181,7 @@ class OidcProviderSettings $result = json_decode($response->getBody()->getContents(), true); if (empty($result) || !is_array($result) || !isset($result['keys'])) { - throw new OidcIssuerDiscoveryException("Error reading keys from issuer jwks_uri"); + throw new OidcIssuerDiscoveryException('Error reading keys from issuer jwks_uri'); } return $result['keys']; @@ -193,6 +197,7 @@ class OidcProviderSettings foreach ($settingKeys as $setting) { $settings[$setting] = $this->$setting; } + return $settings; } -} \ No newline at end of file +} diff --git a/app/Auth/Access/Oidc/OidcService.php b/app/Auth/Access/Oidc/OidcService.php index d59d274e8..b8e017b4b 100644 --- a/app/Auth/Access/Oidc/OidcService.php +++ b/app/Auth/Access/Oidc/OidcService.php @@ -1,5 +1,8 @@ -getProviderSettings(); $provider = $this->getProvider($settings); + return [ - 'url' => $provider->getAuthorizationUrl(), + 'url' => $provider->getAuthorizationUrl(), 'state' => $provider->getState(), ]; } @@ -56,6 +60,7 @@ class OidcService * return the matching, or new if registration active, user matched to * the authorization server. * Returns null if not authenticated. + * * @throws Exception * @throws ClientExceptionInterface */ @@ -80,12 +85,12 @@ class OidcService { $config = $this->config(); $settings = new OidcProviderSettings([ - 'issuer' => $config['issuer'], - 'clientId' => $config['client_id'], - 'clientSecret' => $config['client_secret'], - 'redirectUri' => url('/oidc/callback'), + 'issuer' => $config['issuer'], + 'clientId' => $config['client_id'], + 'clientSecret' => $config['client_secret'], + 'redirectUri' => url('/oidc/callback'), 'authorizationEndpoint' => $config['authorization_endpoint'], - 'tokenEndpoint' => $config['token_endpoint'], + 'tokenEndpoint' => $config['token_endpoint'], ]); // Use keys if configured @@ -109,13 +114,13 @@ class OidcService protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider { return new OidcOAuthProvider($settings->arrayForProvider(), [ - 'httpClient' => $this->httpClient, + 'httpClient' => $this->httpClient, 'optionProvider' => new HttpBasicAuthOptionProvider(), ]); } /** - * Calculate the display name + * Calculate the display name. */ protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string { @@ -138,21 +143,24 @@ class OidcService /** * Extract the details of a user from an ID token. + * * @return array{name: string, email: string, external_id: string} */ protected function getUserDetails(OidcIdToken $token): array { $id = $token->getClaim('sub'); + return [ 'external_id' => $id, - 'email' => $token->getClaim('email'), - 'name' => $this->getUserDisplayName($token, $id), + 'email' => $token->getClaim('email'), + 'name' => $this->getUserDisplayName($token, $id), ]; } /** * Processes a received access token for a user. Login the user when * they exist, optionally registering them automatically. + * * @throws OpenIdConnectException * @throws JsonDebugException * @throws UserRegistrationException @@ -189,7 +197,9 @@ class OidcService } $user = $this->registrationService->findOrRegister( - $userDetails['name'], $userDetails['email'], $userDetails['external_id'] + $userDetails['name'], + $userDetails['email'], + $userDetails['external_id'] ); if ($user === null) { @@ -197,6 +207,7 @@ class OidcService } $this->loginService->login($user, 'oidc'); + return $user; } diff --git a/app/Auth/Access/RegistrationService.php b/app/Auth/Access/RegistrationService.php index 48970bd2e..dcdb68bd5 100644 --- a/app/Auth/Access/RegistrationService.php +++ b/app/Auth/Access/RegistrationService.php @@ -54,6 +54,7 @@ class RegistrationService /** * Attempt to find a user in the system otherwise register them as a new * user. For use with external auth systems since password is auto-generated. + * * @throws UserRegistrationException */ public function findOrRegister(string $name, string $email, string $externalId): User diff --git a/app/Auth/Access/Saml2Service.php b/app/Auth/Access/Saml2Service.php index 8e076f86c..6d3915c4d 100644 --- a/app/Auth/Access/Saml2Service.php +++ b/app/Auth/Access/Saml2Service.php @@ -29,10 +29,9 @@ class Saml2Service */ public function __construct( RegistrationService $registrationService, - LoginService $loginService, - GroupSyncService $groupSyncService - ) - { + LoginService $loginService, + GroupSyncService $groupSyncService + ) { $this->config = config('saml2'); $this->registrationService = $registrationService; $this->loginService = $loginService; @@ -51,7 +50,7 @@ class Saml2Service return [ 'url' => $toolKit->login($returnRoute, [], false, false, true), - 'id' => $toolKit->getLastRequestID(), + 'id' => $toolKit->getLastRequestID(), ]; } @@ -200,7 +199,7 @@ class Saml2Service protected function loadOneloginServiceProviderDetails(): array { $spDetails = [ - 'entityId' => url('/saml2/metadata'), + 'entityId' => url('/saml2/metadata'), 'assertionConsumerService' => [ 'url' => url('/saml2/acs'), ], @@ -211,7 +210,7 @@ class Saml2Service return [ 'baseurl' => url('/saml2'), - 'sp' => $spDetails, + 'sp' => $spDetails, ]; } @@ -263,6 +262,7 @@ class Saml2Service /** * Extract the details of a user from a SAML response. + * * @return array{external_id: string, name: string, email: string, saml_id: string} */ protected function getUserDetails(string $samlID, $samlAttributes): array @@ -275,9 +275,9 @@ class Saml2Service return [ 'external_id' => $externalId, - 'name' => $this->getUserDisplayName($samlAttributes, $externalId), - 'email' => $email, - 'saml_id' => $samlID, + 'name' => $this->getUserDisplayName($samlAttributes, $externalId), + 'email' => $email, + 'saml_id' => $samlID, ]; } @@ -344,8 +344,8 @@ class Saml2Service if ($this->config['dump_user_details']) { throw new JsonDebugException([ - 'id_from_idp' => $samlID, - 'attrs_from_idp' => $samlAttributes, + 'id_from_idp' => $samlID, + 'attrs_from_idp' => $samlAttributes, 'attrs_after_parsing' => $userDetails, ]); } @@ -359,7 +359,9 @@ class Saml2Service } $user = $this->registrationService->findOrRegister( - $userDetails['name'], $userDetails['email'], $userDetails['external_id'] + $userDetails['name'], + $userDetails['email'], + $userDetails['external_id'] ); if ($user === null) { diff --git a/app/Config/auth.php b/app/Config/auth.php index 69da69bf1..88c22e70a 100644 --- a/app/Config/auth.php +++ b/app/Config/auth.php @@ -41,7 +41,7 @@ return [ 'provider' => 'external', ], 'oidc' => [ - 'driver' => 'async-external-session', + 'driver' => 'async-external-session', 'provider' => 'external', ], 'api' => [ diff --git a/app/Config/oidc.php b/app/Config/oidc.php index 1b50d9d66..842ac8af6 100644 --- a/app/Config/oidc.php +++ b/app/Config/oidc.php @@ -31,5 +31,5 @@ return [ // OAuth2 endpoints. 'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null), - 'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null), + 'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null), ]; diff --git a/app/Exceptions/OpenIdConnectException.php b/app/Exceptions/OpenIdConnectException.php index d58585732..7bbc4bdaf 100644 --- a/app/Exceptions/OpenIdConnectException.php +++ b/app/Exceptions/OpenIdConnectException.php @@ -1,6 +1,7 @@ -showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')])); + return redirect('/login'); } $this->oidcService->processAuthorizeResponse($request->query('code')); + return redirect()->intended(); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index da41de651..34a3a290f 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -22,8 +22,8 @@ use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; use Laravel\Socialite\Contracts\Factory as SocialiteFactory; -use Whoops\Handler\HandlerInterface; use Psr\Http\Client\ClientInterface as HttpClientInterface; +use Whoops\Handler\HandlerInterface; class AppServiceProvider extends ServiceProvider { @@ -85,7 +85,7 @@ class AppServiceProvider extends ServiceProvider return new CspService(); }); - $this->app->bind(HttpClientInterface::class, function($app) { + $this->app->bind(HttpClientInterface::class, function ($app) { return new Client([ 'timeout' => 3, ]); diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index bc7caa195..4a626e4fa 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -4,8 +4,8 @@ namespace BookStack\Providers; use BookStack\Api\ApiTokenGuard; use BookStack\Auth\Access\ExternalBaseUserProvider; -use BookStack\Auth\Access\Guards\LdapSessionGuard; use BookStack\Auth\Access\Guards\AsyncExternalBaseSessionGuard; +use BookStack\Auth\Access\Guards\LdapSessionGuard; use BookStack\Auth\Access\LdapService; use BookStack\Auth\Access\LoginService; use BookStack\Auth\Access\RegistrationService; diff --git a/tests/Auth/OidcTest.php b/tests/Auth/OidcTest.php index cf04080fc..6c806aa8e 100644 --- a/tests/Auth/OidcTest.php +++ b/tests/Auth/OidcTest.php @@ -1,7 +1,8 @@ -keyFilePath, OidcJwtHelper::publicPemKey()); config()->set([ - 'auth.method' => 'oidc', - 'auth.defaults.guard' => 'oidc', - 'oidc.name' => 'SingleSignOn-Testing', - 'oidc.display_name_claims' => ['name'], - 'oidc.client_id' => OidcJwtHelper::defaultClientId(), - 'oidc.client_secret' => 'testpass', - 'oidc.jwt_public_key' => $this->keyFilePath, - 'oidc.issuer' => OidcJwtHelper::defaultIssuer(), + 'auth.method' => 'oidc', + 'auth.defaults.guard' => 'oidc', + 'oidc.name' => 'SingleSignOn-Testing', + 'oidc.display_name_claims' => ['name'], + 'oidc.client_id' => OidcJwtHelper::defaultClientId(), + 'oidc.client_secret' => 'testpass', + 'oidc.jwt_public_key' => $this->keyFilePath, + 'oidc.issuer' => OidcJwtHelper::defaultIssuer(), 'oidc.authorization_endpoint' => 'https://oidc.local/auth', - 'oidc.token_endpoint' => 'https://oidc.local/token', - 'oidc.discover' => false, - 'oidc.dump_user_details' => false, + 'oidc.token_endpoint' => 'https://oidc.local/token', + 'oidc.discover' => false, + 'oidc.dump_user_details' => false, ]); } @@ -131,7 +132,7 @@ class OidcTest extends TestCase $transactions = &$this->mockHttpClient([$this->getMockAuthorizationResponse([ 'email' => 'benny@example.com', - 'sub' => 'benny1010101' + 'sub' => 'benny1010101', ])]); // Callback from auth provider @@ -148,12 +149,11 @@ class OidcTest extends TestCase $this->assertStringContainsString('code=SplxlOBeZQQYbYS6WxSbIA', $tokenRequest->getBody()); $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $tokenRequest->getBody()); - $this->assertTrue(auth()->check()); $this->assertDatabaseHas('users', [ - 'email' => 'benny@example.com', + 'email' => 'benny@example.com', 'external_auth_id' => 'benny1010101', - 'email_confirmed' => false, + 'email_confirmed' => false, ]); $user = User::query()->where('email', '=', 'benny@example.com')->first(); @@ -176,15 +176,15 @@ class OidcTest extends TestCase $resp = $this->runLogin([ 'email' => 'benny@example.com', - 'sub' => 'benny505' + 'sub' => 'benny505', ]); $resp->assertStatus(200); $resp->assertJson([ 'email' => 'benny@example.com', - 'sub' => 'benny505', - "iss" => OidcJwtHelper::defaultIssuer(), - "aud" => OidcJwtHelper::defaultClientId(), + 'sub' => 'benny505', + 'iss' => OidcJwtHelper::defaultIssuer(), + 'aud' => OidcJwtHelper::defaultClientId(), ]); $this->assertFalse(auth()->check()); } @@ -193,7 +193,7 @@ class OidcTest extends TestCase { $this->runLogin([ 'email' => '', - 'sub' => 'benny505' + 'sub' => 'benny505', ]); $this->assertSessionError('Could not find an email address, for this user, in the data provided by the external authentication system'); @@ -205,7 +205,7 @@ class OidcTest extends TestCase $this->runLogin([ 'email' => 'benny@example.com', - 'sub' => 'benny505' + 'sub' => 'benny505', ]); $this->assertSessionError('Already logged in'); @@ -221,7 +221,7 @@ class OidcTest extends TestCase $this->runLogin([ 'email' => 'benny@example.com', - 'sub' => 'benny505' + 'sub' => 'benny505', ]); $this->assertTrue(auth()->check()); @@ -238,7 +238,7 @@ class OidcTest extends TestCase $this->runLogin([ 'email' => $editor->email, - 'sub' => 'benny505' + 'sub' => 'benny505', ]); $this->assertSessionError('A user with the email ' . $editor->email . ' already exists but with different credentials.'); @@ -300,7 +300,7 @@ class OidcTest extends TestCase $this->getAutoDiscoveryResponse(), $this->getJwksResponse(), $this->getAutoDiscoveryResponse([ - 'issuer' => 'https://auto.example.com' + 'issuer' => 'https://auto.example.com', ]), $this->getJwksResponse(), ]); @@ -321,11 +321,11 @@ class OidcTest extends TestCase protected function withAutodiscovery() { config()->set([ - 'oidc.issuer' => OidcJwtHelper::defaultIssuer(), - 'oidc.discover' => true, + 'oidc.issuer' => OidcJwtHelper::defaultIssuer(), + 'oidc.discover' => true, 'oidc.authorization_endpoint' => null, - 'oidc.token_endpoint' => null, - 'oidc.jwt_public_key' => null, + 'oidc.token_endpoint' => null, + 'oidc.jwt_public_key' => null, ]); } @@ -341,41 +341,41 @@ class OidcTest extends TestCase protected function getAutoDiscoveryResponse($responseOverrides = []): Response { return new Response(200, [ - 'Content-Type' => 'application/json', + 'Content-Type' => 'application/json', 'Cache-Control' => 'no-cache, no-store', - 'Pragma' => 'no-cache' + 'Pragma' => 'no-cache', ], json_encode(array_merge([ - 'token_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/token', + 'token_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/token', 'authorization_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/authorize', - 'jwks_uri' => OidcJwtHelper::defaultIssuer() . '/oidc/keys', - 'issuer' => OidcJwtHelper::defaultIssuer() + 'jwks_uri' => OidcJwtHelper::defaultIssuer() . '/oidc/keys', + 'issuer' => OidcJwtHelper::defaultIssuer(), ], $responseOverrides))); } protected function getJwksResponse(): Response { return new Response(200, [ - 'Content-Type' => 'application/json', + 'Content-Type' => 'application/json', 'Cache-Control' => 'no-cache, no-store', - 'Pragma' => 'no-cache' + 'Pragma' => 'no-cache', ], json_encode([ 'keys' => [ - OidcJwtHelper::publicJwkKeyArray() - ] + OidcJwtHelper::publicJwkKeyArray(), + ], ])); } protected function getMockAuthorizationResponse($claimOverrides = []): Response { return new Response(200, [ - 'Content-Type' => 'application/json', + 'Content-Type' => 'application/json', 'Cache-Control' => 'no-cache, no-store', - 'Pragma' => 'no-cache' + 'Pragma' => 'no-cache', ], json_encode([ 'access_token' => 'abc123', - 'token_type' => 'Bearer', - 'expires_in' => 3600, - 'id_token' => OidcJwtHelper::idToken($claimOverrides) + 'token_type' => 'Bearer', + 'expires_in' => 3600, + 'id_token' => OidcJwtHelper::idToken($claimOverrides), ])); } } diff --git a/tests/Helpers/OidcJwtHelper.php b/tests/Helpers/OidcJwtHelper.php index 0c44efb01..55a34d4dc 100644 --- a/tests/Helpers/OidcJwtHelper.php +++ b/tests/Helpers/OidcJwtHelper.php @@ -12,31 +12,31 @@ class OidcJwtHelper { public static function defaultIssuer(): string { - return "https://auth.example.com"; + return 'https://auth.example.com'; } public static function defaultClientId(): string { - return "xxyyzz.aaa.bbccdd.123"; + return 'xxyyzz.aaa.bbccdd.123'; } public static function defaultPayload(): array { return [ - "sub" => "abc1234def", - "name" => "Barry Scott", - "email" => "bscott@example.com", - "ver" => 1, - "iss" => static::defaultIssuer(), - "aud" => static::defaultClientId(), - "iat" => time(), - "exp" => time() + 720, - "jti" => "ID.AaaBBBbbCCCcccDDddddddEEEeeeeee", - "amr" => ["pwd"], - "idp" => "fghfghgfh546456dfgdfg", - "preferred_username" => "xXBazzaXx", - "auth_time" => time(), - "at_hash" => "sT4jbsdSGy9w12pq3iNYDA", + 'sub' => 'abc1234def', + 'name' => 'Barry Scott', + 'email' => 'bscott@example.com', + 'ver' => 1, + 'iss' => static::defaultIssuer(), + 'aud' => static::defaultClientId(), + 'iat' => time(), + 'exp' => time() + 720, + 'jti' => 'ID.AaaBBBbbCCCcccDDddddddEEEeeeeee', + 'amr' => ['pwd'], + 'idp' => 'fghfghgfh546456dfgdfg', + 'preferred_username' => 'xXBazzaXx', + 'auth_time' => time(), + 'at_hash' => 'sT4jbsdSGy9w12pq3iNYDA', ]; } @@ -55,6 +55,7 @@ class OidcJwtHelper $privateKey = static::privateKeyInstance(); $signature = $privateKey->sign($top); + return $top . '.' . static::base64UrlEncode($signature); } @@ -75,7 +76,7 @@ class OidcJwtHelper public static function publicPemKey(): string { - return "-----BEGIN PUBLIC KEY----- + return '-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqo1OmfNKec5S2zQC4SP9 DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvm zXL16c93Obn7G8x8A3ao6yN5qKO5S5+CETqOZfKN/g75Xlz7VsC3igOhgsXnPx6i @@ -83,12 +84,12 @@ iM6sbYbk0U/XpFaT84LXKI8VTIPUo7gTeZN1pTET//i9FlzAOzX+xfWBKdOqlEzl +zihMHCZUUvQu99P+o0MDR0lMUT+vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNk WvsLta1+jNUee+8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw 3wIDAQAB ------END PUBLIC KEY-----"; +-----END PUBLIC KEY-----'; } public static function privatePemKey(): string { - return "-----BEGIN PRIVATE KEY----- + return '-----BEGIN PRIVATE KEY----- MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCqjU6Z80p5zlLb NALhI/0Ose5RHRWAKLqipwYRHPvOo7fqGqTcDdHdoKAmQSN+ducy6zNFEqzjk1te g6n2m+bNcvXpz3c5ufsbzHwDdqjrI3moo7lLn4IROo5l8o3+DvleXPtWwLeKA6GC @@ -115,7 +116,7 @@ W0TI+6Fc2xwPj38vP465dTentbKM1E+wdSYW6SMwSfhO6ECDbfJsst5Sr2Kkt1N7 efqXPwg2wAPYeiec49EbfnteQQKAkqNfJ9K69yE2naf6bw3/5mCBsq/cXeuaBMII ylysUIRBqt2J0kWm2yCpFWR7H+Ilhdx9A7ZLCqYVt8e+vjO/BOI3cQDe2VPOLPSl q/1PY4iJviGKddtmfClH3v4= ------END PRIVATE KEY-----"; +-----END PRIVATE KEY-----'; } public static function publicJwkKeyArray(): array @@ -125,8 +126,8 @@ q/1PY4iJviGKddtmfClH3v4= 'alg' => 'RS256', 'kid' => '066e52af-8884-4926-801d-032a276f9f2a', 'use' => 'sig', - 'e' => 'AQAB', - 'n' => 'qo1OmfNKec5S2zQC4SP9DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvmzXL16c93Obn7G8x8A3ao6yN5qKO5S5-CETqOZfKN_g75Xlz7VsC3igOhgsXnPx6iiM6sbYbk0U_XpFaT84LXKI8VTIPUo7gTeZN1pTET__i9FlzAOzX-xfWBKdOqlEzl-zihMHCZUUvQu99P-o0MDR0lMUT-vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNkWvsLta1-jNUee-8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw3w', + 'e' => 'AQAB', + 'n' => 'qo1OmfNKec5S2zQC4SP9DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvmzXL16c93Obn7G8x8A3ao6yN5qKO5S5-CETqOZfKN_g75Xlz7VsC3igOhgsXnPx6iiM6sbYbk0U_XpFaT84LXKI8VTIPUo7gTeZN1pTET__i9FlzAOzX-xfWBKdOqlEzl-zihMHCZUUvQu99P-o0MDR0lMUT-vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNkWvsLta1-jNUee-8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw3w', ]; } -} \ No newline at end of file +} diff --git a/tests/SharedTestHelpers.php b/tests/SharedTestHelpers.php index 606a3cd9e..04952d223 100644 --- a/tests/SharedTestHelpers.php +++ b/tests/SharedTestHelpers.php @@ -252,6 +252,7 @@ trait SharedTestHelpers /** * Mock the http client used in BookStack. * Returns a reference to the container which holds all history of http transactions. + * * @link https://docs.guzzlephp.org/en/stable/testing.html#history-middleware */ protected function &mockHttpClient(array $responses = []): array @@ -262,6 +263,7 @@ trait SharedTestHelpers $handlerStack = new HandlerStack($mock); $handlerStack->push($history); $this->app[ClientInterface::class] = new Client(['handler' => $handlerStack]); + return $container; } diff --git a/tests/Unit/OidcIdTokenTest.php b/tests/Unit/OidcIdTokenTest.php index b08d578b3..ad91eecd8 100644 --- a/tests/Unit/OidcIdTokenTest.php +++ b/tests/Unit/OidcIdTokenTest.php @@ -2,8 +2,8 @@ namespace Tests\Unit; -use BookStack\Auth\Access\Oidc\OidcInvalidTokenException; use BookStack\Auth\Access\Oidc\OidcIdToken; +use BookStack\Auth\Access\Oidc\OidcInvalidTokenException; use Tests\Helpers\OidcJwtHelper; use Tests\TestCase; @@ -12,7 +12,7 @@ class OidcIdTokenTest extends TestCase public function test_valid_token_passes_validation() { $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), [ - OidcJwtHelper::publicJwkKeyArray() + OidcJwtHelper::publicJwkKeyArray(), ]); $this->assertTrue($token->validate('xxyyzz.aaa.bbccdd.123')); @@ -54,6 +54,7 @@ class OidcIdTokenTest extends TestCase foreach ($messagesAndTokenValues as [$message, $tokenValue]) { $token = new OidcIdToken($tokenValue, OidcJwtHelper::defaultIssuer(), []); $err = null; + try { $token->validate('abc'); } catch (\Exception $exception) { @@ -77,8 +78,8 @@ class OidcIdTokenTest extends TestCase { $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), [ array_merge(OidcJwtHelper::publicJwkKeyArray(), [ - 'n' => 'iqK-1QkICMf_cusNLpeNnN-bhT0-9WLBvzgwKLALRbrevhdi5ttrLHIQshaSL0DklzfyG2HWRmAnJ9Q7sweEjuRiiqRcSUZbYu8cIv2hLWYu7K_NH67D2WUjl0EnoHEuiVLsZhQe1CmdyLdx087j5nWkd64K49kXRSdxFQUlj8W3NeK3CjMEUdRQ3H4RZzJ4b7uuMiFA29S2ZhMNG20NPbkUVsFL-jiwTd10KSsPT8yBYipI9O7mWsUWt_8KZs1y_vpM_k3SyYihnWpssdzDm1uOZ8U3mzFr1xsLAO718GNUSXk6npSDzLl59HEqa6zs4O9awO2qnSHvcmyELNk31w' - ]) + 'n' => 'iqK-1QkICMf_cusNLpeNnN-bhT0-9WLBvzgwKLALRbrevhdi5ttrLHIQshaSL0DklzfyG2HWRmAnJ9Q7sweEjuRiiqRcSUZbYu8cIv2hLWYu7K_NH67D2WUjl0EnoHEuiVLsZhQe1CmdyLdx087j5nWkd64K49kXRSdxFQUlj8W3NeK3CjMEUdRQ3H4RZzJ4b7uuMiFA29S2ZhMNG20NPbkUVsFL-jiwTd10KSsPT8yBYipI9O7mWsUWt_8KZs1y_vpM_k3SyYihnWpssdzDm1uOZ8U3mzFr1xsLAO718GNUSXk6npSDzLl59HEqa6zs4O9awO2qnSHvcmyELNk31w', + ]), ]); $this->expectException(OidcInvalidTokenException::class); $this->expectExceptionMessage('Token signature could not be validated using the provided keys'); @@ -97,7 +98,7 @@ class OidcIdTokenTest extends TestCase { $token = new OidcIdToken(OidcJwtHelper::idToken([], ['alg' => 'HS256']), OidcJwtHelper::defaultIssuer(), []); $this->expectException(OidcInvalidTokenException::class); - $this->expectExceptionMessage("Only RS256 signature validation is supported. Token reports using HS256"); + $this->expectExceptionMessage('Only RS256 signature validation is supported. Token reports using HS256'); $token->validate('abc'); } @@ -134,10 +135,11 @@ class OidcIdTokenTest extends TestCase foreach ($claimOverridesByErrorMessage as [$message, $overrides]) { $token = new OidcIdToken(OidcJwtHelper::idToken($overrides), OidcJwtHelper::defaultIssuer(), [ - OidcJwtHelper::publicJwkKeyArray() + OidcJwtHelper::publicJwkKeyArray(), ]); $err = null; + try { $token->validate('xxyyzz.aaa.bbccdd.123'); } catch (\Exception $exception) { @@ -155,10 +157,10 @@ class OidcIdTokenTest extends TestCase $testFilePath = 'file://' . stream_get_meta_data($file)['uri']; file_put_contents($testFilePath, OidcJwtHelper::publicPemKey()); $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), [ - $testFilePath + $testFilePath, ]); $this->assertTrue($token->validate('xxyyzz.aaa.bbccdd.123')); unlink($testFilePath); } -} \ No newline at end of file +} From cb45c53029a4c11d605da9b5b7e5a32a86760e65 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 18 Oct 2021 11:42:50 +0100 Subject: [PATCH 29/50] Added base64 image extraction to markdown page content - Included tests to cover. - Manually tested via API update and interface page update. Closes #2898 --- app/Entities/Models/Page.php | 2 +- app/Entities/Tools/PageContent.php | 43 ++++++++++++++++++++++++++---- app/Uploads/ImageRepo.php | 10 +++++++ tests/Entity/PageContentTest.php | 38 +++++++++++++++++++++++++- 4 files changed, 86 insertions(+), 7 deletions(-) diff --git a/app/Entities/Models/Page.php b/app/Entities/Models/Page.php index b8467c38c..601e9630d 100644 --- a/app/Entities/Models/Page.php +++ b/app/Entities/Models/Page.php @@ -28,7 +28,7 @@ class Page extends BookChild public static $listAttributes = ['name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'text', 'created_at', 'updated_at', 'priority']; public static $contentAttributes = ['name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'html', 'text', 'created_at', 'updated_at', 'priority']; - protected $fillable = ['name', 'priority', 'markdown']; + protected $fillable = ['name', 'priority']; public $textField = 'text'; diff --git a/app/Entities/Tools/PageContent.php b/app/Entities/Tools/PageContent.php index 661c554da..9f4ac2893 100644 --- a/app/Entities/Tools/PageContent.php +++ b/app/Entities/Tools/PageContent.php @@ -37,7 +37,7 @@ class PageContent */ public function setNewHTML(string $html) { - $html = $this->extractBase64Images($this->page, $html); + $html = $this->extractBase64ImagesFromHtml($html); $this->page->html = $this->formatHtml($html); $this->page->text = $this->toPlainText(); $this->page->markdown = ''; @@ -48,6 +48,7 @@ class PageContent */ public function setNewMarkdown(string $markdown) { + $markdown = $this->extractBase64ImagesFromMarkdown($markdown); $this->page->markdown = $markdown; $html = $this->markdownToHtml($markdown); $this->page->html = $this->formatHtml($html); @@ -74,7 +75,7 @@ class PageContent /** * Convert all base64 image data to saved images. */ - public function extractBase64Images(Page $page, string $htmlText): string + protected function extractBase64ImagesFromHtml(string $htmlText): string { if (empty($htmlText) || strpos($htmlText, 'data:image') === false) { return $htmlText; @@ -86,7 +87,6 @@ class PageContent $childNodes = $body->childNodes; $xPath = new DOMXPath($doc); $imageRepo = app()->make(ImageRepo::class); - $allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp']; // Get all img elements with image data blobs $imageNodes = $xPath->query('//img[contains(@src, \'data:image\')]'); @@ -96,7 +96,7 @@ class PageContent $extension = strtolower(preg_split('/[\/;]/', $dataDefinition)[1] ?? 'png'); // Validate extension - if (!in_array($extension, $allowedExtensions)) { + if (!$imageRepo->imageExtensionSupported($extension)) { $imageNode->setAttribute('src', ''); continue; } @@ -105,7 +105,7 @@ class PageContent $imageName = 'embedded-image-' . Str::random(8) . '.' . $extension; try { - $image = $imageRepo->saveNewFromData($imageName, base64_decode($base64ImageData), 'gallery', $page->id); + $image = $imageRepo->saveNewFromData($imageName, base64_decode($base64ImageData), 'gallery', $this->page->id); $imageNode->setAttribute('src', $image->url); } catch (ImageUploadException $exception) { $imageNode->setAttribute('src', ''); @@ -121,6 +121,39 @@ class PageContent return $html; } + /** + * Convert all inline base64 content to uploaded image files. + */ + protected function extractBase64ImagesFromMarkdown(string $markdown) + { + $imageRepo = app()->make(ImageRepo::class); + $matches = []; + preg_match_all('/!\[.*?]\(.*?(data:image\/.*?)[)"\s]/', $markdown, $matches); + + foreach ($matches[1] as $base64Match) { + [$dataDefinition, $base64ImageData] = explode(',', $base64Match, 2); + $extension = strtolower(preg_split('/[\/;]/', $dataDefinition)[1] ?? 'png'); + + // Validate extension + if (!$imageRepo->imageExtensionSupported($extension)) { + $markdown = str_replace($base64Match, '', $markdown); + continue; + } + + // Save image from data with a random name + $imageName = 'embedded-image-' . Str::random(8) . '.' . $extension; + + try { + $image = $imageRepo->saveNewFromData($imageName, base64_decode($base64ImageData), 'gallery', $this->page->id); + $markdown = str_replace($base64Match, $image->url, $markdown); + } catch (ImageUploadException $exception) { + $markdown = str_replace($base64Match, '', $markdown); + } + } + + return $markdown; + } + /** * Formats a page's html to be tagged correctly within the system. */ diff --git a/app/Uploads/ImageRepo.php b/app/Uploads/ImageRepo.php index 115078561..c4205e357 100644 --- a/app/Uploads/ImageRepo.php +++ b/app/Uploads/ImageRepo.php @@ -16,6 +16,8 @@ class ImageRepo protected $restrictionService; protected $page; + protected static $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp']; + /** * ImageRepo constructor. */ @@ -31,6 +33,14 @@ class ImageRepo $this->page = $page; } + /** + * Check if the given image extension is supported by BookStack. + */ + public function imageExtensionSupported(string $extension): bool + { + return in_array(trim($extension, '. \t\n\r\0\x0B'), static::$supportedExtensions); + } + /** * Get an image with the given id. */ diff --git a/tests/Entity/PageContentTest.php b/tests/Entity/PageContentTest.php index 45c27c9f9..60fa6fd77 100644 --- a/tests/Entity/PageContentTest.php +++ b/tests/Entity/PageContentTest.php @@ -594,7 +594,7 @@ class PageContentTest extends TestCase $this->deleteImage($imagePath); } - public function test_base64_images_blanked_if_not_supported_extension_for_extract() + public function test_base64_images_within_html_blanked_if_not_supported_extension_for_extract() { $this->asEditor(); $page = Page::query()->first(); @@ -607,4 +607,40 @@ class PageContentTest extends TestCase $page->refresh(); $this->assertStringContainsString('html); } + + public function test_base64_images_get_extracted_from_markdown_page_content() + { + $this->asEditor(); + $page = Page::query()->first(); + + $this->put($page->getUrl(), [ + 'name' => $page->name, 'summary' => '', + 'markdown' => 'test ![test](data:image/jpeg;base64,' . $this->base64Jpeg . ')', + ]); + + $page->refresh(); + $this->assertStringMatchesFormat('%Atest test%A

    %A', $page->html); + + $matches = []; + preg_match('/src="http:\/\/localhost(.*?)"/', $page->html, $matches); + $imagePath = $matches[1]; + $imageFile = public_path($imagePath); + $this->assertEquals(base64_decode($this->base64Jpeg), file_get_contents($imageFile)); + + $this->deleteImage($imagePath); + } + + public function test_base64_images_within_markdown_blanked_if_not_supported_extension_for_extract() + { + $this->asEditor(); + $page = Page::query()->first(); + + $this->put($page->getUrl(), [ + 'name' => $page->name, 'summary' => '', + 'markdown' => 'test ![test](data:image/jiff;base64,' . $this->base64Jpeg . ')', + ]); + + $page->refresh(); + $this->assertStringContainsString('html); + } } From 1a8a6c609a0b12de3e14a917345bd5fee5e0baa4 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 18 Oct 2021 11:43:54 +0100 Subject: [PATCH 30/50] Added phpseclib to readme --- readme.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 45b40cdb7..1ab54de6e 100644 --- a/readme.md +++ b/readme.md @@ -199,4 +199,5 @@ These are the great open-source projects used to help build BookStack: * [League/Flysystem](https://flysystem.thephpleague.com) * [StyleCI](https://styleci.io/) * [pragmarx/google2fa](https://github.com/antonioribeiro/google2fa) -* [Bacon/BaconQrCode](https://github.com/Bacon/BaconQrCode) \ No newline at end of file +* [Bacon/BaconQrCode](https://github.com/Bacon/BaconQrCode) +* [phpseclib](https://github.com/phpseclib/phpseclib) \ No newline at end of file From 32f6ea946f00d25b3e70166d4e1bd3ef27d64a33 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 18 Oct 2021 17:46:55 +0100 Subject: [PATCH 31/50] Build out core attachments API controller Related to #2942 --- .../Api/AttachmentApiController.php | 155 ++++++++++++++++++ app/Http/Controllers/AttachmentController.php | 4 +- app/Uploads/Attachment.php | 32 +++- app/Uploads/AttachmentService.php | 16 +- routes/api.php | 6 + 5 files changed, 196 insertions(+), 17 deletions(-) create mode 100644 app/Http/Controllers/Api/AttachmentApiController.php diff --git a/app/Http/Controllers/Api/AttachmentApiController.php b/app/Http/Controllers/Api/AttachmentApiController.php new file mode 100644 index 000000000..2ee1c98a6 --- /dev/null +++ b/app/Http/Controllers/Api/AttachmentApiController.php @@ -0,0 +1,155 @@ + [ + 'name' => 'required|min:1|max:255|string', + 'uploaded_to' => 'required|integer|exists:pages,id', + 'file' => 'required_without:link|file', + 'link' => 'required_without:file|min:1|max:255|safe_url' + ], + 'update' => [ + 'name' => 'min:1|max:255|string', + 'uploaded_to' => 'integer|exists:pages,id', + 'file' => 'link|file', + 'link' => 'file|min:1|max:255|safe_url' + ], + ]; + + public function __construct(AttachmentService $attachmentService) + { + $this->attachmentService = $attachmentService; + } + + /** + * Get a listing of attachments visible to the user. + * The external property indicates whether the attachment is simple a link. + * A false value for the external property would indicate a file upload. + */ + public function list() + { + return $this->apiListingResponse(Attachment::visible(), [ + 'id', 'name', 'extension', 'uploaded_to', 'external', 'order', 'created_at', 'updated_at', 'created_by', 'updated_by', + ]); + } + + /** + * Create a new attachment in the system. + * An uploaded_to value must be provided containing an ID of the page + * that this upload will be related to. + * + * @throws ValidationException + * @throws FileUploadException + */ + public function create(Request $request) + { + $this->checkPermission('attachment-create-all'); + $requestData = $this->validate($request, $this->rules['create']); + + $pageId = $request->get('uploaded_to'); + $page = Page::visible()->findOrFail($pageId); + $this->checkOwnablePermission('page-update', $page); + + if ($request->hasFile('file')) { + $uploadedFile = $request->file('file'); + $attachment = $this->attachmentService->saveNewUpload($uploadedFile, $page->id); + } else { + $attachment = $this->attachmentService->saveNewFromLink( + $requestData['name'], $requestData['link'], $page->id + ); + } + + $this->attachmentService->updateFile($attachment, $requestData); + return response()->json($attachment); + } + + /** + * Get the details & content of a single attachment of the given ID. + * The attachment link or file content is provided via a 'content' property. + * For files the content will be base64 encoded. + * + * @throws FileNotFoundException + */ + public function read(string $id) + { + /** @var Attachment $attachment */ + $attachment = Attachment::visible()->findOrFail($id); + + $attachment->setAttribute('links', [ + 'html' => $attachment->htmlLink(), + 'markdown' => $attachment->markdownLink(), + ]); + + if (!$attachment->external) { + $attachmentContents = $this->attachmentService->getAttachmentFromStorage($attachment); + $attachment->setAttribute('content', base64_encode($attachmentContents)); + } else { + $attachment->setAttribute('content', $attachment->path); + } + + return response()->json($attachment); + } + + /** + * Update the details of a single attachment. + * + * @throws ValidationException + * @throws FileUploadException + */ + public function update(Request $request, string $id) + { + $requestData = $this->validate($request, $this->rules['update']); + /** @var Attachment $attachment */ + $attachment = Attachment::visible()->findOrFail($id); + + $page = $attachment->page; + if ($requestData['uploaded_to'] ?? false) { + $pageId = $request->get('uploaded_to'); + $page = Page::visible()->findOrFail($pageId); + $attachment->uploaded_to = $requestData['uploaded_to']; + } + + $this->checkOwnablePermission('page-view', $page); + $this->checkOwnablePermission('page-update', $page); + $this->checkOwnablePermission('attachment-update', $attachment); + + if ($request->hasFile('file')) { + $uploadedFile = $request->file('file'); + $attachment = $this->attachmentService->saveUpdatedUpload($uploadedFile, $page->id); + } + + $this->attachmentService->updateFile($attachment, $requestData); + return response()->json($attachment); + } + + /** + * Delete an attachment of the given ID. + * + * @throws Exception + */ + public function delete(string $id) + { + /** @var Attachment $attachment */ + $attachment = Attachment::visible()->findOrFail($id); + $this->checkOwnablePermission('attachment-delete', $attachment); + + $this->attachmentService->deleteFile($attachment); + + return response('', 204); + } + +} diff --git a/app/Http/Controllers/AttachmentController.php b/app/Http/Controllers/AttachmentController.php index 046b8c19d..56503a694 100644 --- a/app/Http/Controllers/AttachmentController.php +++ b/app/Http/Controllers/AttachmentController.php @@ -121,9 +121,9 @@ class AttachmentController extends Controller ]), 422); } - $this->checkOwnablePermission('view', $attachment->page); + $this->checkOwnablePermission('page-view', $attachment->page); $this->checkOwnablePermission('page-update', $attachment->page); - $this->checkOwnablePermission('attachment-create', $attachment); + $this->checkOwnablePermission('attachment-update', $attachment); $attachment = $this->attachmentService->updateFile($attachment, [ 'name' => $request->get('attachment_edit_name'), diff --git a/app/Uploads/Attachment.php b/app/Uploads/Attachment.php index 5acd4f141..dfd7d980a 100644 --- a/app/Uploads/Attachment.php +++ b/app/Uploads/Attachment.php @@ -2,18 +2,24 @@ namespace BookStack\Uploads; +use BookStack\Auth\Permissions\PermissionService; +use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; use BookStack\Model; use BookStack\Traits\HasCreatorAndUpdater; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsTo; /** - * @property int id - * @property string name - * @property string path - * @property string extension - * @property ?Page page - * @property bool external + * @property int $id + * @property string $name + * @property string $path + * @property string $extension + * @property ?Page $page + * @property bool $external + * @property int $uploaded_to + * + * @method static Entity|Builder visible() */ class Attachment extends Model { @@ -70,4 +76,18 @@ class Attachment extends Model { return '[' . $this->name . '](' . $this->getUrl() . ')'; } + + /** + * Scope the query to those attachments that are visible based upon related page permissions. + */ + public function scopeVisible(): string + { + $permissionService = app()->make(PermissionService::class); + return $permissionService->filterRelatedEntity( + Page::class, + Attachment::query(), + 'attachments', + 'uploaded_to' + ); + } } diff --git a/app/Uploads/AttachmentService.php b/app/Uploads/AttachmentService.php index 3de0a0dae..d530d8fbe 100644 --- a/app/Uploads/AttachmentService.php +++ b/app/Uploads/AttachmentService.php @@ -78,18 +78,18 @@ class AttachmentService * * @throws FileUploadException */ - public function saveNewUpload(UploadedFile $uploadedFile, int $page_id): Attachment + public function saveNewUpload(UploadedFile $uploadedFile, int $pageId): Attachment { $attachmentName = $uploadedFile->getClientOriginalName(); $attachmentPath = $this->putFileInStorage($uploadedFile); - $largestExistingOrder = Attachment::query()->where('uploaded_to', '=', $page_id)->max('order'); + $largestExistingOrder = Attachment::query()->where('uploaded_to', '=', $pageId)->max('order'); /** @var Attachment $attachment */ $attachment = Attachment::query()->forceCreate([ 'name' => $attachmentName, 'path' => $attachmentPath, 'extension' => $uploadedFile->getClientOriginalExtension(), - 'uploaded_to' => $page_id, + 'uploaded_to' => $pageId, 'created_by' => user()->id, 'updated_by' => user()->id, 'order' => $largestExistingOrder + 1, @@ -159,8 +159,9 @@ class AttachmentService public function updateFile(Attachment $attachment, array $requestData): Attachment { $attachment->name = $requestData['name']; + $link = trim($requestData['link'] ?? ''); - if (isset($requestData['link']) && trim($requestData['link']) !== '') { + if (!empty($link)) { $attachment->path = $requestData['link']; if (!$attachment->external) { $this->deleteFileInStorage($attachment); @@ -180,13 +181,10 @@ class AttachmentService */ public function deleteFile(Attachment $attachment) { - if ($attachment->external) { - $attachment->delete(); - - return; + if (!$attachment->external) { + $this->deleteFileInStorage($attachment); } - $this->deleteFileInStorage($attachment); $attachment->delete(); } diff --git a/routes/api.php b/routes/api.php index 83a411219..49521bb89 100644 --- a/routes/api.php +++ b/routes/api.php @@ -7,6 +7,12 @@ */ Route::get('docs.json', 'ApiDocsController@json'); +Route::get('attachments', 'AttachmentApiController@list'); +Route::post('attachments', 'AttachmentApiController@create'); +Route::get('attachments/{id}', 'AttachmentApiController@read'); +Route::put('attachments/{id}', 'AttachmentApiController@update'); +Route::delete('attachments/{id}', 'AttachmentApiController@delete'); + Route::get('books', 'BookApiController@list'); Route::post('books', 'BookApiController@create'); Route::get('books/{id}', 'BookApiController@read'); From 5a4b366e56617ed244b152f95a33adaf90792501 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 19 Oct 2021 14:05:34 +0100 Subject: [PATCH 32/50] Create language_request.yml --- .github/ISSUE_TEMPLATE/language_request.yml | 32 +++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/language_request.yml diff --git a/.github/ISSUE_TEMPLATE/language_request.yml b/.github/ISSUE_TEMPLATE/language_request.yml new file mode 100644 index 000000000..7e6dda58e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/language_request.yml @@ -0,0 +1,32 @@ +name: Language Request +description: Request a new language to be added to CrowdIn for you to translate +title: "[Language Request]: " +labels: [":earth_africa: Translations"] +assignees: + - ssddanbrown +body: + - type: markdown + attributes: + value: | + Thanks for offering to help start a new translation for BookStack! + - type: input + id: language + attributes: + label: Language to Add + description: What language (and region if applicable) are you offering to help add to BookStack? + validations: + required: true + - type: checkboxes + id: confirm + attributes: + label: Confirmation of Intent + description: | + This issue template is to request a new language be added to our [Crowdin translation management project](https://crowdin.com/project/bookstack). + Please don't use this template to request a new language that you are not prepared to provide translations for. + options: + - label: I confirm I'm offering to help translate for this new language via CrowdIn. + required: true + - type: markdown + attributes: + value: | + Note: New languages are added at specific points of the development process so it may be a small while before the requested language is added for translation. From 0d6f1638fe3b9d6fcbdbcd9b573c3c1d1ee3ba6e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 19 Oct 2021 14:06:53 +0100 Subject: [PATCH 33/50] Delete language_request.md --- .github/ISSUE_TEMPLATE/language_request.md | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/language_request.md diff --git a/.github/ISSUE_TEMPLATE/language_request.md b/.github/ISSUE_TEMPLATE/language_request.md deleted file mode 100644 index 249ef7871..000000000 --- a/.github/ISSUE_TEMPLATE/language_request.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -name: Language Request -about: Request a new language to be added to Crowdin for you to translate - ---- - -### Language To Add - -_Specify here the language you want to add._ - ----- - -_This issue template is to request a new language be added to our [Crowdin translation management project](https://crowdin.com/project/bookstack). Please don't use this template to request a new language that you are not prepared to provide translations for._ \ No newline at end of file From 7ad98fc3c33d7d1bd3b297b3e4c9498d1a906846 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 19 Oct 2021 14:07:45 +0100 Subject: [PATCH 34/50] Update language_request.yml --- .github/ISSUE_TEMPLATE/language_request.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/language_request.yml b/.github/ISSUE_TEMPLATE/language_request.yml index 7e6dda58e..b94bb88bc 100644 --- a/.github/ISSUE_TEMPLATE/language_request.yml +++ b/.github/ISSUE_TEMPLATE/language_request.yml @@ -29,4 +29,4 @@ body: - type: markdown attributes: value: | - Note: New languages are added at specific points of the development process so it may be a small while before the requested language is added for translation. + *__Note: New languages are added at specific points of the development process so it may be a small while before the requested language is added for translation.__* From 5d6d7ef5a75f8e8ef34ff7f9442fb099d817f1f1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 19 Oct 2021 14:49:49 +0100 Subject: [PATCH 35/50] Converted issues templates to forms Added support request template --- .github/ISSUE_TEMPLATE/api_request.md | 17 ------ .github/ISSUE_TEMPLATE/api_request.yml | 26 +++++++++ .github/ISSUE_TEMPLATE/bug_report.md | 29 ---------- .github/ISSUE_TEMPLATE/bug_report.yml | 62 +++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 14 ----- .github/ISSUE_TEMPLATE/feature_request.yml | 26 +++++++++ .github/ISSUE_TEMPLATE/support_request.yml | 63 ++++++++++++++++++++++ 7 files changed, 177 insertions(+), 60 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/api_request.md create mode 100644 .github/ISSUE_TEMPLATE/api_request.yml delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/ISSUE_TEMPLATE/support_request.yml diff --git a/.github/ISSUE_TEMPLATE/api_request.md b/.github/ISSUE_TEMPLATE/api_request.md deleted file mode 100644 index dc050efbb..000000000 --- a/.github/ISSUE_TEMPLATE/api_request.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: New API Endpoint or Feature -about: Request a new endpoint or API feature be added -labels: ":nut_and_bolt: API Request" ---- - -#### API Endpoint or Feature - -Clearly describe what you'd like to have added to the API. - -#### Use-Case - -Explain the use-case that you're working-on that requires the above request. - -#### Additional Context - -If required, add any other context about the feature request here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/api_request.yml b/.github/ISSUE_TEMPLATE/api_request.yml new file mode 100644 index 000000000..81e11e23d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/api_request.yml @@ -0,0 +1,26 @@ +name: New API Endpoint or API Ability +description: Request a new endpoint or API feature be added +title: "[API Request]: " +labels: [":nut_and_bolt: API Request"] +body: + - type: textarea + id: feature + attributes: + label: API Endpoint or Feature + description: Clearly describe what you'd like to have added to the API. + validations: + required: true + - type: textarea + id: usecase + attributes: + label: Use-Case + description: Explain the use-case that you're working-on that requires the above request. + validations: + required: true + - type: textarea + id: context + attributes: + label: Additional context + description: Add any other context about the feature request here. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index c4444f242..000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -name: Bug Report -about: Create a report to help us improve - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**Steps To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Your Configuration (please complete the following information):** - - Exact BookStack Version (Found in settings): - - PHP Version: - - Hosting Method (Nginx/Apache/Docker): - -**Additional context** -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..4b2baab86 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,62 @@ +name: Bug Report +description: Create a report to help us improve or fix things +title: "[Bug Report]: " +labels: [":bug: Bug"] +body: + - type: textarea + id: description + attributes: + label: Describe the Bug + description: Provide a clear and concise description of what the bug is. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Steps to Reproduce + description: Detail the steps that would replicate this issue + placeholder: | + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected Behaviour + description: Provide clear and concise description of what you expected to happen. + validations: + required: true + - type: textarea + id: context + attributes: + label: Screenshots or Additional Context + description: Provide any additional context and screenshots here to help us solve this issue + validations: + required: false + - type: input + id: bsversion + attributes: + label: Exact BookStack Version + description: This can be found in the settings view of BookStack. Please provide an exact version. + placeholder: (eg. v21.08.5) + validations: + required: true +- type: input + id: phpversion + attributes: + label: PHP Version + description: Keep in mind your command-line PHP version may differ to that of your webserver. Provide that relevant to the issue. + placeholder: (eg. 7.4) + validations: + required: false +- type: textarea + id: hosting + attributes: + label: Hosting Environment + description: Describe your hosting environment as much as possible including any proxies used (If applicable). + placeholder: (eg. Ubuntu 20.04 VPS, installed using official installation script) + validations: + required: true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 781cca5b8..000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -name: Feature Request -about: Suggest an idea for this project - ---- - -**Describe the feature you'd like** -A clear description of the feature you'd like implemented in BookStack. - -**Describe the benefits this feature would bring to BookStack users** -Explain the measurable benefits this feature would achieve. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..a945c34b4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,26 @@ +name: Feature Request +description: Request a new language to be added to CrowdIn for you to translate +title: "[Feature Request]: " +labels: [":hammer: Feature Request"] +body: + - type: textarea + id: description + attributes: + label: Describe the feature you'd like + description: Provide a clear description of the feature you'd like implemented in BookStack + validations: + required: true + - type: textarea + id: benefits + attributes: + label: Describe the benefits this feature would bring to BookStack users + description: Explain the measurable benefits this feature would achieve for existing BookStack users + validations: + required: true + - type: textarea + id: context + attributes: + label: Additional context + description: Add any other context or screenshots about the feature request here. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/support_request.yml b/.github/ISSUE_TEMPLATE/support_request.yml new file mode 100644 index 000000000..dfff745ff --- /dev/null +++ b/.github/ISSUE_TEMPLATE/support_request.yml @@ -0,0 +1,63 @@ +name: Support Request +description: Request support for a specific problem you have not been able to solve +title: "[Support Request]: " +labels: [":dog2: Support"] +body: + - type: checkboxes + id: useddocs + attributes: + label: Attempted Debugging + description: | + I have read the [BookStack debugging](https://www.bookstackapp.com/docs/admin/debugging/) page and seeked resolution or more + detail for the issue. + options: + - label: I have read the debugging page + required: true + - type: checkboxes + id: searchissue + attributes: + label: Searched GitHub Issues + description: | + I have searched for the issue and potential resolutions within the [project's GitHub issue list](https://github.com/BookStackApp/BookStack/issues) + options: + - label: I have searched GitHub for the issue. + required: true + - type: textarea + id: scenario + attributes: + label: Describe the Scenario + description: Detail the problem that you're having or what you need support with. + validations: + required: true + - type: input + id: bsversion + attributes: + label: Exact BookStack Version + description: This can be found in the settings view of BookStack. Please provide an exact version. + placeholder: (eg. v21.08.5) + validations: + required: true + - type: textarea + id: logs + attributes: + label: Log Content + description: If the issue has produced an error, provide any [BookStack or server log](https://www.bookstackapp.com/docs/admin/debugging/) content below. + placeholder: Be sure to remove any confidential details in your logs + validations: + required: false + - type: input + id: phpversion + attributes: + label: PHP Version + description: Keep in mind your command-line PHP version may differ to that of your webserver. Provide that relevant to the issue. + placeholder: (eg. 7.4) + validations: + required: false + - type: textarea + id: hosting + attributes: + label: Hosting Environment + description: Describe your hosting environment as much as possible including any proxies used (If applicable). + placeholder: (eg. Ubuntu 20.04 VPS, installed using official installation script) + validations: + required: true \ No newline at end of file From 72ad87b123f3091ef16d04a2629d29a81cd093f6 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 19 Oct 2021 14:52:00 +0100 Subject: [PATCH 36/50] Update support_request.yml --- .github/ISSUE_TEMPLATE/support_request.yml | 30 +++++++++++----------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/support_request.yml b/.github/ISSUE_TEMPLATE/support_request.yml index dfff745ff..bd52b12af 100644 --- a/.github/ISSUE_TEMPLATE/support_request.yml +++ b/.github/ISSUE_TEMPLATE/support_request.yml @@ -1,5 +1,5 @@ name: Support Request -description: Request support for a specific problem you have not been able to solve +description: Request support for a specific problem you have not been able to solve yourself title: "[Support Request]: " labels: [":dog2: Support"] body: @@ -46,18 +46,18 @@ body: validations: required: false - type: input - id: phpversion - attributes: - label: PHP Version - description: Keep in mind your command-line PHP version may differ to that of your webserver. Provide that relevant to the issue. - placeholder: (eg. 7.4) - validations: - required: false + id: phpversion + attributes: + label: PHP Version + description: Keep in mind your command-line PHP version may differ to that of your webserver. Provide that most relevant to the issue. + placeholder: (eg. 7.4) + validations: + required: false - type: textarea - id: hosting - attributes: - label: Hosting Environment - description: Describe your hosting environment as much as possible including any proxies used (If applicable). - placeholder: (eg. Ubuntu 20.04 VPS, installed using official installation script) - validations: - required: true \ No newline at end of file + id: hosting + attributes: + label: Hosting Environment + description: Describe your hosting environment as much as possible including any proxies used (If applicable). + placeholder: (eg. Ubuntu 20.04 VPS, installed using official installation script) + validations: + required: true From c699f176bcb085f17427d44ff3bb0c614110edbf Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 19 Oct 2021 15:15:35 +0100 Subject: [PATCH 37/50] Fixed bug report yaml formatting --- .github/ISSUE_TEMPLATE/bug_report.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 4b2baab86..35aa481db 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -44,7 +44,7 @@ body: placeholder: (eg. v21.08.5) validations: required: true -- type: input + - type: input id: phpversion attributes: label: PHP Version @@ -52,11 +52,11 @@ body: placeholder: (eg. 7.4) validations: required: false -- type: textarea + - type: textarea id: hosting attributes: label: Hosting Environment description: Describe your hosting environment as much as possible including any proxies used (If applicable). placeholder: (eg. Ubuntu 20.04 VPS, installed using official installation script) validations: - required: true \ No newline at end of file + required: true From 2409d1850feeae10b5f0ef6a3f67bc9739881f44 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 20 Oct 2021 00:58:56 +0100 Subject: [PATCH 38/50] Added TestCase for attachments API methods --- .../Api/AttachmentApiController.php | 10 +- app/Uploads/Attachment.php | 9 +- app/Uploads/AttachmentService.php | 6 +- tests/Api/AttachmentsApiTest.php | 330 ++++++++++++++++++ tests/Api/TestsApi.php | 10 + tests/Uploads/AttachmentTest.php | 4 +- 6 files changed, 359 insertions(+), 10 deletions(-) create mode 100644 tests/Api/AttachmentsApiTest.php diff --git a/app/Http/Controllers/Api/AttachmentApiController.php b/app/Http/Controllers/Api/AttachmentApiController.php index 2ee1c98a6..7aa4ee493 100644 --- a/app/Http/Controllers/Api/AttachmentApiController.php +++ b/app/Http/Controllers/Api/AttachmentApiController.php @@ -25,8 +25,8 @@ class AttachmentApiController extends ApiController 'update' => [ 'name' => 'min:1|max:255|string', 'uploaded_to' => 'integer|exists:pages,id', - 'file' => 'link|file', - 'link' => 'file|min:1|max:255|safe_url' + 'file' => 'file', + 'link' => 'min:1|max:255|safe_url' ], ]; @@ -87,7 +87,9 @@ class AttachmentApiController extends ApiController public function read(string $id) { /** @var Attachment $attachment */ - $attachment = Attachment::visible()->findOrFail($id); + $attachment = Attachment::visible() + ->with(['createdBy', 'updatedBy']) + ->findOrFail($id); $attachment->setAttribute('links', [ 'html' => $attachment->htmlLink(), @@ -129,7 +131,7 @@ class AttachmentApiController extends ApiController if ($request->hasFile('file')) { $uploadedFile = $request->file('file'); - $attachment = $this->attachmentService->saveUpdatedUpload($uploadedFile, $page->id); + $attachment = $this->attachmentService->saveUpdatedUpload($uploadedFile, $attachment); } $this->attachmentService->updateFile($attachment, $requestData); diff --git a/app/Uploads/Attachment.php b/app/Uploads/Attachment.php index dfd7d980a..8ae53199e 100644 --- a/app/Uploads/Attachment.php +++ b/app/Uploads/Attachment.php @@ -3,6 +3,7 @@ namespace BookStack\Uploads; use BookStack\Auth\Permissions\PermissionService; +use BookStack\Auth\User; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; use BookStack\Model; @@ -18,6 +19,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property ?Page $page * @property bool $external * @property int $uploaded_to + * @property User $updatedBy + * @property User $createdBy * * @method static Entity|Builder visible() */ @@ -26,6 +29,10 @@ class Attachment extends Model use HasCreatorAndUpdater; protected $fillable = ['name', 'order']; + protected $hidden = ['path']; + protected $casts = [ + 'external' => 'bool', + ]; /** * Get the downloadable file name for this upload. @@ -80,7 +87,7 @@ class Attachment extends Model /** * Scope the query to those attachments that are visible based upon related page permissions. */ - public function scopeVisible(): string + public function scopeVisible(): Builder { $permissionService = app()->make(PermissionService::class); return $permissionService->filterRelatedEntity( diff --git a/app/Uploads/AttachmentService.php b/app/Uploads/AttachmentService.php index d530d8fbe..2ad1663ff 100644 --- a/app/Uploads/AttachmentService.php +++ b/app/Uploads/AttachmentService.php @@ -162,16 +162,16 @@ class AttachmentService $link = trim($requestData['link'] ?? ''); if (!empty($link)) { - $attachment->path = $requestData['link']; if (!$attachment->external) { $this->deleteFileInStorage($attachment); $attachment->external = true; + $attachment->extension = ''; } + $attachment->path = $requestData['link']; } $attachment->save(); - - return $attachment; + return $attachment->refresh(); } /** diff --git a/tests/Api/AttachmentsApiTest.php b/tests/Api/AttachmentsApiTest.php new file mode 100644 index 000000000..88b5b9ddd --- /dev/null +++ b/tests/Api/AttachmentsApiTest.php @@ -0,0 +1,330 @@ +actingAsApiEditor(); + $page = Page::query()->first(); + $attachment = $this->createAttachmentForPage($page, [ + 'name' => 'My test attachment', + 'external' => true, + ]); + + $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id'); + $resp->assertJson(['data' => [ + [ + 'id' => $attachment->id, + 'name' => 'My test attachment', + 'uploaded_to' => $page->id, + 'external' => true, + ], + ]]); + } + + public function test_attachments_listing_based_upon_page_visibility() + { + $this->actingAsApiEditor(); + /** @var Page $page */ + $page = Page::query()->first(); + $attachment = $this->createAttachmentForPage($page, [ + 'name' => 'My test attachment', + 'external' => true, + ]); + + $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id'); + $resp->assertJson(['data' => [ + [ + 'id' => $attachment->id, + ], + ]]); + + $page->restricted = true; + $page->save(); + $this->regenEntityPermissions($page); + + $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id'); + $resp->assertJsonMissing(['data' => [ + [ + 'id' => $attachment->id, + ], + ]]); + } + + public function test_create_endpoint_for_link_attachment() + { + $this->actingAsApiAdmin(); + /** @var Page $page */ + $page = Page::query()->first(); + + $details = [ + 'name' => 'My attachment', + 'uploaded_to' => $page->id, + 'link' => 'https://cats.example.com', + ]; + + $resp = $this->postJson($this->baseEndpoint, $details); + $resp->assertStatus(200); + /** @var Attachment $newItem */ + $newItem = Attachment::query()->orderByDesc('id')->where('name', '=', $details['name'])->first(); + $resp->assertJson(['id' => $newItem->id, 'external' => true, 'name' => $details['name'], 'uploaded_to' => $page->id]); + } + + public function test_create_endpoint_for_upload_attachment() + { + $this->actingAsApiAdmin(); + /** @var Page $page */ + $page = Page::query()->first(); + $file = $this->getTestFile('textfile.txt'); + + $details = [ + 'name' => 'My attachment', + 'uploaded_to' => $page->id, + ]; + + $resp = $this->call('POST', $this->baseEndpoint, $details, [], ['file' => $file]); + $resp->assertStatus(200); + /** @var Attachment $newItem */ + $newItem = Attachment::query()->orderByDesc('id')->where('name', '=', $details['name'])->first(); + $resp->assertJson(['id' => $newItem->id, 'external' => false, 'extension' => 'txt', 'name' => $details['name'], 'uploaded_to' => $page->id]); + $this->assertTrue(file_exists(storage_path($newItem->path))); + unlink(storage_path($newItem->path)); + } + + public function test_name_needed_to_create() + { + $this->actingAsApiAdmin(); + /** @var Page $page */ + $page = Page::query()->first(); + + $details = [ + 'uploaded_to' => $page->id, + 'link' => 'https://example.com', + ]; + + $resp = $this->postJson($this->baseEndpoint, $details); + $resp->assertStatus(422); + $resp->assertJson([ + 'error' => [ + 'message' => 'The given data was invalid.', + 'validation' => [ + 'name' => ['The name field is required.'], + ], + 'code' => 422, + ], + ]); + } + + public function test_link_or_file_needed_to_create() + { + $this->actingAsApiAdmin(); + /** @var Page $page */ + $page = Page::query()->first(); + + $details = [ + 'name' => 'my attachment', + 'uploaded_to' => $page->id, + ]; + + $resp = $this->postJson($this->baseEndpoint, $details); + $resp->assertStatus(422); + $resp->assertJson([ + 'error' => [ + 'message' => 'The given data was invalid.', + 'validation' => [ + "file" => ["The file field is required when link is not present."], + "link" => ["The link field is required when file is not present."], + ], + 'code' => 422, + ], + ]); + } + + public function test_read_endpoint_for_link_attachment() + { + $this->actingAsApiAdmin(); + /** @var Page $page */ + $page = Page::query()->first(); + + $attachment = $this->createAttachmentForPage($page, [ + 'name' => 'my attachment', + 'path' => 'https://example.com', + 'order' => 1, + ]); + + $resp = $this->getJson("{$this->baseEndpoint}/{$attachment->id}"); + + $resp->assertStatus(200); + $resp->assertJson([ + 'id' => $attachment->id, + 'content' => 'https://example.com', + 'external' => true, + 'uploaded_to' => $page->id, + 'order' => 1, + 'created_by' => [ + 'name' => $attachment->createdBy->name, + ], + 'updated_by' => [ + 'name' => $attachment->createdBy->name, + ], + 'links' => [ + "html" => "id}\">my attachment", + "markdown" => "[my attachment](http://localhost/attachments/{$attachment->id})" + ], + ]); + } + + public function test_read_endpoint_for_file_attachment() + { + $this->actingAsApiAdmin(); + /** @var Page $page */ + $page = Page::query()->first(); + $file = $this->getTestFile('textfile.txt'); + + $details = [ + 'name' => 'My file attachment', + 'uploaded_to' => $page->id, + ]; + $this->call('POST', $this->baseEndpoint, $details, [], ['file' => $file]); + /** @var Attachment $attachment */ + $attachment = Attachment::query()->orderByDesc('id')->where('name', '=', $details['name'])->firstOrFail(); + + $resp = $this->getJson("{$this->baseEndpoint}/{$attachment->id}"); + + $resp->assertStatus(200); + $resp->assertJson([ + 'id' => $attachment->id, + 'content' => base64_encode(file_get_contents(storage_path($attachment->path))), + 'external' => false, + 'uploaded_to' => $page->id, + 'order' => 1, + 'created_by' => [ + 'name' => $attachment->createdBy->name, + ], + 'updated_by' => [ + 'name' => $attachment->updatedBy->name, + ], + 'links' => [ + "html" => "id}\">My file attachment", + "markdown" => "[My file attachment](http://localhost/attachments/{$attachment->id})" + ], + ]); + + unlink(storage_path($attachment->path)); + } + + public function test_update_endpoint() + { + $this->actingAsApiAdmin(); + /** @var Page $page */ + $page = Page::query()->first(); + $attachment = $this->createAttachmentForPage($page); + + $details = [ + 'name' => 'My updated API attachment', + ]; + + $resp = $this->putJson("{$this->baseEndpoint}/{$attachment->id}", $details); + $attachment->refresh(); + + $resp->assertStatus(200); + $resp->assertJson(['id' => $attachment->id, 'name' => 'My updated API attachment']); + } + + public function test_update_link_attachment_to_file() + { + $this->actingAsApiAdmin(); + /** @var Page $page */ + $page = Page::query()->first(); + $attachment = $this->createAttachmentForPage($page); + $file = $this->getTestFile('textfile.txt'); + + + $resp = $this->call('PUT', "{$this->baseEndpoint}/{$attachment->id}", ['name' => 'My updated file'], [], ['file' => $file]); + $resp->assertStatus(200); + + $attachment->refresh(); + $this->assertFalse($attachment->external); + $this->assertEquals('txt', $attachment->extension); + $this->assertStringStartsWith('uploads/files/', $attachment->path); + $this->assertFileExists(storage_path($attachment->path)); + + unlink(storage_path($attachment->path)); + } + + public function test_update_file_attachment_to_link() + { + $this->actingAsApiAdmin(); + /** @var Page $page */ + $page = Page::query()->first(); + $file = $this->getTestFile('textfile.txt'); + $this->call('POST', $this->baseEndpoint, ['name' => 'My file attachment', 'uploaded_to' => $page->id], [], ['file' => $file]); + /** @var Attachment $attachment */ + $attachment = Attachment::query()->where('name', '=', 'My file attachment')->firstOrFail(); + + $filePath = storage_path($attachment->path); + $this->assertFileExists($filePath); + + $details = [ + 'name' => 'My updated API attachment', + 'link' => 'https://cats.example.com' + ]; + + $resp = $this->putJson("{$this->baseEndpoint}/{$attachment->id}", $details); + $resp->assertStatus(200); + $attachment->refresh(); + + $this->assertFileDoesNotExist($filePath); + $this->assertTrue($attachment->external); + $this->assertEquals('https://cats.example.com', $attachment->path); + $this->assertEquals('', $attachment->extension); + } + + public function test_delete_endpoint() + { + $this->actingAsApiAdmin(); + /** @var Page $page */ + $page = Page::query()->first(); + $attachment = $this->createAttachmentForPage($page); + + $resp = $this->deleteJson("{$this->baseEndpoint}/{$attachment->id}"); + + $resp->assertStatus(204); + $this->assertDatabaseMissing('attachments', ['id' => $attachment->id]); + } + + protected function createAttachmentForPage(Page $page, $attributes = []): Attachment + { + $admin = $this->getAdmin(); + /** @var Attachment $attachment */ + $attachment = $page->attachments()->forceCreate(array_merge([ + 'uploaded_to' => $page->id, + 'name' => 'test attachment', + 'external' => true, + 'order' => 1, + 'created_by' => $admin->id, + 'updated_by' => $admin->id, + 'path' => 'https://attachment.example.com' + ], $attributes)); + return $attachment; + } + + /** + * Get a test file that can be uploaded. + */ + protected function getTestFile(string $fileName): UploadedFile + { + return new UploadedFile(base_path('tests/test-data/test-file.txt'), $fileName, 'text/plain', 55, null, true); + } +} diff --git a/tests/Api/TestsApi.php b/tests/Api/TestsApi.php index 683ca0c74..97ca82ea7 100644 --- a/tests/Api/TestsApi.php +++ b/tests/Api/TestsApi.php @@ -17,6 +17,16 @@ trait TestsApi return $this; } + /** + * Set the API admin role as the current user via the API driver. + */ + protected function actingAsApiAdmin() + { + $this->actingAs($this->getAdmin(), 'api'); + + return $this; + } + /** * Format the given items into a standardised error format. */ diff --git a/tests/Uploads/AttachmentTest.php b/tests/Uploads/AttachmentTest.php index 2248bc2c5..60fd370b6 100644 --- a/tests/Uploads/AttachmentTest.php +++ b/tests/Uploads/AttachmentTest.php @@ -76,9 +76,9 @@ class AttachmentTest extends TestCase $upload->assertStatus(200); $attachment = Attachment::query()->orderBy('id', 'desc')->first(); - $expectedResp['path'] = $attachment->path; - $upload->assertJson($expectedResp); + + $expectedResp['path'] = $attachment->path; $this->assertDatabaseHas('attachments', $expectedResp); $this->deleteUploads(); From 60d4c5902b1a3d03ae493fdeabfa09bbf80d844d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 20 Oct 2021 10:43:03 +0100 Subject: [PATCH 39/50] Added attachment API examples during manual testing --- .../Api/AttachmentApiController.php | 5 ++++ app/Uploads/Attachment.php | 2 +- dev/api/requests/attachments-create.json | 5 ++++ dev/api/requests/attachments-update.json | 5 ++++ dev/api/responses/attachments-create.json | 12 ++++++++ dev/api/responses/attachments-list.json | 29 +++++++++++++++++++ dev/api/responses/attachments-read.json | 25 ++++++++++++++++ dev/api/responses/attachments-update.json | 12 ++++++++ 8 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 dev/api/requests/attachments-create.json create mode 100644 dev/api/requests/attachments-update.json create mode 100644 dev/api/responses/attachments-create.json create mode 100644 dev/api/responses/attachments-list.json create mode 100644 dev/api/responses/attachments-read.json create mode 100644 dev/api/responses/attachments-update.json diff --git a/app/Http/Controllers/Api/AttachmentApiController.php b/app/Http/Controllers/Api/AttachmentApiController.php index 7aa4ee493..353cb058d 100644 --- a/app/Http/Controllers/Api/AttachmentApiController.php +++ b/app/Http/Controllers/Api/AttachmentApiController.php @@ -52,6 +52,9 @@ class AttachmentApiController extends ApiController * An uploaded_to value must be provided containing an ID of the page * that this upload will be related to. * + * If you're uploading a file the POST data should be provided via + * a multipart/form-data type request instead of JSON. + * * @throws ValidationException * @throws FileUploadException */ @@ -108,6 +111,8 @@ class AttachmentApiController extends ApiController /** * Update the details of a single attachment. + * As per the create endpoint, if a file is being provided as the attachment content + * the request should be formatted as a multipart/form-data request instead of JSON. * * @throws ValidationException * @throws FileUploadException diff --git a/app/Uploads/Attachment.php b/app/Uploads/Attachment.php index 8ae53199e..410a7d4dd 100644 --- a/app/Uploads/Attachment.php +++ b/app/Uploads/Attachment.php @@ -29,7 +29,7 @@ class Attachment extends Model use HasCreatorAndUpdater; protected $fillable = ['name', 'order']; - protected $hidden = ['path']; + protected $hidden = ['path', 'page']; protected $casts = [ 'external' => 'bool', ]; diff --git a/dev/api/requests/attachments-create.json b/dev/api/requests/attachments-create.json new file mode 100644 index 000000000..8ed34b24e --- /dev/null +++ b/dev/api/requests/attachments-create.json @@ -0,0 +1,5 @@ +{ + "name": "My uploaded attachment", + "uploaded_to": 8, + "link": "https://link.example.com" +} \ No newline at end of file diff --git a/dev/api/requests/attachments-update.json b/dev/api/requests/attachments-update.json new file mode 100644 index 000000000..062050b3a --- /dev/null +++ b/dev/api/requests/attachments-update.json @@ -0,0 +1,5 @@ +{ + "name": "My updated attachment", + "uploaded_to": 4, + "link": "https://link.example.com/updated" +} \ No newline at end of file diff --git a/dev/api/responses/attachments-create.json b/dev/api/responses/attachments-create.json new file mode 100644 index 000000000..5af524e1a --- /dev/null +++ b/dev/api/responses/attachments-create.json @@ -0,0 +1,12 @@ +{ + "id": 5, + "name": "My uploaded attachment", + "extension": "", + "uploaded_to": 8, + "external": true, + "order": 2, + "created_by": 1, + "updated_by": 1, + "created_at": "2021-10-20 06:35:46", + "updated_at": "2021-10-20 06:35:46" +} \ No newline at end of file diff --git a/dev/api/responses/attachments-list.json b/dev/api/responses/attachments-list.json new file mode 100644 index 000000000..946dd542a --- /dev/null +++ b/dev/api/responses/attachments-list.json @@ -0,0 +1,29 @@ +{ + "data": [ + { + "id": 3, + "name": "datasheet.pdf", + "extension": "pdf", + "uploaded_to": 8, + "external": false, + "order": 1, + "created_at": "2021-10-11 06:18:49", + "updated_at": "2021-10-20 06:31:10", + "created_by": 1, + "updated_by": 1 + }, + { + "id": 4, + "name": "Cat reference", + "extension": "", + "uploaded_to": 9, + "external": true, + "order": 1, + "created_at": "2021-10-20 06:30:11", + "updated_at": "2021-10-20 06:30:11", + "created_by": 1, + "updated_by": 1 + } + ], + "total": 2 +} \ No newline at end of file diff --git a/dev/api/responses/attachments-read.json b/dev/api/responses/attachments-read.json new file mode 100644 index 000000000..e22f4e5fe --- /dev/null +++ b/dev/api/responses/attachments-read.json @@ -0,0 +1,25 @@ +{ + "id": 5, + "name": "My link attachment", + "extension": "", + "uploaded_to": 4, + "external": true, + "order": 2, + "created_by": { + "id": 1, + "name": "Admin", + "slug": "admin" + }, + "updated_by": { + "id": 1, + "name": "Admin", + "slug": "admin" + }, + "created_at": "2021-10-20 06:35:46", + "updated_at": "2021-10-20 06:37:11", + "links": { + "html": "My updated attachment", + "markdown": "[My updated attachment](https://bookstack.local/attachments/5)" + }, + "content": "https://link.example.com/updated" +} \ No newline at end of file diff --git a/dev/api/responses/attachments-update.json b/dev/api/responses/attachments-update.json new file mode 100644 index 000000000..8054b0e48 --- /dev/null +++ b/dev/api/responses/attachments-update.json @@ -0,0 +1,12 @@ +{ + "id": 5, + "name": "My updated attachment", + "extension": "", + "uploaded_to": 4, + "external": true, + "order": 2, + "created_by": 1, + "updated_by": 1, + "created_at": "2021-10-20 06:35:46", + "updated_at": "2021-10-20 06:37:11" +} \ No newline at end of file From 7e28c76e6fef824d9efcce3d75f5050a389b4c00 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 20 Oct 2021 10:46:06 +0100 Subject: [PATCH 40/50] Adjusted API docs table --- resources/views/api-docs/parts/getting-started.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/api-docs/parts/getting-started.blade.php b/resources/views/api-docs/parts/getting-started.blade.php index ba0f85fc7..ca28a7d90 100644 --- a/resources/views/api-docs/parts/getting-started.blade.php +++ b/resources/views/api-docs/parts/getting-started.blade.php @@ -44,7 +44,7 @@

    - + From 859934d6a3f9c94298dee1f177b95f192f4a90b7 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 20 Oct 2021 10:49:45 +0100 Subject: [PATCH 41/50] Applied latest changes from styleCI --- .../Api/AttachmentApiController.php | 19 +++-- app/Uploads/Attachment.php | 13 ++-- app/Uploads/AttachmentService.php | 1 + tests/Api/AttachmentsApiTest.php | 78 +++++++++---------- tests/Entity/PageContentTest.php | 4 +- 5 files changed, 60 insertions(+), 55 deletions(-) diff --git a/app/Http/Controllers/Api/AttachmentApiController.php b/app/Http/Controllers/Api/AttachmentApiController.php index 353cb058d..06d9f6907 100644 --- a/app/Http/Controllers/Api/AttachmentApiController.php +++ b/app/Http/Controllers/Api/AttachmentApiController.php @@ -17,16 +17,16 @@ class AttachmentApiController extends ApiController protected $rules = [ 'create' => [ - 'name' => 'required|min:1|max:255|string', + 'name' => 'required|min:1|max:255|string', 'uploaded_to' => 'required|integer|exists:pages,id', - 'file' => 'required_without:link|file', - 'link' => 'required_without:file|min:1|max:255|safe_url' + 'file' => 'required_without:link|file', + 'link' => 'required_without:file|min:1|max:255|safe_url', ], 'update' => [ - 'name' => 'min:1|max:255|string', + 'name' => 'min:1|max:255|string', 'uploaded_to' => 'integer|exists:pages,id', - 'file' => 'file', - 'link' => 'min:1|max:255|safe_url' + 'file' => 'file', + 'link' => 'min:1|max:255|safe_url', ], ]; @@ -72,11 +72,14 @@ class AttachmentApiController extends ApiController $attachment = $this->attachmentService->saveNewUpload($uploadedFile, $page->id); } else { $attachment = $this->attachmentService->saveNewFromLink( - $requestData['name'], $requestData['link'], $page->id + $requestData['name'], + $requestData['link'], + $page->id ); } $this->attachmentService->updateFile($attachment, $requestData); + return response()->json($attachment); } @@ -140,6 +143,7 @@ class AttachmentApiController extends ApiController } $this->attachmentService->updateFile($attachment, $requestData); + return response()->json($attachment); } @@ -158,5 +162,4 @@ class AttachmentApiController extends ApiController return response('', 204); } - } diff --git a/app/Uploads/Attachment.php b/app/Uploads/Attachment.php index 410a7d4dd..a470ec534 100644 --- a/app/Uploads/Attachment.php +++ b/app/Uploads/Attachment.php @@ -12,15 +12,15 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsTo; /** - * @property int $id + * @property int $id * @property string $name * @property string $path * @property string $extension - * @property ?Page $page - * @property bool $external - * @property int $uploaded_to - * @property User $updatedBy - * @property User $createdBy + * @property ?Page $page + * @property bool $external + * @property int $uploaded_to + * @property User $updatedBy + * @property User $createdBy * * @method static Entity|Builder visible() */ @@ -90,6 +90,7 @@ class Attachment extends Model public function scopeVisible(): Builder { $permissionService = app()->make(PermissionService::class); + return $permissionService->filterRelatedEntity( Page::class, Attachment::query(), diff --git a/app/Uploads/AttachmentService.php b/app/Uploads/AttachmentService.php index 2ad1663ff..f7a0918c6 100644 --- a/app/Uploads/AttachmentService.php +++ b/app/Uploads/AttachmentService.php @@ -171,6 +171,7 @@ class AttachmentService } $attachment->save(); + return $attachment->refresh(); } diff --git a/tests/Api/AttachmentsApiTest.php b/tests/Api/AttachmentsApiTest.php index 88b5b9ddd..40624dcdf 100644 --- a/tests/Api/AttachmentsApiTest.php +++ b/tests/Api/AttachmentsApiTest.php @@ -18,17 +18,17 @@ class AttachmentsApiTest extends TestCase $this->actingAsApiEditor(); $page = Page::query()->first(); $attachment = $this->createAttachmentForPage($page, [ - 'name' => 'My test attachment', + 'name' => 'My test attachment', 'external' => true, ]); $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id'); $resp->assertJson(['data' => [ [ - 'id' => $attachment->id, - 'name' => 'My test attachment', + 'id' => $attachment->id, + 'name' => 'My test attachment', 'uploaded_to' => $page->id, - 'external' => true, + 'external' => true, ], ]]); } @@ -39,7 +39,7 @@ class AttachmentsApiTest extends TestCase /** @var Page $page */ $page = Page::query()->first(); $attachment = $this->createAttachmentForPage($page, [ - 'name' => 'My test attachment', + 'name' => 'My test attachment', 'external' => true, ]); @@ -69,9 +69,9 @@ class AttachmentsApiTest extends TestCase $page = Page::query()->first(); $details = [ - 'name' => 'My attachment', + 'name' => 'My attachment', 'uploaded_to' => $page->id, - 'link' => 'https://cats.example.com', + 'link' => 'https://cats.example.com', ]; $resp = $this->postJson($this->baseEndpoint, $details); @@ -89,7 +89,7 @@ class AttachmentsApiTest extends TestCase $file = $this->getTestFile('textfile.txt'); $details = [ - 'name' => 'My attachment', + 'name' => 'My attachment', 'uploaded_to' => $page->id, ]; @@ -110,14 +110,14 @@ class AttachmentsApiTest extends TestCase $details = [ 'uploaded_to' => $page->id, - 'link' => 'https://example.com', + 'link' => 'https://example.com', ]; $resp = $this->postJson($this->baseEndpoint, $details); $resp->assertStatus(422); $resp->assertJson([ 'error' => [ - 'message' => 'The given data was invalid.', + 'message' => 'The given data was invalid.', 'validation' => [ 'name' => ['The name field is required.'], ], @@ -133,7 +133,7 @@ class AttachmentsApiTest extends TestCase $page = Page::query()->first(); $details = [ - 'name' => 'my attachment', + 'name' => 'my attachment', 'uploaded_to' => $page->id, ]; @@ -141,10 +141,10 @@ class AttachmentsApiTest extends TestCase $resp->assertStatus(422); $resp->assertJson([ 'error' => [ - 'message' => 'The given data was invalid.', + 'message' => 'The given data was invalid.', 'validation' => [ - "file" => ["The file field is required when link is not present."], - "link" => ["The link field is required when file is not present."], + 'file' => ['The file field is required when link is not present.'], + 'link' => ['The link field is required when file is not present.'], ], 'code' => 422, ], @@ -158,8 +158,8 @@ class AttachmentsApiTest extends TestCase $page = Page::query()->first(); $attachment = $this->createAttachmentForPage($page, [ - 'name' => 'my attachment', - 'path' => 'https://example.com', + 'name' => 'my attachment', + 'path' => 'https://example.com', 'order' => 1, ]); @@ -167,20 +167,20 @@ class AttachmentsApiTest extends TestCase $resp->assertStatus(200); $resp->assertJson([ - 'id' => $attachment->id, - 'content' => 'https://example.com', - 'external' => true, + 'id' => $attachment->id, + 'content' => 'https://example.com', + 'external' => true, 'uploaded_to' => $page->id, - 'order' => 1, - 'created_by' => [ + 'order' => 1, + 'created_by' => [ 'name' => $attachment->createdBy->name, ], 'updated_by' => [ 'name' => $attachment->createdBy->name, ], 'links' => [ - "html" => "id}\">my attachment", - "markdown" => "[my attachment](http://localhost/attachments/{$attachment->id})" + 'html' => "id}\">my attachment", + 'markdown' => "[my attachment](http://localhost/attachments/{$attachment->id})", ], ]); } @@ -193,7 +193,7 @@ class AttachmentsApiTest extends TestCase $file = $this->getTestFile('textfile.txt'); $details = [ - 'name' => 'My file attachment', + 'name' => 'My file attachment', 'uploaded_to' => $page->id, ]; $this->call('POST', $this->baseEndpoint, $details, [], ['file' => $file]); @@ -204,20 +204,20 @@ class AttachmentsApiTest extends TestCase $resp->assertStatus(200); $resp->assertJson([ - 'id' => $attachment->id, - 'content' => base64_encode(file_get_contents(storage_path($attachment->path))), - 'external' => false, + 'id' => $attachment->id, + 'content' => base64_encode(file_get_contents(storage_path($attachment->path))), + 'external' => false, 'uploaded_to' => $page->id, - 'order' => 1, - 'created_by' => [ + 'order' => 1, + 'created_by' => [ 'name' => $attachment->createdBy->name, ], 'updated_by' => [ 'name' => $attachment->updatedBy->name, ], 'links' => [ - "html" => "id}\">My file attachment", - "markdown" => "[My file attachment](http://localhost/attachments/{$attachment->id})" + 'html' => "id}\">My file attachment", + 'markdown' => "[My file attachment](http://localhost/attachments/{$attachment->id})", ], ]); @@ -250,7 +250,6 @@ class AttachmentsApiTest extends TestCase $attachment = $this->createAttachmentForPage($page); $file = $this->getTestFile('textfile.txt'); - $resp = $this->call('PUT', "{$this->baseEndpoint}/{$attachment->id}", ['name' => 'My updated file'], [], ['file' => $file]); $resp->assertStatus(200); @@ -278,7 +277,7 @@ class AttachmentsApiTest extends TestCase $details = [ 'name' => 'My updated API attachment', - 'link' => 'https://cats.example.com' + 'link' => 'https://cats.example.com', ]; $resp = $this->putJson("{$this->baseEndpoint}/{$attachment->id}", $details); @@ -310,13 +309,14 @@ class AttachmentsApiTest extends TestCase /** @var Attachment $attachment */ $attachment = $page->attachments()->forceCreate(array_merge([ 'uploaded_to' => $page->id, - 'name' => 'test attachment', - 'external' => true, - 'order' => 1, - 'created_by' => $admin->id, - 'updated_by' => $admin->id, - 'path' => 'https://attachment.example.com' + 'name' => 'test attachment', + 'external' => true, + 'order' => 1, + 'created_by' => $admin->id, + 'updated_by' => $admin->id, + 'path' => 'https://attachment.example.com', ], $attributes)); + return $attachment; } diff --git a/tests/Entity/PageContentTest.php b/tests/Entity/PageContentTest.php index 60fa6fd77..ca2a24131 100644 --- a/tests/Entity/PageContentTest.php +++ b/tests/Entity/PageContentTest.php @@ -614,7 +614,7 @@ class PageContentTest extends TestCase $page = Page::query()->first(); $this->put($page->getUrl(), [ - 'name' => $page->name, 'summary' => '', + 'name' => $page->name, 'summary' => '', 'markdown' => 'test ![test](data:image/jpeg;base64,' . $this->base64Jpeg . ')', ]); @@ -636,7 +636,7 @@ class PageContentTest extends TestCase $page = Page::query()->first(); $this->put($page->getUrl(), [ - 'name' => $page->name, 'summary' => '', + 'name' => $page->name, 'summary' => '', 'markdown' => 'test ![test](data:image/jiff;base64,' . $this->base64Jpeg . ')', ]); From cdef1b3ab05123ed2d92047dc949f8e8b1e4aaa0 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 20 Oct 2021 13:30:45 +0100 Subject: [PATCH 42/50] Updated SAML ACS post to retain user session Session was being lost due to the callback POST request cookies not being provided due to samesite=lax. This instead adds an additional hop in the flow to route the request via a GET request so the session is retained. SAML POST data is stored encrypted in cache via a unique ID then pulled out straight afterwards, and restored into POST for the SAML toolkit to validate. Updated testing to cover. --- app/Auth/Access/Saml2Service.php | 5 +- app/Http/Controllers/Auth/Saml2Controller.php | 54 +++++- routes/web.php | 3 +- tests/Auth/Saml2Test.php | 177 +++++++++--------- 4 files changed, 144 insertions(+), 95 deletions(-) diff --git a/app/Auth/Access/Saml2Service.php b/app/Auth/Access/Saml2Service.php index 6d3915c4d..9c208832a 100644 --- a/app/Auth/Access/Saml2Service.php +++ b/app/Auth/Access/Saml2Service.php @@ -91,8 +91,11 @@ class Saml2Service * @throws JsonDebugException * @throws UserRegistrationException */ - public function processAcsResponse(?string $requestId): ?User + public function processAcsResponse(string $requestId, string $samlResponse): ?User { + // The SAML2 toolkit expects the response to be within the $_POST superglobal + // so we need to manually put it back there at this point. + $_POST['SAMLResponse'] = $samlResponse; $toolkit = $this->getToolkit(); $toolkit->processResponse($requestId); $errors = $toolkit->getErrors(); diff --git a/app/Http/Controllers/Auth/Saml2Controller.php b/app/Http/Controllers/Auth/Saml2Controller.php index 14eb65b71..6a9071f98 100644 --- a/app/Http/Controllers/Auth/Saml2Controller.php +++ b/app/Http/Controllers/Auth/Saml2Controller.php @@ -4,6 +4,9 @@ namespace BookStack\Http\Controllers\Auth; use BookStack\Auth\Access\Saml2Service; use BookStack\Http\Controllers\Controller; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; +use Str; class Saml2Controller extends Controller { @@ -68,17 +71,56 @@ class Saml2Controller extends Controller } /** - * Assertion Consumer Service. - * Processes the SAML response from the IDP. + * Assertion Consumer Service start URL. Takes the SAMLResponse from the IDP. + * Due to being an external POST request, we likely won't have context of the + * current user session due to lax cookies. To work around this we store the + * SAMLResponse data and redirect to the processAcs endpoint for the actual + * processing of the request with proper context of the user session. */ - public function acs() + public function startAcs(Request $request) { - $requestId = session()->pull('saml2_request_id', null); + // Note: This is a bit of a hack to prevent a session being stored + // on the response of this request. Within Laravel7+ this could instead + // be done via removing the StartSession middleware from the route. + config()->set('session.driver', 'array'); - $user = $this->samlService->processAcsResponse($requestId); - if ($user === null) { + $samlResponse = $request->get('SAMLResponse', null); + + if (empty($samlResponse)) { $this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')])); + return redirect('/login'); + } + $acsId = Str::random(16); + $cacheKey = 'saml2_acs:' . $acsId; + cache()->set($cacheKey, encrypt($samlResponse), 10); + + return redirect()->guest('/saml2/acs?id=' . $acsId); + } + + /** + * Assertion Consumer Service process endpoint. + * Processes the SAML response from the IDP with context of the current session. + * Takes the SAML request from the cache, added by the startAcs method above. + */ + public function processAcs(Request $request) + { + $acsId = $request->get('id', null); + $cacheKey = 'saml2_acs:' . $acsId; + $samlResponse = null; + try { + $samlResponse = decrypt(cache()->pull($cacheKey)); + } catch (\Exception $exception) {} + $requestId = session()->pull('saml2_request_id', 'unset'); + + if (empty($acsId) || empty($samlResponse)) { + $this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')])); + return redirect('/login'); + } + + $user = $this->samlService->processAcsResponse($requestId, $samlResponse); + if (is_null($user)) { + $this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')])); return redirect('/login'); } diff --git a/routes/web.php b/routes/web.php index 254076451..a5f35fb8a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -265,7 +265,8 @@ Route::post('/saml2/login', 'Auth\Saml2Controller@login'); Route::get('/saml2/logout', 'Auth\Saml2Controller@logout'); Route::get('/saml2/metadata', 'Auth\Saml2Controller@metadata'); Route::get('/saml2/sls', 'Auth\Saml2Controller@sls'); -Route::post('/saml2/acs', 'Auth\Saml2Controller@acs'); +Route::post('/saml2/acs', 'Auth\Saml2Controller@startAcs'); +Route::get('/saml2/acs', 'Auth\Saml2Controller@processAcs'); // OIDC routes Route::post('/oidc/login', 'Auth\OidcController@login'); diff --git a/tests/Auth/Saml2Test.php b/tests/Auth/Saml2Test.php index 8ace3e2ee..7fb8d6ddb 100644 --- a/tests/Auth/Saml2Test.php +++ b/tests/Auth/Saml2Test.php @@ -68,17 +68,47 @@ class Saml2Test extends TestCase config()->set(['saml2.onelogin.strict' => false]); $this->assertFalse($this->isAuthenticated()); - $this->withPost(['SAMLResponse' => $this->acsPostData], function () { - $acsPost = $this->post('/saml2/acs'); - $acsPost->assertRedirect('/'); - $this->assertTrue($this->isAuthenticated()); - $this->assertDatabaseHas('users', [ - 'email' => 'user@example.com', - 'external_auth_id' => 'user', - 'email_confirmed' => false, - 'name' => 'Barry Scott', - ]); - }); + $acsPost = $this->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]); + $redirect = $acsPost->headers->get('Location'); + $acsId = explode('?id=', $redirect)[1]; + $this->assertTrue(strlen($acsId) > 12); + + $this->assertStringContainsString('/saml2/acs?id=', $redirect); + $this->assertTrue(cache()->has('saml2_acs:' . $acsId)); + + $acsGet = $this->get($redirect); + $acsGet->assertRedirect('/'); + $this->assertFalse(cache()->has('saml2_acs:' . $acsId)); + + $this->assertTrue($this->isAuthenticated()); + $this->assertDatabaseHas('users', [ + 'email' => 'user@example.com', + 'external_auth_id' => 'user', + 'email_confirmed' => false, + 'name' => 'Barry Scott', + ]); + } + + public function test_acs_process_id_randomly_generated() + { + $acsPost = $this->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]); + $redirectA = $acsPost->headers->get('Location'); + + $acsPost = $this->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]); + $redirectB = $acsPost->headers->get('Location'); + + $this->assertFalse($redirectA === $redirectB); + } + + public function test_process_acs_endpoint_cant_be_called_with_invalid_id() + { + $resp = $this->get('/saml2/acs'); + $resp->assertRedirect('/login'); + $this->followRedirects($resp)->assertSeeText('Login using SingleSignOn-Testing failed, system did not provide successful authorization'); + + $resp = $this->get('/saml2/acs?id=abc123'); + $resp->assertRedirect('/login'); + $this->followRedirects($resp)->assertSeeText('Login using SingleSignOn-Testing failed, system did not provide successful authorization'); } public function test_group_role_sync_on_login() @@ -92,14 +122,12 @@ class Saml2Test extends TestCase $memberRole = factory(Role::class)->create(['external_auth_id' => 'member']); $adminRole = Role::getSystemRole('admin'); - $this->withPost(['SAMLResponse' => $this->acsPostData], function () use ($memberRole, $adminRole) { - $acsPost = $this->post('/saml2/acs'); - $user = User::query()->where('external_auth_id', '=', 'user')->first(); + $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]); + $user = User::query()->where('external_auth_id', '=', 'user')->first(); - $userRoleIds = $user->roles()->pluck('id'); - $this->assertContains($memberRole->id, $userRoleIds, 'User was assigned to member role'); - $this->assertContains($adminRole->id, $userRoleIds, 'User was assigned to admin role'); - }); + $userRoleIds = $user->roles()->pluck('id'); + $this->assertContains($memberRole->id, $userRoleIds, 'User was assigned to member role'); + $this->assertContains($adminRole->id, $userRoleIds, 'User was assigned to admin role'); } public function test_group_role_sync_removal_option_works_as_expected() @@ -110,18 +138,16 @@ class Saml2Test extends TestCase 'saml2.remove_from_groups' => true, ]); - $this->withPost(['SAMLResponse' => $this->acsPostData], function () { - $acsPost = $this->post('/saml2/acs'); - $user = User::query()->where('external_auth_id', '=', 'user')->first(); + $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]); + $user = User::query()->where('external_auth_id', '=', 'user')->first(); - $randomRole = factory(Role::class)->create(['external_auth_id' => 'random']); - $user->attachRole($randomRole); - $this->assertContains($randomRole->id, $user->roles()->pluck('id')); + $randomRole = factory(Role::class)->create(['external_auth_id' => 'random']); + $user->attachRole($randomRole); + $this->assertContains($randomRole->id, $user->roles()->pluck('id')); - auth()->logout(); - $acsPost = $this->post('/saml2/acs'); - $this->assertNotContains($randomRole->id, $user->roles()->pluck('id')); - }); + auth()->logout(); + $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]); + $this->assertNotContains($randomRole->id, $user->roles()->pluck('id')); } public function test_logout_link_directs_to_saml_path() @@ -149,16 +175,12 @@ class Saml2Test extends TestCase $this->assertFalse($this->isAuthenticated()); }; - $loginAndStartLogout = function () use ($handleLogoutResponse) { - $this->post('/saml2/acs'); + $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]); - $req = $this->get('/saml2/logout'); - $redirect = $req->headers->get('location'); - $this->assertStringStartsWith('http://saml.local/saml2/idp/SingleLogoutService.php', $redirect); - $this->withGet(['SAMLResponse' => $this->sloResponseData], $handleLogoutResponse); - }; - - $this->withPost(['SAMLResponse' => $this->acsPostData], $loginAndStartLogout); + $req = $this->get('/saml2/logout'); + $redirect = $req->headers->get('location'); + $this->assertStringStartsWith('http://saml.local/saml2/idp/SingleLogoutService.php', $redirect); + $this->withGet(['SAMLResponse' => $this->sloResponseData], $handleLogoutResponse); } public function test_logout_sls_flow_when_sls_not_configured() @@ -168,15 +190,12 @@ class Saml2Test extends TestCase 'saml2.onelogin.idp.singleLogoutService.url' => null, ]); - $loginAndStartLogout = function () { - $this->post('/saml2/acs'); + $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]); + $this->assertTrue($this->isAuthenticated()); - $req = $this->get('/saml2/logout'); - $req->assertRedirect('/'); - $this->assertFalse($this->isAuthenticated()); - }; - - $this->withPost(['SAMLResponse' => $this->acsPostData], $loginAndStartLogout); + $req = $this->get('/saml2/logout'); + $req->assertRedirect('/'); + $this->assertFalse($this->isAuthenticated()); } public function test_dump_user_details_option_works() @@ -186,14 +205,12 @@ class Saml2Test extends TestCase 'saml2.dump_user_details' => true, ]); - $this->withPost(['SAMLResponse' => $this->acsPostData], function () { - $acsPost = $this->post('/saml2/acs'); - $acsPost->assertJsonStructure([ - 'id_from_idp', - 'attrs_from_idp' => [], - 'attrs_after_parsing' => [], - ]); - }); + $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]); + $acsPost->assertJsonStructure([ + 'id_from_idp', + 'attrs_from_idp' => [], + 'attrs_after_parsing' => [], + ]); } public function test_saml_routes_are_only_active_if_saml_enabled() @@ -263,13 +280,10 @@ class Saml2Test extends TestCase 'saml2.onelogin.strict' => false, ]); - $this->withPost(['SAMLResponse' => $this->acsPostData], function () { - $acsPost = $this->post('/saml2/acs'); - $acsPost->assertRedirect('/login'); - $errorMessage = session()->get('error'); - $this->assertStringContainsString('That email domain does not have access to this application', $errorMessage); - $this->assertDatabaseMissing('users', ['email' => 'user@example.com']); - }); + $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]); + $acsPost->assertSeeText('That email domain does not have access to this application'); + $this->assertFalse(auth()->check()); + $this->assertDatabaseMissing('users', ['email' => 'user@example.com']); } public function test_group_sync_functions_when_email_confirmation_required() @@ -284,19 +298,17 @@ class Saml2Test extends TestCase $memberRole = factory(Role::class)->create(['external_auth_id' => 'member']); $adminRole = Role::getSystemRole('admin'); - $this->withPost(['SAMLResponse' => $this->acsPostData], function () use ($memberRole, $adminRole) { - $acsPost = $this->followingRedirects()->post('/saml2/acs'); + $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]); - $this->assertEquals('http://localhost/register/confirm', url()->current()); - $acsPost->assertSee('Please check your email and click the confirmation button to access BookStack.'); - /** @var User $user */ - $user = User::query()->where('external_auth_id', '=', 'user')->first(); + $this->assertEquals('http://localhost/register/confirm', url()->current()); + $acsPost->assertSee('Please check your email and click the confirmation button to access BookStack.'); + /** @var User $user */ + $user = User::query()->where('external_auth_id', '=', 'user')->first(); - $userRoleIds = $user->roles()->pluck('id'); - $this->assertContains($memberRole->id, $userRoleIds, 'User was assigned to member role'); - $this->assertContains($adminRole->id, $userRoleIds, 'User was assigned to admin role'); - $this->assertFalse(boolval($user->email_confirmed), 'User email remains unconfirmed'); - }); + $userRoleIds = $user->roles()->pluck('id'); + $this->assertContains($memberRole->id, $userRoleIds, 'User was assigned to member role'); + $this->assertContains($adminRole->id, $userRoleIds, 'User was assigned to admin role'); + $this->assertFalse(boolval($user->email_confirmed), 'User email remains unconfirmed'); $this->assertNull(auth()->user()); $homeGet = $this->get('/'); @@ -316,18 +328,14 @@ class Saml2Test extends TestCase 'name' => 'Barry Scott', ]); - $this->withPost(['SAMLResponse' => $this->acsPostData], function () { - $acsPost = $this->post('/saml2/acs'); - $acsPost->assertRedirect('/login'); - $this->assertFalse($this->isAuthenticated()); - $this->assertDatabaseHas('users', [ - 'email' => 'user@example.com', - 'external_auth_id' => 'old_system_user_id', - ]); + $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]); + $this->assertFalse($this->isAuthenticated()); + $this->assertDatabaseHas('users', [ + 'email' => 'user@example.com', + 'external_auth_id' => 'old_system_user_id', + ]); - $loginGet = $this->get('/login'); - $loginGet->assertSee('A user with the email user@example.com already exists but with different credentials'); - }); + $acsPost->assertSee('A user with the email user@example.com already exists but with different credentials'); } public function test_login_request_contains_expected_default_authncontext() @@ -370,11 +378,6 @@ class Saml2Test extends TestCase return $this->withGlobal($_GET, $options, $callback); } - protected function withPost(array $options, callable $callback) - { - return $this->withGlobal($_POST, $options, $callback); - } - protected function withGlobal(array &$global, array $options, callable $callback) { $original = []; From 129f3286d9a57505898bfdf61f812cf0cddb0e4c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 20 Oct 2021 13:40:27 +0100 Subject: [PATCH 43/50] Applied styleci changes --- app/Http/Controllers/Auth/Saml2Controller.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/Auth/Saml2Controller.php b/app/Http/Controllers/Auth/Saml2Controller.php index 6a9071f98..871abf59f 100644 --- a/app/Http/Controllers/Auth/Saml2Controller.php +++ b/app/Http/Controllers/Auth/Saml2Controller.php @@ -88,6 +88,7 @@ class Saml2Controller extends Controller if (empty($samlResponse)) { $this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')])); + return redirect('/login'); } @@ -108,19 +109,23 @@ class Saml2Controller extends Controller $acsId = $request->get('id', null); $cacheKey = 'saml2_acs:' . $acsId; $samlResponse = null; + try { $samlResponse = decrypt(cache()->pull($cacheKey)); - } catch (\Exception $exception) {} + } catch (\Exception $exception) { + } $requestId = session()->pull('saml2_request_id', 'unset'); if (empty($acsId) || empty($samlResponse)) { $this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')])); + return redirect('/login'); } $user = $this->samlService->processAcsResponse($requestId, $samlResponse); if (is_null($user)) { $this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')])); + return redirect('/login'); } From 0b15e2bf1c8f1e196c9c64f3684b3c81f6699ef2 Mon Sep 17 00:00:00 2001 From: Francesco Franchina Date: Fri, 22 Oct 2021 01:34:41 +0200 Subject: [PATCH 44/50] Fixes padding issues of the sidebar's items --- resources/sass/_lists.scss | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/resources/sass/_lists.scss b/resources/sass/_lists.scss index 436c7e533..e5ce1a939 100644 --- a/resources/sass/_lists.scss +++ b/resources/sass/_lists.scss @@ -201,6 +201,7 @@ .entity-list-item-name { font-size: 1em; margin: 0; + margin-right: $-m; } .chapter-child-menu { font-size: .8rem; @@ -410,7 +411,7 @@ ul.pagination { } .entity-list-item, .icon-list-item { - padding: $-s $-m; + padding: $-s 0 $-s $-m; display: flex; align-items: center; background-color: transparent; @@ -682,4 +683,4 @@ ul.pagination { border-radius: 3px; text-decoration: none; } -} \ No newline at end of file +} From 98072ba4a963449c82933bff4781c77bdeda3337 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 23 Oct 2021 17:26:01 +0100 Subject: [PATCH 45/50] Reviewed SAML SLS changes for ADFS, #2902 - Migrated env usages to config. - Removed potentially unneeded config options or auto-set signed options based upon provision of certificate. - Aligned SP certificate env option naming with similar IDP option. Tested via AFDS on windows server 2019. To test on other providers. --- .env.example.complete | 7 ++---- app/Auth/Access/Saml2Service.php | 25 ++++++++++++------- app/Config/saml2.php | 14 +++++++---- app/Http/Controllers/Auth/Saml2Controller.php | 2 +- 4 files changed, 28 insertions(+), 20 deletions(-) diff --git a/.env.example.complete b/.env.example.complete index a29afaafd..683db703c 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -232,11 +232,8 @@ SAML2_ONELOGIN_OVERRIDES=null SAML2_DUMP_USER_DETAILS=false SAML2_AUTOLOAD_METADATA=false SAML2_IDP_AUTHNCONTEXT=true -SAML2_SP_CERTIFICATE=null -SAML2_SP_PRIVATEKEY=null -SAML2_SP_NAME_ID_Format=null -SAML2_SP_NAME_ID_SP_NAME_QUALIFIER=null -SAML2_RETRIEVE_PARAMETERS_FROM_SERVER=false +SAML2_SP_x509=null +SAML2_SP_x509_KEY=null # SAML group sync configuration # Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/ diff --git a/app/Auth/Access/Saml2Service.php b/app/Auth/Access/Saml2Service.php index 58f999709..ad889faf7 100644 --- a/app/Auth/Access/Saml2Service.php +++ b/app/Auth/Access/Saml2Service.php @@ -9,6 +9,7 @@ use BookStack\Exceptions\StoppedAuthenticationException; use BookStack\Exceptions\UserRegistrationException; use Exception; use OneLogin\Saml2\Auth; +use OneLogin\Saml2\Constants; use OneLogin\Saml2\Error; use OneLogin\Saml2\IdPMetadataParser; use OneLogin\Saml2\ValidationError; @@ -59,17 +60,20 @@ class Saml2Service * * @throws Error */ - public function logout(): array + public function logout(User $user): array { $toolKit = $this->getToolkit(); $returnRoute = url('/'); try { - $email = auth()->user()['email']; - $nameIdFormat = env('SAML2_SP_NAME_ID_Format', null); - $nameIdSPNameQualifier = env('SAML2_SP_NAME_ID_SP_NAME_QUALIFIER', null); - - $url = $toolKit->logout($returnRoute, [], $email, null, true, $nameIdFormat, null, $nameIdSPNameQualifier); + $url = $toolKit->logout( + $returnRoute, + [], + $user->email, + null, + true, + Constants::NAMEID_EMAIL_ADDRESS + ); $id = $toolKit->getLastRequestID(); } catch (Error $error) { if ($error->getCode() !== Error::SAML_SINGLE_LOGOUT_NOT_SUPPORTED) { @@ -128,10 +132,13 @@ class Saml2Service public function processSlsResponse(?string $requestId): ?string { $toolkit = $this->getToolkit(); - $retrieveParametersFromServer = env('SAML2_RETRIEVE_PARAMETERS_FROM_SERVER', false); - - $redirect = $toolkit->processSLO(true, $requestId, $retrieveParametersFromServer, null, true); + // The $retrieveParametersFromServer in the call below will mean the library will take the query + // parameters, used for the response signing, from the raw $_SERVER['QUERY_STRING'] + // value so that the exact encoding format is matched when checking the signature. + // This is primarily due to ADFS encoding query params with lowercase percent encoding while + // PHP (And most other sensible providers) standardise on uppercase. + $redirect = $toolkit->processSLO(true, $requestId, true, null, true); $errors = $toolkit->getErrors(); if (!empty($errors)) { diff --git a/app/Config/saml2.php b/app/Config/saml2.php index 3c4319100..44d06c5b2 100644 --- a/app/Config/saml2.php +++ b/app/Config/saml2.php @@ -1,6 +1,7 @@ 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + // Usually x509cert and privateKey of the SP are provided by files placed at // the certs folder. But we can also provide them with the following parameters - 'x509cert' => env('SAML2_SP_CERTIFICATE', ''), - 'privateKey' => env('SAML2_SP_PRIVATEKEY', ''), + 'x509cert' => $SAML2_SP_x509 ?: '', + 'privateKey' => env('SAML2_SP_x509_KEY', ''), ], // Identity Provider Data that we want connect with our SP 'idp' => [ @@ -147,9 +149,11 @@ return [ // Multiple forced values can be passed via a space separated array, For example: // SAML2_IDP_AUTHNCONTEXT="urn:federation:authentication:windows urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport" 'requestedAuthnContext' => is_string($SAML2_IDP_AUTHNCONTEXT) ? explode(' ', $SAML2_IDP_AUTHNCONTEXT) : $SAML2_IDP_AUTHNCONTEXT, - 'logoutRequestSigned' => env('SAML2_LOGOUT_REQUEST_SIGNED', false), - 'logoutResponseSigned' => env('SAML2_LOGOUT_RESPONSE_SIGNED', false), - 'lowercaseUrlencoding' => env('SAML2_LOWERCASE_URLENCODING', false), + // Sign requests and responses if a certificate is in use + 'logoutRequestSigned' => (bool) $SAML2_SP_x509, + 'logoutResponseSigned' => (bool) $SAML2_SP_x509, + 'authnRequestsSigned' => (bool) $SAML2_SP_x509, + 'lowercaseUrlencoding' => false, ], ], diff --git a/app/Http/Controllers/Auth/Saml2Controller.php b/app/Http/Controllers/Auth/Saml2Controller.php index 871abf59f..bd3b25da7 100644 --- a/app/Http/Controllers/Auth/Saml2Controller.php +++ b/app/Http/Controllers/Auth/Saml2Controller.php @@ -37,7 +37,7 @@ class Saml2Controller extends Controller */ public function logout() { - $logoutDetails = $this->samlService->logout(); + $logoutDetails = $this->samlService->logout(auth()->user()); if ($logoutDetails['id']) { session()->flash('saml2_logout_request_id', $logoutDetails['id']); From 31ba972cfc2638e6d90c7af833c895528556a8d1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 23 Oct 2021 22:03:03 +0100 Subject: [PATCH 46/50] Tweaked sidepart list item padding, Review of #3000 - Scoped padding change to just entity-list-items within the sidebar side reduction of right-hand-padding to zero was causing other entity-list-items, such as those in the homepage listing, would then have no padding. - Updated styles to use css logical properties to retain support for RTL languages such as Arabic, where the whole interface flips around. Related: https://css-tricks.com/css-logical-properties-and-values/ --- resources/sass/_lists.scss | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/resources/sass/_lists.scss b/resources/sass/_lists.scss index e5ce1a939..c46ac84f3 100644 --- a/resources/sass/_lists.scss +++ b/resources/sass/_lists.scss @@ -185,6 +185,7 @@ padding-bottom: $-xxs; background-clip: content-box; border-radius: 0 3px 3px 0; + padding-inline-end: 0; .content { padding-top: $-xs; padding-bottom: $-xs; @@ -201,7 +202,7 @@ .entity-list-item-name { font-size: 1em; margin: 0; - margin-right: $-m; + margin-inline-end: $-m; } .chapter-child-menu { font-size: .8rem; @@ -411,7 +412,7 @@ ul.pagination { } .entity-list-item, .icon-list-item { - padding: $-s 0 $-s $-m; + padding: $-s $-m; display: flex; align-items: center; background-color: transparent; From 9c2b8057ab7b744c0824a9a3e48c3ccd36b8c103 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 25 Oct 2021 13:51:27 +0100 Subject: [PATCH 47/50] New Crowdin updates (#2983) * New translations auth.php (Polish) * New translations common.php (Polish) * New translations entities.php (Polish) * New translations auth.php (Polish) * New translations common.php (Polish) * New translations settings.php (Polish) * New translations validation.php (Polish) * New translations activities.php (Estonian) * New translations auth.php (Estonian) * New translations common.php (Estonian) * New translations components.php (Estonian) * New translations entities.php (Estonian) * New translations errors.php (Estonian) * New translations pagination.php (Estonian) * New translations passwords.php (Estonian) * New translations settings.php (Estonian) * New translations validation.php (Estonian) * New translations activities.php (Estonian) * New translations activities.php (Estonian) * New translations auth.php (Estonian) * New translations common.php (Estonian) * New translations components.php (Estonian) * New translations entities.php (Estonian) * New translations pagination.php (Estonian) * New translations passwords.php (Estonian) * New translations entities.php (Estonian) * New translations errors.php (Estonian) * New translations validation.php (Estonian) * New translations settings.php (Estonian) * New translations auth.php (Estonian) * New translations entities.php (Estonian) * New translations passwords.php (Estonian) * New translations settings.php (Estonian) * New translations auth.php (Estonian) * New translations entities.php (Estonian) * New translations errors.php (Estonian) * New translations settings.php (Estonian) * New translations settings.php (Estonian) * New translations errors.php (German) * New translations errors.php (Portuguese, Brazilian) * New translations errors.php (Swedish) * New translations errors.php (Turkish) * New translations errors.php (Ukrainian) * New translations errors.php (Chinese Simplified) * New translations errors.php (Chinese Traditional) * New translations errors.php (Vietnamese) * New translations errors.php (Indonesian) * New translations errors.php (Slovak) * New translations errors.php (Persian) * New translations errors.php (Spanish, Argentina) * New translations errors.php (Croatian) * New translations errors.php (Latvian) * New translations errors.php (Bosnian) * New translations errors.php (Norwegian Bokmal) * New translations errors.php (Slovenian) * New translations errors.php (Russian) * New translations errors.php (Estonian) * New translations errors.php (Danish) * New translations errors.php (French) * New translations errors.php (Spanish) * New translations errors.php (Arabic) * New translations errors.php (Bulgarian) * New translations errors.php (Catalan) * New translations errors.php (Czech) * New translations errors.php (Hebrew) * New translations errors.php (Portuguese) * New translations errors.php (Hungarian) * New translations errors.php (Italian) * New translations errors.php (Japanese) * New translations errors.php (Korean) * New translations errors.php (Lithuanian) * New translations errors.php (Dutch) * New translations errors.php (Polish) * New translations errors.php (German Informal) * New translations errors.php (Spanish) * New translations auth.php (Estonian) * New translations entities.php (Estonian) * New translations errors.php (Estonian) * New translations activities.php (Japanese) * New translations activities.php (Japanese) * New translations auth.php (Japanese) * New translations components.php (Japanese) * New translations passwords.php (Japanese) * New translations errors.php (Estonian) * New translations settings.php (Estonian) * New translations validation.php (Estonian) * New translations errors.php (French) * New translations activities.php (Japanese) * New translations settings.php (Japanese) * New translations entities.php (Japanese) * New translations settings.php (Japanese) * New translations common.php (Japanese) * New translations settings.php (Japanese) * New translations settings.php (Japanese) * New translations entities.php (Japanese) * New translations settings.php (Japanese) * New translations settings.php (Japanese) * New translations entities.php (Japanese) * New translations settings.php (Japanese) * New translations common.php (Japanese) * New translations errors.php (Polish) * New translations auth.php (Estonian) * New translations components.php (Estonian) * New translations entities.php (Estonian) * New translations validation.php (Estonian) * New translations errors.php (Estonian) * New translations settings.php (Estonian) * New translations errors.php (Chinese Simplified) * New translations auth.php (Japanese) * New translations auth.php (Japanese) * New translations common.php (Japanese) * New translations entities.php (Japanese) * New translations errors.php (Italian) * New translations common.php (Japanese) * New translations auth.php (Italian) * New translations entities.php (Italian) * New translations entities.php (Japanese) * New translations settings.php (Japanese) * New translations common.php (Japanese) * New translations entities.php (Japanese) * New translations entities.php (Estonian) * New translations settings.php (Estonian) * New translations validation.php (Japanese) * New translations errors.php (Japanese) * New translations validation.php (Japanese) * New translations auth.php (Japanese) * New translations settings.php (Japanese) * New translations activities.php (Indonesian) * New translations auth.php (Indonesian) * New translations validation.php (Estonian) * New translations settings.php (Estonian) --- resources/lang/ar/errors.php | 4 + resources/lang/bg/errors.php | 4 + resources/lang/bs/errors.php | 4 + resources/lang/ca/errors.php | 4 + resources/lang/cs/errors.php | 4 + resources/lang/da/errors.php | 4 + resources/lang/de/errors.php | 4 + resources/lang/de_informal/errors.php | 6 +- resources/lang/es/errors.php | 4 + resources/lang/es_AR/errors.php | 4 + resources/lang/et/activities.php | 57 +++++ resources/lang/et/auth.php | 112 +++++++++ resources/lang/et/common.php | 95 ++++++++ resources/lang/et/components.php | 34 +++ resources/lang/et/entities.php | 325 ++++++++++++++++++++++++++ resources/lang/et/errors.php | 109 +++++++++ resources/lang/et/pagination.php | 12 + resources/lang/et/passwords.php | 15 ++ resources/lang/et/settings.php | 277 ++++++++++++++++++++++ resources/lang/et/validation.php | 116 +++++++++ resources/lang/fa/errors.php | 4 + resources/lang/fr/errors.php | 4 + resources/lang/he/errors.php | 4 + resources/lang/hr/errors.php | 4 + resources/lang/hu/errors.php | 4 + resources/lang/id/activities.php | 4 +- resources/lang/id/auth.php | 2 +- resources/lang/id/errors.php | 4 + resources/lang/it/auth.php | 52 ++--- resources/lang/it/entities.php | 2 +- resources/lang/it/errors.php | 4 + resources/lang/ja/activities.php | 18 +- resources/lang/ja/auth.php | 66 +++--- resources/lang/ja/common.php | 50 ++-- resources/lang/ja/components.php | 2 +- resources/lang/ja/entities.php | 92 ++++---- resources/lang/ja/errors.php | 22 +- resources/lang/ja/passwords.php | 2 +- resources/lang/ja/settings.php | 214 ++++++++--------- resources/lang/ja/validation.php | 52 ++--- resources/lang/ko/errors.php | 4 + resources/lang/lt/errors.php | 4 + resources/lang/lv/errors.php | 4 + resources/lang/nb/errors.php | 4 + resources/lang/nl/errors.php | 4 + resources/lang/pl/auth.php | 66 +++--- resources/lang/pl/common.php | 12 +- resources/lang/pl/entities.php | 12 +- resources/lang/pl/errors.php | 4 + resources/lang/pl/settings.php | 24 +- resources/lang/pl/validation.php | 4 +- resources/lang/pt/errors.php | 4 + resources/lang/pt_BR/errors.php | 4 + resources/lang/ru/errors.php | 4 + resources/lang/sk/errors.php | 4 + resources/lang/sl/errors.php | 4 + resources/lang/sv/errors.php | 4 + resources/lang/tr/errors.php | 4 + resources/lang/uk/errors.php | 4 + resources/lang/vi/errors.php | 4 + resources/lang/zh_CN/errors.php | 4 + resources/lang/zh_TW/errors.php | 4 + 62 files changed, 1639 insertions(+), 347 deletions(-) create mode 100644 resources/lang/et/activities.php create mode 100644 resources/lang/et/auth.php create mode 100644 resources/lang/et/common.php create mode 100644 resources/lang/et/components.php create mode 100644 resources/lang/et/entities.php create mode 100644 resources/lang/et/errors.php create mode 100644 resources/lang/et/pagination.php create mode 100644 resources/lang/et/passwords.php create mode 100644 resources/lang/et/settings.php create mode 100644 resources/lang/et/validation.php diff --git a/resources/lang/ar/errors.php b/resources/lang/ar/errors.php index 829571c58..c9851588b 100644 --- a/resources/lang/ar/errors.php +++ b/resources/lang/ar/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'تعذر العثور على عنوان بريد إلكتروني، لهذا المستخدم، في البيانات المقدمة من نظام المصادقة الخارجي', 'saml_invalid_response_id' => 'لم يتم التعرف على الطلب من نظام التوثيق الخارجي من خلال عملية تبدأ بهذا التطبيق. العودة بعد تسجيل الدخول يمكن أن يسبب هذه المشكلة.', 'saml_fail_authed' => 'تسجيل الدخول باستخدام :system فشل، النظام لم يوفر التفويض الناجح', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'لم يتم تعريف أي إجراء', 'social_login_bad_response' => "حصل خطأ خلال تسجيل الدخول باستخدام :socialAccount \n:error", 'social_account_in_use' => 'حساب :socialAccount قيد الاستخدام حالياً, الرجاء محاولة الدخول باستخدام خيار :socialAccount.', diff --git a/resources/lang/bg/errors.php b/resources/lang/bg/errors.php index ea6d497f0..514b274ec 100644 --- a/resources/lang/bg/errors.php +++ b/resources/lang/bg/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'Не успяхме да намерим емейл адрес, за този потребител, от информацията предоставена от външната система', 'saml_invalid_response_id' => 'Заявката от външната система не е разпознат от процеса започнат от това приложение. Връщането назад след влизане може да породи този проблем.', 'saml_fail_authed' => 'Влизането чрез :system не беше успешно, системата не успя да оторизира потребителя', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'Действието не беше дефинирано', 'social_login_bad_response' => "Възникна грешка по време на :socialAccount login: \n:error", 'social_account_in_use' => 'Този :socialAccount вече е използван. Опитайте се да влезете чрез опцията за :socialAccount.', diff --git a/resources/lang/bs/errors.php b/resources/lang/bs/errors.php index f36960619..5c3855853 100644 --- a/resources/lang/bs/errors.php +++ b/resources/lang/bs/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'E-mail adresa za ovog korisnika nije nađena u podacima dobijenim od eksternog autentifikacijskog sistema', 'saml_invalid_response_id' => 'Proces, koji je pokrenula ova aplikacija, nije prepoznao zahtjev od eksternog sistema za autentifikaciju. Navigacija nazad nakon prijave može uzrokovati ovaj problem.', 'saml_fail_authed' => 'Prijava koristeći :system nije uspjela, sistem nije obezbijedio uspješnu autorizaciju', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'Nema definisane akcije', 'social_login_bad_response' => "Došlo je do greške prilikom prijave preko :socialAccount :\n:error", 'social_account_in_use' => 'Ovaj :socialAccount račun se već koristi, pokušajte se prijaviti putem :socialAccount opcije.', diff --git a/resources/lang/ca/errors.php b/resources/lang/ca/errors.php index 1a413ba88..477b59e26 100644 --- a/resources/lang/ca/errors.php +++ b/resources/lang/ca/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'No s\'ha pogut trobar cap adreça electrònica, per a aquest usuari, en les dades proporcionades pel sistema d\'autenticació extern', 'saml_invalid_response_id' => 'La petició del sistema d\'autenticació extern no és reconeguda per un procés iniciat per aquesta aplicació. Aquest problema podria ser causat per navegar endarrere després d\'iniciar la sessió.', 'saml_fail_authed' => 'L\'inici de sessió fent servir :system ha fallat, el sistema no ha proporcionat una autorització satisfactòria', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'No hi ha cap acció definida', 'social_login_bad_response' => "S'ha rebut un error mentre s'iniciava la sessió amb :socialAccount: \n:error", 'social_account_in_use' => 'Aquest compte de :socialAccount ja està en ús, proveu d\'iniciar la sessió mitjançant l\'opció de :socialAccount.', diff --git a/resources/lang/cs/errors.php b/resources/lang/cs/errors.php index c948cafd1..54b18766c 100644 --- a/resources/lang/cs/errors.php +++ b/resources/lang/cs/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'Nelze najít e-mailovou adresu pro tohoto uživatele v datech poskytnutých externím přihlašovacím systémem', 'saml_invalid_response_id' => 'Požadavek z externího ověřovacího systému nebyl rozpoznám procesem, který tato aplikace spustila. Tento problém může způsobit stisknutí tlačítka Zpět po přihlášení.', 'saml_fail_authed' => 'Přihlášení pomocí :system selhalo, systém neposkytl úspěšnou autorizaci', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'Nebyla zvolena žádá akce', 'social_login_bad_response' => "Nastala chyba během přihlašování přes :socialAccount \n:error", 'social_account_in_use' => 'Tento účet na :socialAccount se již používá. Pokuste se s ním přihlásit volbou Přihlásit přes :socialAccount.', diff --git a/resources/lang/da/errors.php b/resources/lang/da/errors.php index d54cac243..9bb0cb560 100644 --- a/resources/lang/da/errors.php +++ b/resources/lang/da/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'Kunne ikke finde en e-mail-adresse for denne bruger i de data, der leveres af det eksterne godkendelsessystem', 'saml_invalid_response_id' => 'Anmodningen fra det eksterne godkendelsessystem genkendes ikke af en proces, der er startet af denne applikation. Navigering tilbage efter et login kan forårsage dette problem.', 'saml_fail_authed' => 'Login ved hjælp af :system failed, systemet har ikke givet tilladelse', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'Ingen handling er defineret', 'social_login_bad_response' => "Der opstod en fejl under :socialAccount login:\n:error", 'social_account_in_use' => 'Denne :socialAccount konto er allerede i brug, prøv at logge ind med :socialAccount loginmetoden.', diff --git a/resources/lang/de/errors.php b/resources/lang/de/errors.php index 65174eada..764733768 100644 --- a/resources/lang/de/errors.php +++ b/resources/lang/de/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'Es konnte keine E-Mail-Adresse für diesen Benutzer in den vom externen Authentifizierungssystem zur Verfügung gestellten Daten gefunden werden', 'saml_invalid_response_id' => 'Die Anfrage vom externen Authentifizierungssystem wird von einem von dieser Anwendung gestarteten Prozess nicht erkannt. Das Zurückgehen nach einem Login könnte dieses Problem verursachen.', 'saml_fail_authed' => 'Anmeldung mit :system fehlgeschlagen, System konnte keine erfolgreiche Autorisierung bereitstellen', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'Es ist keine Aktion definiert', 'social_login_bad_response' => "Fehler bei der :socialAccount-Anmeldung: \n:error", 'social_account_in_use' => 'Dieses :socialAccount-Konto wird bereits verwendet. Bitte melden Sie sich mit dem :socialAccount-Konto an.', diff --git a/resources/lang/de_informal/errors.php b/resources/lang/de_informal/errors.php index 181051459..7269c663e 100644 --- a/resources/lang/de_informal/errors.php +++ b/resources/lang/de_informal/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'Es konnte keine E-Mail-Adresse für diesen Benutzer in den vom externen Authentifizierungssystem zur Verfügung gestellten Daten gefunden werden', 'saml_invalid_response_id' => 'Die Anfrage vom externen Authentifizierungssystem wird von einem von dieser Anwendung gestarteten Prozess nicht erkannt. Das Zurückgehen nach einem Login könnte dieses Problem verursachen.', 'saml_fail_authed' => 'Anmeldung mit :system fehlgeschlagen, System konnte keine erfolgreiche Autorisierung bereitstellen', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'Es ist keine Aktion definiert', 'social_login_bad_response' => "Fehler bei :socialAccount Login: \n:error", 'social_account_in_use' => 'Dieses :socialAccount-Konto wird bereits verwendet. Bitte melde dich mit dem :socialAccount-Konto an.', @@ -84,7 +88,7 @@ return [ 'sorry_page_not_found' => 'Entschuldigung. Die Seite, die Du angefordert hast, wurde nicht gefunden.', 'sorry_page_not_found_permission_warning' => 'Wenn du erwartet hast, dass diese Seite existiert, hast du möglicherweise nicht die Berechtigung, sie anzuzeigen.', 'image_not_found' => 'Bild nicht gefunden', - 'image_not_found_subtitle' => 'Entschuldigung. Das Bild, die Sie angefordert haben, wurde nicht gefunden.', + 'image_not_found_subtitle' => 'Entschuldigung. Das angeforderte Bild wurde nicht gefunden.', 'image_not_found_details' => 'Wenn Sie erwartet haben, dass dieses Bild existiert, könnte es gelöscht worden sein.', 'return_home' => 'Zurück zur Startseite', 'error_occurred' => 'Es ist ein Fehler aufgetreten', diff --git a/resources/lang/es/errors.php b/resources/lang/es/errors.php index 03e712526..05357421f 100644 --- a/resources/lang/es/errors.php +++ b/resources/lang/es/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'No se pudo encontrar una dirección de correo electrónico, para este usuario, en los datos proporcionados por el sistema de autenticación externo', 'saml_invalid_response_id' => 'La solicitud del sistema de autenticación externo no está reconocida por un proceso iniciado por esta aplicación. Navegar hacia atrás después de un inicio de sesión podría causar este problema.', 'saml_fail_authed' => 'El inicio de sesión con :system falló, el sistema no proporcionó una autorización correcta', + 'oidc_already_logged_in' => 'Ya tenías la sesión iniciada', + 'oidc_user_not_registered' => 'El usuario :name no está registrado y el registro automático está deshabilitado', + 'oidc_no_email_address' => 'No se pudo encontrar una dirección de correo electrónico, para este usuario, en los datos proporcionados por el sistema de autenticación externo', + 'oidc_fail_authed' => 'El inicio de sesión con :system falló, el sistema no proporcionó una autorización correcta', 'social_no_action_defined' => 'Acción no definida', 'social_login_bad_response' => "Se ha recibido un error durante el acceso con :socialAccount error: \n:error", 'social_account_in_use' => 'la cuenta :socialAccount ya se encuentra en uso, intente acceder a través de la opción :socialAccount .', diff --git a/resources/lang/es_AR/errors.php b/resources/lang/es_AR/errors.php index f96e29db1..fe12922ba 100644 --- a/resources/lang/es_AR/errors.php +++ b/resources/lang/es_AR/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'No se pudo encontrar una dirección de correo electrónico, para este usuario, en los datos proporcionados por el sistema de autenticación externo', 'saml_invalid_response_id' => 'La solicitud del sistema de autenticación externo no está reconocida por un proceso iniciado por esta aplicación. Navegar hacia atrás después de un inicio de sesión podría causar este problema.', 'saml_fail_authed' => 'El inicio de sesión con :system falló, el sistema no proporcionó una autorización correcta', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'Acción no definida', 'social_login_bad_response' => "SE recibió un Error durante el acceso con :socialAccount : \n:error", 'social_account_in_use' => 'la cuenta :socialAccount ya se encuentra en uso, intente loguearse a través de la opcón :socialAccount .', diff --git a/resources/lang/et/activities.php b/resources/lang/et/activities.php new file mode 100644 index 000000000..f36d75cdd --- /dev/null +++ b/resources/lang/et/activities.php @@ -0,0 +1,57 @@ + 'lisas lehe', + 'page_create_notification' => 'Leht on lisatud', + 'page_update' => 'muutis lehte', + 'page_update_notification' => 'Leht on muudetud', + 'page_delete' => 'kustutas lehe', + 'page_delete_notification' => 'Leht on kustutatud', + 'page_restore' => 'taastas lehe', + 'page_restore_notification' => 'Leht on taastatud', + 'page_move' => 'liigutas lehte', + + // Chapters + 'chapter_create' => 'lisas peatüki', + 'chapter_create_notification' => 'Peatükk on lisatud', + 'chapter_update' => 'muutis peatükki', + 'chapter_update_notification' => 'Peatükk on muudetud', + 'chapter_delete' => 'kustutas peatüki', + 'chapter_delete_notification' => 'Peatükk on kustutatud', + 'chapter_move' => 'liigutas peatükki', + + // Books + 'book_create' => 'lisas raamatu', + 'book_create_notification' => 'Raamat on lisatud', + 'book_update' => 'muutis raamatut', + 'book_update_notification' => 'Raamat on muudetud', + 'book_delete' => 'kustutas raamatu', + 'book_delete_notification' => 'Raamat on kustutatud', + 'book_sort' => 'sorteeris raamatut', + 'book_sort_notification' => 'Raamat on sorteeritud', + + // Bookshelves + 'bookshelf_create' => 'lisas riiuli', + 'bookshelf_create_notification' => 'Riiul on lisatud', + 'bookshelf_update' => 'muutis riiulit', + 'bookshelf_update_notification' => 'Riiul on muudetud', + 'bookshelf_delete' => 'kustutas riiuli', + 'bookshelf_delete_notification' => 'Riiul on kustutatud', + + // Favourites + 'favourite_add_notification' => '":name" lisati su lemmikute hulka', + 'favourite_remove_notification' => '":name" eemaldati su lemmikute hulgast', + + // MFA + 'mfa_setup_method_notification' => 'Mitmeastmeline autentimine seadistatud', + 'mfa_remove_method_notification' => 'Mitmeastmeline autentimine eemaldatud', + + // Other + 'commented_on' => 'kommenteeris lehte', + 'permissions_update' => 'muutis õiguseid', +]; diff --git a/resources/lang/et/auth.php b/resources/lang/et/auth.php new file mode 100644 index 000000000..6022d933b --- /dev/null +++ b/resources/lang/et/auth.php @@ -0,0 +1,112 @@ + 'Kasutajanimi ja parool ei klapi.', + 'throttle' => 'Liiga palju sisselogimiskatseid. Proovi uuesti :seconds sekundi pärast.', + + // Login & Register + 'sign_up' => 'Registreeru', + 'log_in' => 'Logi sisse', + 'log_in_with' => 'Logi sisse :socialDriver abil', + 'sign_up_with' => 'Registreeru :socialDriver abil', + 'logout' => 'Logi välja', + + 'name' => 'Nimi', + 'username' => 'Kasutajanimi', + 'email' => 'E-post', + 'password' => 'Parool', + 'password_confirm' => 'Kinnita parool', + 'password_hint' => 'Peab olema rohkem kui 7 tähemärki', + 'forgot_password' => 'Unustasid parooli?', + 'remember_me' => 'Jäta mind meelde', + 'ldap_email_hint' => 'Sisesta kasutajakonto e-posti aadress.', + 'create_account' => 'Loo konto', + 'already_have_account' => 'Kasutajakonto juba olemas?', + 'dont_have_account' => 'Sul ei ole veel kontot?', + 'social_login' => 'Social Login', + 'social_registration' => 'Social Registration', + 'social_registration_text' => 'Registreeru ja logi sisse välise teenuse kaudu.', + + 'register_thanks' => 'Aitäh, et registreerusid!', + 'register_confirm' => 'Vaata oma postkasti ja klõpsa kinnitusnupul, et rakendusele :appName ligi pääseda.', + 'registrations_disabled' => 'Registreerumine on hetkel keelatud', + 'registration_email_domain_invalid' => 'Sellel e-posti domeenil ei ole rakendusele ligipääsu', + 'register_success' => 'Aitäh, et registreerusid! Oled nüüd sisse logitud.', + + + // Password Reset + 'reset_password' => 'Lähtesta parool', + 'reset_password_send_instructions' => 'Siseta oma e-posti aadress ning sulle saadetakse link parooli lähtestamiseks.', + 'reset_password_send_button' => 'Saada lähtestamise link', + 'reset_password_sent' => 'Kui süsteemis leidub e-posti aadress :email, saadetakse sinna link parooli lähtestamiseks.', + 'reset_password_success' => 'Sinu parool on edukalt lähtestatud.', + 'email_reset_subject' => 'Lähtesta oma :appName parool', + 'email_reset_text' => 'Said selle e-kirja, sest meile laekus soov sinu konto parooli lähtestamiseks.', + 'email_reset_not_requested' => 'Kui sa ei soovinud parooli lähtestada, ei pea sa rohkem midagi tegema.', + + + // Email Confirmation + 'email_confirm_subject' => 'Kinnita oma :appName konto e-posti aadress', + 'email_confirm_greeting' => 'Aitäh, et liitusid rakendusega :appName!', + 'email_confirm_text' => 'Palun kinnita oma e-posti aadress, klõpsates alloleval nupul:', + 'email_confirm_action' => 'Kinnita e-posti aadress', + 'email_confirm_send_error' => 'E-posti aadressi kinnitamine on vajalik, aga e-kirja saatmine ebaõnnestus. Võta ühendust administraatoriga.', + 'email_confirm_success' => 'Sinu e-posti aadress on kinnitatud!', + 'email_confirm_resent' => 'Kinnituskiri on saadetud, vaata oma postkasti.', + + 'email_not_confirmed' => 'E-posti aadress ei ole kinnitatud', + 'email_not_confirmed_text' => 'Sinu e-posti aadress ei ole veel kinnitatud.', + 'email_not_confirmed_click_link' => 'Klõpsa lingil e-kirjas, mis saadeti sulle pärast registreerumist.', + 'email_not_confirmed_resend' => 'Kui sa ei leia e-kirja, siis saad alloleva vormi abil selle uuesti saata.', + 'email_not_confirmed_resend_button' => 'Saada kinnituskiri uuesti', + + // User Invite + 'user_invite_email_subject' => 'Sind on kutsutud liituma rakendusega :appName!', + 'user_invite_email_greeting' => 'Sulle on loodud kasutajakonto rakenduses :appName.', + 'user_invite_email_text' => 'Vajuta allolevale nupule, et seada parool ja ligipääs saada:', + 'user_invite_email_action' => 'Sea konto parool', + 'user_invite_page_welcome' => 'Tere tulemast rakendusse :appName!', + 'user_invite_page_text' => 'Registreerumise lõpetamiseks ja ligipääsu saamiseks pead seadma parooli, millega edaspidi rakendusse sisse logid.', + 'user_invite_page_confirm_button' => 'Kinnita parool', + 'user_invite_success' => 'Parool seatud, sul on nüüd ligipääs!', + + // Multi-factor Authentication + 'mfa_setup' => 'Seadista mitmeastmeline autentimine', + 'mfa_setup_desc' => 'Seadista mitmeastmeline autentimine, et oma kasutajakonto turvalisust tõsta.', + 'mfa_setup_configured' => 'Juba seadistatud', + 'mfa_setup_reconfigure' => 'Seadista ümber', + 'mfa_setup_remove_confirmation' => 'Kas oled kindel, et soovid selle mitmeastmelise autentimise meetodi eemaldada?', + 'mfa_setup_action' => 'Seadista', + 'mfa_backup_codes_usage_limit_warning' => 'Sul on vähem kui 5 varukoodi järel. Genereeri ja hoiusta uus komplekt enne, kui nad otsa saavad, et vältida oma kasutajakontole ligipääsu kaotamist.', + 'mfa_option_totp_title' => 'Mobiilirakendus', + 'mfa_option_totp_desc' => 'Mitmeastmelise autentimise kasutamiseks on sul vaja TOTP-toega mobiilirakendust, nagu Google Authenticator, Authy või Microsoft Authenticator.', + 'mfa_option_backup_codes_title' => 'Varukoodid', + 'mfa_option_backup_codes_desc' => 'Hoiusta kindlas kohas komplekt ühekordseid varukoode, millega saad oma isikut tuvastada.', + 'mfa_gen_confirm_and_enable' => 'Kinnita ja lülita sisse', + 'mfa_gen_backup_codes_title' => 'Varukoodide seadistamine', + 'mfa_gen_backup_codes_desc' => 'Hoiusta allolevad koodid turvalises kohas. Saad neid kasutada sisselogimisel sekundaarse autentimismeetodina.', + 'mfa_gen_backup_codes_download' => 'Laadi koodid alla', + 'mfa_gen_backup_codes_usage_warning' => 'Igat koodi saab ainult ühe korra kasutada', + 'mfa_gen_totp_title' => 'Mobiilirakenduse seadistamine', + 'mfa_gen_totp_desc' => 'Mitmeastmelise autentimise kasutamiseks on sul vaja TOTP-toega mobiilirakendust, nagu Google Authenticator, Authy või Microsoft Authenticator.', + 'mfa_gen_totp_scan' => 'Alustamiseks skaneeri allolevat QR-koodi oma eelistatud rakendusega.', + 'mfa_gen_totp_verify_setup' => 'Kontrolli seadistust', + 'mfa_gen_totp_verify_setup_desc' => 'Veendu, et kõik toimib korrektselt, sisestades oma rakenduse genereeritud koodi allolevasse tekstikasti:', + 'mfa_gen_totp_provide_code_here' => 'Sisesta rakenduse genereeritud kood siia', + 'mfa_verify_access' => 'Kinnita ligipääs', + 'mfa_verify_access_desc' => 'Sinu konto nõuab ligipääsuks täiendava kinnitusmeetodi abil oma isiku tuvastamist. Jätkamiseks vali üks järgnevatest meetoditest.', + 'mfa_verify_no_methods' => 'Ühtegi meetodit pole seadistatud', + 'mfa_verify_no_methods_desc' => 'Sinu kontole pole lisatud ühtegi mitmeastmelise autentimise meetodit. Ligipääsu saamiseks pead seadistama vähemalt ühe meetodi.', + 'mfa_verify_use_totp' => 'Tuvasta mobiilirakendusega', + 'mfa_verify_use_backup_codes' => 'Tuvasta varukoodiga', + 'mfa_verify_backup_code' => 'Varukood', + 'mfa_verify_backup_code_desc' => 'Sisesta allpool üks oma järelejäänud varukoodidest:', + 'mfa_verify_backup_code_enter_here' => 'Sisesta varukood siia', + 'mfa_verify_totp_desc' => 'Sisesta oma mobiilirakenduse poolt genereeritud kood allpool:', + 'mfa_setup_login_notification' => 'Mitmeastmeline autentimine seadistatud. Logi nüüd uuesti sisse, kasutades seadistatud meetodit.', +]; \ No newline at end of file diff --git a/resources/lang/et/common.php b/resources/lang/et/common.php new file mode 100644 index 000000000..d3349f5fb --- /dev/null +++ b/resources/lang/et/common.php @@ -0,0 +1,95 @@ + 'Tühista', + 'confirm' => 'Kinnita', + 'back' => 'Tagasi', + 'save' => 'Salvesta', + 'continue' => 'Jätka', + 'select' => 'Vali', + 'toggle_all' => 'Vaheta kõik', + 'more' => 'Rohkem', + + // Form Labels + 'name' => 'Pealkiri', + 'description' => 'Kirjeldus', + 'role' => 'Roll', + 'cover_image' => 'Kaanepilt', + 'cover_image_description' => 'See pilt peaks olema umbes 440x250 pikslit.', + + // Actions + 'actions' => 'Tegevused', + 'view' => 'Vaata', + 'view_all' => 'Vaata kõiki', + 'create' => 'Lisa', + 'update' => 'Uuenda', + 'edit' => 'Muuda', + 'sort' => 'Sorteeri', + 'move' => 'Liiguta', + 'copy' => 'Kopeeri', + 'reply' => 'Vasta', + 'delete' => 'Kustuta', + 'delete_confirm' => 'Kinnita kustutamine', + 'search' => 'Otsi', + 'search_clear' => 'Tühjenda otsing', + 'reset' => 'Taasta', + 'remove' => 'Eemalda', + 'add' => 'Lisa', + 'configure' => 'Seadista', + 'fullscreen' => 'Täisekraan', + 'favourite' => 'Lemmik', + 'unfavourite' => 'Eemalda lemmik', + 'next' => 'Järgmine', + 'previous' => 'Eelmine', + + // Sort Options + 'sort_options' => 'Sorteerimise valikud', + 'sort_direction_toggle' => 'Sorteerimise suund', + 'sort_ascending' => 'Sorteeri kasvavalt', + 'sort_descending' => 'Sorteeri kahanevalt', + 'sort_name' => 'Pealkiri', + 'sort_default' => 'Vaikimisi', + 'sort_created_at' => 'Loomise aeg', + 'sort_updated_at' => 'Muutmise aeg', + + // Misc + 'deleted_user' => 'Kustutatud kasutaja', + 'no_activity' => 'Pole tegevusi, mida näidata', + 'no_items' => 'Ühtegi elementi pole', + 'back_to_top' => 'Tagasi üles', + 'skip_to_main_content' => 'Otse põhisisu juurde', + 'toggle_details' => 'Näita detaile', + 'toggle_thumbnails' => 'Näita eelvaateid', + 'details' => 'Detailid', + 'grid_view' => 'Tabelivaade', + 'list_view' => 'Loendivaade', + 'default' => 'Vaikimisi', + 'breadcrumb' => 'Jäljerida', + + // Header + 'header_menu_expand' => 'Laienda päisemenüü', + 'profile_menu' => 'Profiilimenüü', + 'view_profile' => 'Vaata profiili', + 'edit_profile' => 'Muuda profiili', + 'dark_mode' => 'Tume režiim', + 'light_mode' => 'Hele režiim', + + // Layout tabs + 'tab_info' => 'Info', + 'tab_info_label' => 'Tab: Show Secondary Information', + 'tab_content' => 'Sisu', + 'tab_content_label' => 'Tab: Show Primary Content', + + // Email Content + 'email_action_help' => 'Kui sul on probleeme ":actionText" nupu vajutamisega, kopeeri allolev URL oma veebilehitsejasse:', + 'email_rights' => 'Kõik õigused kaitstud', + + // Footer Link Options + // Not directly used but available for convenience to users. + 'privacy_policy' => 'Privaatsus', + 'terms_of_service' => 'Kasutustingimused', +]; diff --git a/resources/lang/et/components.php b/resources/lang/et/components.php new file mode 100644 index 000000000..aeb9c3f30 --- /dev/null +++ b/resources/lang/et/components.php @@ -0,0 +1,34 @@ + 'Pildifaili valik', + 'image_all' => 'Kõik', + 'image_all_title' => 'Vaata kõiki pildifaile', + 'image_book_title' => 'Vaata sellesse raamatusse laaditud pildifaile', + 'image_page_title' => 'Vaata sellele lehele laaditud pildifaile', + 'image_search_hint' => 'Otsi pildifaili nime järgi', + 'image_uploaded' => 'Üles laaditud :uploadedDate', + 'image_load_more' => 'Lae rohkem', + 'image_image_name' => 'Pildifaili nimi', + 'image_delete_used' => 'Seda pildifaili kasutavad järgmised lehed.', + 'image_delete_confirm_text' => 'Kas oled kindel, et soovid selle pildifaili kustutada?', + 'image_select_image' => 'Vali pildifail', + 'image_dropzone' => 'Üleslaadimiseks lohista pildid või klõpsa siin', + 'images_deleted' => 'Pildifailid kustutatud', + 'image_preview' => 'Pildi eelvaade', + 'image_upload_success' => 'Pildifail üles laaditud', + 'image_update_success' => 'Pildifaili andmed muudetud', + 'image_delete_success' => 'Pildifail kustutatud', + 'image_upload_remove' => 'Eemalda', + + // Code Editor + 'code_editor' => 'Muuda koodi', + 'code_language' => 'Koodi keel', + 'code_content' => 'Koodi sisu', + 'code_session_history' => 'Sessiooni ajalugu', + 'code_save' => 'Salvesta kood', +]; diff --git a/resources/lang/et/entities.php b/resources/lang/et/entities.php new file mode 100644 index 000000000..0fabc32df --- /dev/null +++ b/resources/lang/et/entities.php @@ -0,0 +1,325 @@ + 'Hiljuti lisatud', + 'recently_created_pages' => 'Hiljuti lisatud lehed', + 'recently_updated_pages' => 'Hiljuti muudetud lehed', + 'recently_created_chapters' => 'Hiljuti lisatud peatükid', + 'recently_created_books' => 'Hiljuti lisatud raamatud', + 'recently_created_shelves' => 'Hiljuti lisatud riiulid', + 'recently_update' => 'Hiljuti muudetud', + 'recently_viewed' => 'Viimati vaadatud', + 'recent_activity' => 'Hiljutised tegevused', + 'create_now' => 'Create one now', + 'revisions' => 'Redaktsioonid', + 'meta_revision' => 'Redaktsioon #:revisionCount', + 'meta_created' => 'Lisatud :timeLength', + 'meta_created_name' => 'Lisatud :timeLength kasutaja :user poolt', + 'meta_updated' => 'Muudetud :timeLength', + 'meta_updated_name' => 'Muudetud :timeLength kasutaja :user poolt', + 'meta_owned_name' => 'Owned by :user', + 'entity_select' => 'Entity Select', + 'images' => 'Pildid', + 'my_recent_drafts' => 'Minu hiljutised mustandid', + 'my_recently_viewed' => 'Minu viimati vaadatud', + 'my_most_viewed_favourites' => 'Minu enim vaadatud lemmikud', + 'my_favourites' => 'Minu lemmikud', + 'no_pages_viewed' => 'Sa pole veel ühtegi lehte vaadanud', + 'no_pages_recently_created' => 'Hiljuti pole ühtegi lehte lisatud', + 'no_pages_recently_updated' => 'Hiljuti pole ühtegi lehte muudetud', + 'export' => 'Ekspordi', + 'export_html' => 'Contained Web File', + 'export_pdf' => 'PDF fail', + 'export_text' => 'Tekstifail', + 'export_md' => 'Markdown fail', + + // Permissions and restrictions + 'permissions' => 'Õigused', + 'permissions_intro' => 'Once enabled, These permissions will take priority over any set role permissions.', + 'permissions_enable' => 'Enable Custom Permissions', + 'permissions_save' => 'Salvesta õigused', + 'permissions_owner' => 'Omanik', + + // Search + 'search_results' => 'Otsingutulemused', + 'search_total_results_found' => 'leitud :count vaste|leitud :count vastet', + 'search_clear' => 'Tühjenda otsing', + 'search_no_pages' => 'Otsing ei leidnud ühtegi lehte', + 'search_for_term' => 'Search for :term', + 'search_more' => 'Rohkem tulemusi', + 'search_advanced' => 'Täpsem otsing', + 'search_terms' => 'Otsinguterminid', + 'search_content_type' => 'Sisu tüüp', + 'search_exact_matches' => 'Täpsed vasted', + 'search_tags' => 'Sildi otsing', + 'search_options' => 'Valikud', + 'search_viewed_by_me' => 'Minu vaadatud', + 'search_not_viewed_by_me' => 'Minu vaatamata', + 'search_permissions_set' => 'Õigused seatud', + 'search_created_by_me' => 'Minu lisatud', + 'search_updated_by_me' => 'Minu muudetud', + 'search_owned_by_me' => 'Minu omad', + 'search_date_options' => 'Kuupäeva valikud', + 'search_updated_before' => 'Muudetud enne kui', + 'search_updated_after' => 'Muudetud hiljem kui', + 'search_created_before' => 'Lisatud enne kui', + 'search_created_after' => 'Lisatud hiljem kui', + 'search_set_date' => 'Vali kuupäev', + 'search_update' => 'Värskenda otsingutulemusi', + + // Shelves + 'shelf' => 'Riiul', + 'shelves' => 'Riiulid', + 'x_shelves' => ':count riiul|:count riiulit', + 'shelves_long' => 'Raamaturiiulid', + 'shelves_empty' => 'Ühtegi riiulit pole lisatud', + 'shelves_create' => 'Lisa uus riiul', + 'shelves_popular' => 'Populaarsed riiulid', + 'shelves_new' => 'Uued riiulid', + 'shelves_new_action' => 'Uus riiul', + 'shelves_popular_empty' => 'Siia tulevad kõige populaarsemad riiulid.', + 'shelves_new_empty' => 'Siia tulevad hiljuti lisatud riiulid.', + 'shelves_save' => 'Salvesta riiul', + 'shelves_books' => 'Raamatud sellel riiulil', + 'shelves_add_books' => 'Lisa sellele riiulile raamatuid', + 'shelves_drag_books' => 'Lohista raamatuid siia, et neid sellele riiulile lisada', + 'shelves_empty_contents' => 'Sellel riiulil ei ole ühtegi raamatut', + 'shelves_edit_and_assign' => 'Muuda riiulit, et siia raamatuid lisada', + 'shelves_edit_named' => 'Muuda riiulit :name', + 'shelves_edit' => 'Muuda riiulit', + 'shelves_delete' => 'Kustuta riiul', + 'shelves_delete_named' => 'Kustuta riiul :name', + 'shelves_delete_explain' => "See kustutab riiuli nimega ':name'. Raamatuid, mis on sellel riiulil, ei kustutata.", + 'shelves_delete_confirmation' => 'Kas oled kindel, et soovid selle raamaturiiuli kustutada?', + 'shelves_permissions' => 'Riiuli õigused', + 'shelves_permissions_updated' => 'Riiuli õigused muudetud', + 'shelves_permissions_active' => 'Riiuli õigused on aktiivsed', + 'shelves_permissions_cascade_warning' => 'Raamaturiiuli õigused ei rakendu automaatselt sellel olevatele raamatutele, kuna raamat võib olla korraga mitmel riiulil. Alloleva valiku abil saab aga riiuli õigused kopeerida raamatutele.', + 'shelves_copy_permissions_to_books' => 'Kopeeri õigused raamatutele', + 'shelves_copy_permissions' => 'Kopeeri õigused', + 'shelves_copy_permissions_explain' => 'See rakendab raamaturiiuli praegused õigused kõigile sellel olevatele raamatutele. Enne jätkamist veendu, et raamaturiiuli õiguste muudatused oleks salvestatud.', + 'shelves_copy_permission_success' => 'Raamaturiiuli õigused kopeeritud :count raamatule', + + // Books + 'book' => 'Raamat', + 'books' => 'Raamatud', + 'x_books' => ':count raamat|:count raamatut', + 'books_empty' => 'Ühtegi raamatut pole lisatud', + 'books_popular' => 'Populaarsed raamatud', + 'books_recent' => 'Hiljutised raamatud', + 'books_new' => 'Uued raamatud', + 'books_new_action' => 'Uus raamat', + 'books_popular_empty' => 'Siia tulevad kõige populaarsemad raamatud.', + 'books_new_empty' => 'Siia tulevad hiljuti lisatud raamatud.', + 'books_create' => 'Lisa uus raamat', + 'books_delete' => 'Kustuta raamat', + 'books_delete_named' => 'Kustuta raamat :bookName', + 'books_delete_explain' => 'See kustutab raamatu nimega \':bookName\'. Kõik lehed ja peatükid kustutatakse samuti.', + 'books_delete_confirmation' => 'Kas oled kindel, et soovid selle raamatu kustutada?', + 'books_edit' => 'Muuda raamatut', + 'books_edit_named' => 'Muuda raamatut :bookName', + 'books_form_book_name' => 'Raamatu pealkiri', + 'books_save' => 'Salvesta raamat', + 'books_permissions' => 'Raamatu õigused', + 'books_permissions_updated' => 'Raamatu õigused muudetud', + 'books_empty_contents' => 'Ühtegi lehte ega peatükki pole lisatud.', + 'books_empty_create_page' => 'Lisa uus leht', + 'books_empty_sort_current_book' => 'Sorteeri raamat', + 'books_empty_add_chapter' => 'Lisa uus peatükk', + 'books_permissions_active' => 'Raamatu õigused on aktiivsed', + 'books_search_this' => 'Otsi sellest raamatust', + 'books_navigation' => 'Raamatu sisukord', + 'books_sort' => 'Sorteeri raamatu sisu', + 'books_sort_named' => 'Sorteeri raamat :bookName', + 'books_sort_name' => 'Sorteeri nime järgi', + 'books_sort_created' => 'Sorteeri loomisaja järgi', + 'books_sort_updated' => 'Sorteeri muutmisaja järgi', + 'books_sort_chapters_first' => 'Peatükid eespool', + 'books_sort_chapters_last' => 'Peatükid tagapool', + 'books_sort_show_other' => 'Näita teisi raamatuid', + 'books_sort_save' => 'Salvesta uus järjekord', + + // Chapters + 'chapter' => 'Peatükk', + 'chapters' => 'Peatükid', + 'x_chapters' => ':count peatükk|:count peatükki', + 'chapters_popular' => 'Populaarsed peatükid', + 'chapters_new' => 'Uus peatükk', + 'chapters_create' => 'Lisa uus peatükk', + 'chapters_delete' => 'Kustuta peatükk', + 'chapters_delete_named' => 'Kustuta peatükk :chapterName', + 'chapters_delete_explain' => 'See kustutab peatüki nimega \':chapterName\'. Kõik lehed selles peatükis kustutatakse samuti.', + 'chapters_delete_confirm' => 'Kas oled kindel, et soovid selle peatüki kustutada?', + 'chapters_edit' => 'Muuda peatükki', + 'chapters_edit_named' => 'Muuda peatükki :chapterName', + 'chapters_save' => 'Salvesta peatükk', + 'chapters_move' => 'Liiguta peatükk', + 'chapters_move_named' => 'Liiguta peatükk :chapterName', + 'chapter_move_success' => 'Peatükk liigutatud raamatusse :bookName', + 'chapters_permissions' => 'Peatüki õigused', + 'chapters_empty' => 'Selles peatükis ei ole lehti.', + 'chapters_permissions_active' => 'Peatüki õigused on aktiivsed', + 'chapters_permissions_success' => 'Peatüki õigused muudetud', + 'chapters_search_this' => 'Otsi sellest peatükist', + + // Pages + 'page' => 'Leht', + 'pages' => 'Lehed', + 'x_pages' => ':count leht|:count lehte', + 'pages_popular' => 'Populaarsed lehed', + 'pages_new' => 'Uus leht', + 'pages_attachments' => 'Manused', + 'pages_navigation' => 'Lehe sisukord', + 'pages_delete' => 'Kustuta leht', + 'pages_delete_named' => 'Kustuta leht :pageName', + 'pages_delete_draft_named' => 'Kustuta mustand :pageName', + 'pages_delete_draft' => 'Kustuta mustand', + 'pages_delete_success' => 'Leht kustutatud', + 'pages_delete_draft_success' => 'Mustand kustutatud', + 'pages_delete_confirm' => 'Kas oled kindel, et soovid selle lehe kustutada?', + 'pages_delete_draft_confirm' => 'Kas oled kindel, et soovid selle mustandi kustutada?', + 'pages_editing_named' => 'Lehe :pageName muutmine', + 'pages_edit_draft_options' => 'Mustandi valikud', + 'pages_edit_save_draft' => 'Salvesta mustand', + 'pages_edit_draft' => 'Muuda mustandit', + 'pages_editing_draft' => 'Mustandi muutmine', + 'pages_editing_page' => 'Lehe muutmine', + 'pages_edit_draft_save_at' => 'Mustand salvestatud ', + 'pages_edit_delete_draft' => 'Kustuta mustand', + 'pages_edit_discard_draft' => 'Loobu mustandist', + 'pages_edit_set_changelog' => 'Muudatuste logi', + 'pages_edit_enter_changelog_desc' => 'Sisesta tehtud muudatuste lühikirjeldus', + 'pages_edit_enter_changelog' => 'Salvesta muudatuste logi', + 'pages_save' => 'Salvesta leht', + 'pages_title' => 'Lehe pealkiri', + 'pages_name' => 'Lehe nimetus', + 'pages_md_editor' => 'Redaktor', + 'pages_md_preview' => 'Eelvaade', + 'pages_md_insert_image' => 'Lisa pilt', + 'pages_md_insert_link' => 'Lisa viide', + 'pages_md_insert_drawing' => 'Lisa joonis', + 'pages_not_in_chapter' => 'Leht ei kuulu peatüki alla', + 'pages_move' => 'Liiguta leht', + 'pages_move_success' => 'Leht liigutatud ":parentName" alla', + 'pages_copy' => 'Kopeeri leht', + 'pages_copy_desination' => 'Copy Destination', + 'pages_copy_success' => 'Leht on kopeeritud', + 'pages_permissions' => 'Lehe õigused', + 'pages_permissions_success' => 'Lehe õigused muudetud', + 'pages_revision' => 'Redaktsioon', + 'pages_revisions' => 'Lehe redaktsioonid', + 'pages_revisions_named' => 'Lehe :pageName redaktsioonid', + 'pages_revision_named' => 'Lehe :pageName redaktsioon', + 'pages_revision_restored_from' => 'Taastatud redaktsioonist #:id; :summary', + 'pages_revisions_created_by' => 'Autor', + 'pages_revisions_date' => 'Redaktsiooni aeg', + 'pages_revisions_number' => '#', + 'pages_revisions_numbered' => 'Redaktsioon #:id', + 'pages_revisions_numbered_changes' => 'Redaktsiooni #:id muudatused', + 'pages_revisions_changelog' => 'Muudatuste ajalugu', + 'pages_revisions_changes' => 'Muudatused', + 'pages_revisions_current' => 'Praegune versioon', + 'pages_revisions_preview' => 'Eelvaade', + 'pages_revisions_restore' => 'Taasta', + 'pages_revisions_none' => 'Sellel lehel ei ole redaktsioone', + 'pages_copy_link' => 'Kopeeri link', + 'pages_edit_content_link' => 'Muuda sisu', + 'pages_permissions_active' => 'Lehe õigused on aktiivsed', + 'pages_initial_revision' => 'Esimene redaktsioon', + 'pages_initial_name' => 'Uus leht', + 'pages_editing_draft_notification' => 'Sa muudad mustandit, mis salvestati viimati :timeDiff.', + 'pages_draft_edited_notification' => 'This page has been updated by since that time. It is recommended that you discard this draft.', + 'pages_draft_page_changed_since_creation' => 'Seda lehte on pärast mustandi loomist muudetud. Soovitame mustandi ära visata või olla hoolikas, et mitte lehe muudatusi üle kirjutada.', + 'pages_draft_edit_active' => [ + 'start_a' => ':count kasutajat on selle lehe muutmist alustanud', + 'start_b' => ':userName alustas selle lehe muutmist', + 'time_a' => 'lehe viimasest muutmisest alates', + 'time_b' => 'viimase :minCount minuti jooksul', + 'message' => ':start :time. Take care not to overwrite each other\'s updates!', + ], + 'pages_draft_discarded' => 'Draft discarded, The editor has been updated with the current page content', + 'pages_specific' => 'Specific Page', + 'pages_is_template' => 'Page Template', + + // Editor Sidebar + 'page_tags' => 'Lehe sildid', + 'chapter_tags' => 'Peatüki sildid', + 'book_tags' => 'Raamatu sildid', + 'shelf_tags' => 'Riiuli sildid', + 'tag' => 'Silt', + 'tags' => 'Sildid', + 'tag_name' => 'Sildi nimi', + 'tag_value' => 'Sildi väärtus (valikuline)', + 'tags_explain' => "Lisa silte, et sisu paremini organiseerida.\nVeel täpsemaks organiseerimiseks saad siltidele väärtuseid määrata.", + 'tags_add' => 'Lisa veel üks silt', + 'tags_remove' => 'Eemalda see silt', + 'attachments' => 'Manused', + 'attachments_explain' => 'Laadi üles faile või lisa linke, mida lehel kuvada. Need on nähtavad külgmenüüs.', + 'attachments_explain_instant_save' => 'Muudatused salvestatakse koheselt.', + 'attachments_items' => 'Lisatud objektid', + 'attachments_upload' => 'Laadi fail üles', + 'attachments_link' => 'Lisa link', + 'attachments_set_link' => 'Set Link', + 'attachments_delete' => 'Kas oled kindel, et soovid selle manuse kustutada?', + 'attachments_dropzone' => 'Manuse lisamiseks lohista failid või klõpsa siin', + 'attachments_no_files' => 'Üleslaaditud faile ei ole', + 'attachments_explain_link' => 'Faili üleslaadimise asemel saad lingi lisada. See võib viidata teisele lehele või failile kuskil pilves.', + 'attachments_link_name' => 'Lingi nimi', + 'attachment_link' => 'Manuse link', + 'attachments_link_url' => 'Link failile', + 'attachments_link_url_hint' => 'Lehekülje või faili URL', + 'attach' => 'Lisa', + 'attachments_insert_link' => 'Add Attachment Link to Page', + 'attachments_edit_file' => 'Muuda faili', + 'attachments_edit_file_name' => 'Faili nimi', + 'attachments_edit_drop_upload' => 'Drop files or click here to upload and overwrite', + 'attachments_order_updated' => 'Manuste järjekord muudetud', + 'attachments_updated_success' => 'Manuse andmed muudetud', + 'attachments_deleted' => 'Manus kustutatud', + 'attachments_file_uploaded' => 'Fail on üles laaditud', + 'attachments_file_updated' => 'Fail on muudetud', + 'attachments_link_attached' => 'Link on lehele lisatud', + 'templates' => 'Mallid', + 'templates_set_as_template' => 'Leht on mall', + 'templates_explain_set_as_template' => 'Sa saad määrata selle lehe malliks, nii et selle sisu saab kasutada uute lehtede lisamisel. Kui teistel kasutajatel on selle lehe vaatamiseks õigus, saavad ka nemad seda mallina kasutada.', + 'templates_replace_content' => 'Asenda lehe sisu', + 'templates_append_content' => 'Lisa lehe sisu järele', + 'templates_prepend_content' => 'Lisa lehe sisu ette', + + // Profile View + 'profile_user_for_x' => 'User for :time', + 'profile_created_content' => 'Lisatud sisu', + 'profile_not_created_pages' => ':userName ei ole ühtegi lehte lisanud', + 'profile_not_created_chapters' => ':userName ei ole ühtegi peatükki lisanud', + 'profile_not_created_books' => ':userName ei ole ühtegi raamatut lisanud', + 'profile_not_created_shelves' => ':userName ei ole ühtegi riiulit lisanud', + + // Comments + 'comment' => 'Kommentaar', + 'comments' => 'Kommentaarid', + 'comment_add' => 'Lisa kommentaar', + 'comment_placeholder' => 'Jäta siia kommentaar', + 'comment_count' => '{0} Kommentaare pole|{1} 1 kommentaar|[2,*] :count kommentaari', + 'comment_save' => 'Salvesta kommentaar', + 'comment_saving' => 'Kommentaari salvestamine...', + 'comment_deleting' => 'Kommentaari kustutamine...', + 'comment_new' => 'Uus kommentaar', + 'comment_created' => 'kommenteeris :createDiff', + 'comment_updated' => 'Muudetud :updateDiff :username poolt', + 'comment_deleted_success' => 'Kommentaar kustutatud', + 'comment_created_success' => 'Kommentaar lisatud', + 'comment_updated_success' => 'Kommentaar muudetud', + 'comment_delete_confirm' => 'Kas oled kindel, et soovid selle kommentaari kustutada?', + 'comment_in_reply_to' => 'Vastus kommentaarile :commentId', + + // Revision + 'revision_delete_confirm' => 'Kas oled kindel, et soovid selle redaktsiooni kustutada?', + 'revision_restore_confirm' => 'Kas oled kindel, et soovid selle redaktsiooni taastada? Lehe praegune sisu asendatakse.', + 'revision_delete_success' => 'Redaktsioon kustutatud', + 'revision_cannot_delete_latest' => 'Kõige viimast redaktsiooni ei saa kustutada.' +]; diff --git a/resources/lang/et/errors.php b/resources/lang/et/errors.php new file mode 100644 index 000000000..b61dbb16c --- /dev/null +++ b/resources/lang/et/errors.php @@ -0,0 +1,109 @@ + 'Sul puudub õigus selle lehe vaatamiseks.', + 'permissionJson' => 'Sul puudub õigus selle tegevuse teostamiseks.', + + // Auth + 'error_user_exists_different_creds' => 'See e-posti aadress on juba seotud teise kasutajaga.', + 'email_already_confirmed' => 'E-posti aadress on juba kinnitatud. Proovi sisse logida.', + 'email_confirmation_invalid' => 'Kinnituslink ei ole kehtiv või on seda juba kasutatud. Proovi uuesti registreeruda.', + 'email_confirmation_expired' => 'Kinnituslink on aegunud. Sulle saadeti aadressi kinnitamiseks uus e-kiri.', + 'email_confirmation_awaiting' => 'Selle kasutajakonto e-posti aadress vajab kinnitamist', + 'ldap_fail_anonymous' => 'LDAP anonüümne ligipääs ebaõnnestus', + 'ldap_fail_authed' => 'LDAP ligipääs antud nime ja parooliga ebaõnnestus', + 'ldap_extension_not_installed' => 'PHP LDAP laiendus ei ole paigaldatud', + 'ldap_cannot_connect' => 'Ühendus LDAP serveriga ebaõnnestus', + 'saml_already_logged_in' => 'Juba sisse logitud', + 'saml_user_not_registered' => 'Kasutaja :name ei ole registreeritud ning automaatne registreerimine on keelatud', + 'saml_no_email_address' => 'Selle kasutaja e-posti aadressi ei õnnestunud välisest autentimissüsteemist leida', + 'saml_invalid_response_id' => 'Välisest autentimissüsteemist tulnud päringut ei algatatud sellest rakendusest. Seda viga võib põhjustada pärast sisselogimist tagasi liikumine.', + 'saml_fail_authed' => 'Sisenemine :system kaudu ebaõnnestus, süsteem ei andnud volitust', + 'oidc_already_logged_in' => 'Juba sisse logitud', + 'oidc_user_not_registered' => 'Kasutaja :name ei ole registreeritud ning automaatne registreerimine on keelatud', + 'oidc_no_email_address' => 'Selle kasutaja e-posti aadressi ei õnnestunud välisest autentimissüsteemist leida', + 'oidc_fail_authed' => 'Sisenemine :system kaudu ebaõnnestus, süsteem ei andnud volitust', + 'social_no_action_defined' => 'Tegevus on defineerimata', + 'social_login_bad_response' => ":socialAccount kaudu sisselogimisel tekkis viga: \n:error", + 'social_account_in_use' => 'See :socialAccount konto on juba kasutusel, proovi :socialAccount kaudu sisse logida.', + 'social_account_email_in_use' => 'E-posti aadress :email on juba kasutusel. Kui sul on juba kasutajakonto, saad oma :socialAccount konto siduda profiili seadetes.', + 'social_account_existing' => 'See :socialAccount konto on juba seotud su profiiliga.', + 'social_account_already_used_existing' => 'See :socialAccount konto on juba seotud teise kasutajaga.', + 'social_account_not_used' => 'See :socialAccount konto ei ole seotud ühegi kasutajaga. Seosta see oma profiili seadetes. ', + 'social_account_register_instructions' => 'Kui sul pole veel kasutajakontot, saad selle registreerida :socialAccount kaudu.', + 'social_driver_not_found' => 'Sotsiaalmeedia kontode draiverit ei leitud', + 'social_driver_not_configured' => 'Sinu :socialAccount konto seaded ei ole korrektsed.', + 'invite_token_expired' => 'Link on aegunud. Võid selle asemel proovida oma konto parooli lähtestada.', + + // System + 'path_not_writable' => 'Faili asukohaga :filePath ei õnnestunud üles laadida. Veendu, et serveril on kirjutusõigused.', + 'cannot_get_image_from_url' => 'Ei suutnud laadida pilti aadressilt :url', + 'cannot_create_thumbs' => 'Server ei saa piltide eelvaateid tekitada. Veendu, et PHP GD laiendus on paigaldatud.', + 'server_upload_limit' => 'Server ei luba nii suurte failide üleslaadimist. Proovi väiksema failiga.', + 'uploaded' => 'Server ei luba nii suurte failide üleslaadimist. Proovi väiksema failiga.', + 'image_upload_error' => 'Pildi üleslaadimisel tekkis viga', + 'image_upload_type_error' => 'Pildifaili tüüp ei ole korrektne', + 'file_upload_timeout' => 'Faili üleslaadimine aegus.', + + // Attachments + 'attachment_not_found' => 'Manust ei leitud', + + // Pages + 'page_draft_autosave_fail' => 'Mustandi salvestamine ebaõnnestus. Kontrolli oma internetiühendust', + 'page_custom_home_deletion' => 'Ei saa kustutada lehte, mis on määratud avaleheks', + + // Entities + 'entity_not_found' => 'Objekti ei leitud', + 'bookshelf_not_found' => 'Riiulit ei leitud', + 'book_not_found' => 'Raamatut ei leitud', + 'page_not_found' => 'Lehte ei leitud', + 'chapter_not_found' => 'Peatükki ei leitud', + 'selected_book_not_found' => 'Valitud raamatut ei leitud', + 'selected_book_chapter_not_found' => 'Valitud raamatut või peatükki ei leitud', + 'guests_cannot_save_drafts' => 'Külalised ei saa mustandeid salvestada', + + // Users + 'users_cannot_delete_only_admin' => 'Ainsat administraatorit ei saa kustutada', + 'users_cannot_delete_guest' => 'Külaliskasutajat ei saa kustutada', + + // Roles + 'role_cannot_be_edited' => 'Seda rolli ei saa muuta', + 'role_system_cannot_be_deleted' => 'See roll on süsteemne ja seda ei saa kustutada', + 'role_registration_default_cannot_delete' => 'Seda rolli ei saa kustutada, kuna see on seatud uute kasutajate vaikimisi rolliks', + 'role_cannot_remove_only_admin' => 'See kasutaja on ainus, kellel on administraatori roll. Enne kustutamist lisa administraatori roll mõnele teisele kasutajale.', + + // Comments + 'comment_list' => 'Kommentaaride pärimisel tekkis viga.', + 'cannot_add_comment_to_draft' => 'Mustandile ei saa kommentaare lisada.', + 'comment_add' => 'Kommentaari lisamisel / muutmisel tekkis viga.', + 'comment_delete' => 'Kommentaari kustutamisel tekkis viga.', + 'empty_comment' => 'Tühja kommentaari ei saa lisada.', + + // Error pages + '404_page_not_found' => 'Lehekülge ei leitud', + 'sorry_page_not_found' => 'Vabandust, soovitud lehekülge ei leitud.', + 'sorry_page_not_found_permission_warning' => 'Kui see lehekülg peaks kindlalt olemas olema, ei pruugi sul olla õigust selle vaatamiseks.', + 'image_not_found' => 'Pildifaili ei leitud', + 'image_not_found_subtitle' => 'Vabandust, soovitud pildifaili ei leitud.', + 'image_not_found_details' => 'Kui sa eeldasid, et see pildifail on olemas, võib see olla kustutatud.', + 'return_home' => 'Tagasi avalehele', + 'error_occurred' => 'Tekkis viga', + 'app_down' => ':appName on hetkel maas', + 'back_soon' => 'See on varsti tagasi.', + + // API errors + 'api_no_authorization_found' => 'Päringust ei leitud volitustunnust', + 'api_bad_authorization_format' => 'Päringust leiti volitustunnus, aga see ei olnud korrektses formaadis', + 'api_user_token_not_found' => 'Volitustunnusele vastavat API tunnust ei leitud', + 'api_incorrect_token_secret' => 'API tunnusele lisatud salajane võti ei ole korrektne', + 'api_user_no_api_permission' => 'Selle API tunnuse omanikul ei ole õigust API päringuid teha', + 'api_user_token_expired' => 'Volitustunnus on aegunud', + + // Settings & Maintenance + 'maintenance_test_email_failure' => 'Test e-kirja saatmisel tekkis viga:', + +]; diff --git a/resources/lang/et/pagination.php b/resources/lang/et/pagination.php new file mode 100644 index 000000000..c32b7f379 --- /dev/null +++ b/resources/lang/et/pagination.php @@ -0,0 +1,12 @@ + '« Eelmine', + 'next' => 'Järgmine »', + +]; diff --git a/resources/lang/et/passwords.php b/resources/lang/et/passwords.php new file mode 100644 index 000000000..8559d60b2 --- /dev/null +++ b/resources/lang/et/passwords.php @@ -0,0 +1,15 @@ + 'Paroolides peab olema vähemalt kaheksa tähemärki ja nad peavad omavahel ühtima.', + 'user' => "Sellise e-posti aadressiga kasutajat ei leitud.", + 'token' => 'Parooli lähtestamise link ei kehti selle e-posti aadressiga.', + 'sent' => 'Parooli lähtestamise link saadeti e-postiga!', + 'reset' => 'Parool on lähtestatud!', + +]; diff --git a/resources/lang/et/settings.php b/resources/lang/et/settings.php new file mode 100644 index 000000000..08436ca8b --- /dev/null +++ b/resources/lang/et/settings.php @@ -0,0 +1,277 @@ + 'Seaded', + 'settings_save' => 'Salvesta seaded', + 'settings_save_success' => 'Seaded salvestatud', + + // App Settings + 'app_customization' => 'Kohandamine', + 'app_features_security' => 'Funktsioonid ja turvalisus', + 'app_name' => 'Rakenduse nimi', + 'app_name_desc' => 'Seda nime näidatakse päises ja kõigis süsteemsetes e-kirjades.', + 'app_name_header' => 'Näita nime päises', + 'app_public_access' => 'Avalik ligipääs', + 'app_public_access_desc' => 'Selle sisselülitamine võimaldab külalistel ilma sisselogimata ligipääsu su BookStack\'i sisule.', + 'app_public_access_desc_guest' => 'Sisselogimata kasutajate ligipääsu saab seadistada "Külaline" kasutaja kaudu.', + 'app_public_access_toggle' => 'Luba avalik ligipääs', + 'app_public_viewing' => 'Luba avalik ligipääs?', + 'app_secure_images' => 'Turvalisem piltide üleslaadimine', + 'app_secure_images_toggle' => 'Lülita sisse turvalisem piltide üleslaadimine', + 'app_secure_images_desc' => 'Jõudluse kaalutlustel on kõik pildifailid avalikult kättesaadavad. See valik lisab pildifailide URL-ide ette juhugenereeritud, raskesti arvatava stringi. Ligipääsu piiramiseks veendu, et kataloogide indekseerimine ei oleks lubatud.', + 'app_editor' => 'Redaktor', + 'app_editor_desc' => 'Vali, millist redaktorit kasutajad lehtede muutmiseks kasutavad.', + 'app_custom_html' => 'Kohandatud HTML päise sisu', + 'app_custom_html_desc' => 'Siia lisatud sisu lisatakse iga lehe sektsiooni lõppu. See võimaldab stiile üle laadida või lisada analüütika koodi.', + 'app_custom_html_disabled_notice' => 'Kohandatud HTML päise sisu on sellel lehel välja lülitatud, et probleemseid muudatusi saaks tagasi võtta.', + 'app_logo' => 'Rakenduse logo', + 'app_logo_desc' => 'See pildifail peaks olema 43 pikslit kõrge.
    Suuremad pildifailid tehakse väiksemaks.', + 'app_primary_color' => 'Rakenduse põhivärv', + 'app_primary_color_desc' => 'Määrab rakenduse primaarse värvi, sh. päise, nuppude ja linkide jaoks.', + 'app_homepage' => 'Rakenduse avaleht', + 'app_homepage_desc' => 'Vali leht, mida näidata avalehel vaikimisi vaate asemel. Valitud lehele ei rakendata ligipääsuõiguseid.', + 'app_homepage_select' => 'Vali leht', + 'app_footer_links' => 'Lingid jaluses', + 'app_footer_links_desc' => 'Lisa rakenduse jalusesse linke. Neid näidatakse enamike lehtede jaluses, kaasa arvatud need, mis ei vaja sisselogimist. Võid kasutada märgendit "trans::", et kasutada süsteemseid tõlkeid. Näiteks "trans::common.privacy_policy" tekitab tõlgitud teksti "Privaatsus" ning "trans::common.terms_of_service" tekitab tõlgitud teksti "Kasutustingimused".', + 'app_footer_links_label' => 'Lingi tekst', + 'app_footer_links_url' => 'Lingi URL', + 'app_footer_links_add' => 'Lisa link', + 'app_disable_comments' => 'Keela kommentaarid', + 'app_disable_comments_toggle' => 'Keela kommentaarid', + 'app_disable_comments_desc' => 'Keelab kommentaarid kogu rakenduses.
    Olemasolevaid kommentaare ei näidata.', + + // Color settings + 'content_colors' => 'Sisuelementide värvid', + 'content_colors_desc' => 'Määrab värvid erinevatele sisuelementidele. Loetavuse huvides on soovituslik valida värvid, mille heledus on sarnane vaikimisi värvidele.', + 'bookshelf_color' => 'Riiuli värv', + 'book_color' => 'Raamatu värv', + 'chapter_color' => 'Peatüki värv', + 'page_color' => 'Lehe värv', + 'page_draft_color' => 'Mustandi värv', + + // Registration Settings + 'reg_settings' => 'Registreerumine', + 'reg_enable' => 'Luba registreerumine', + 'reg_enable_toggle' => 'Luba registreerumine', + 'reg_enable_desc' => 'Kui registreerumine on lubatud, saavad kasutajad ise endale rakenduse konto tekitada, ning neile antakse vaikimisi roll.', + 'reg_default_role' => 'Vaikimisi roll uutele kasutajatele', + 'reg_enable_external_warning' => 'The option above is ignored while external LDAP or SAML authentication is active. User accounts for non-existing members will be auto-created if authentication, against the external system in use, is successful.', + 'reg_email_confirmation' => 'E-posti aadressi kinnitus', + 'reg_email_confirmation_toggle' => 'Nõua e-posti aadressi kinnitamist', + 'reg_confirm_email_desc' => 'Kui domeeni piirang on kasutusel, siis on e-posti aadressi kinnitamine nõutud ja seda seadet ignoreeritakse.', + 'reg_confirm_restrict_domain' => 'Domeeni piirang', + 'reg_confirm_restrict_domain_desc' => 'Sisesta komaga eraldatud nimekiri e-posti domeenidest, millega soovitud registreerumist piirata. Kasutajale saadetakse aadressi kinnitamiseks e-kiri, enne kui neil lubatakse rakendust kasutada.
    Pane tähele, et kasutajad saavad pärast edukat registreerumist oma e-posti aadressi muuta.', + 'reg_confirm_restrict_domain_placeholder' => 'Piirangut ei ole', + + // Maintenance settings + 'maint' => 'Hooldus', + 'maint_image_cleanup' => 'Pildifailide koristus', + 'maint_image_cleanup_desc' => "Kontrollib lehtede ja redaktsioonide sisu, et leida pilte ja jooniseid, mis enam kasutusel ei ole. Enne selle käivitamist tee andmebaasist ja pildifailidest täielik varukoopia.", + 'maint_delete_images_only_in_revisions' => 'Kustuta ka pildifailid, mis on kasutusel ainult vanades redaktsioonides', + 'maint_image_cleanup_run' => 'Käivita koristus', + 'maint_image_cleanup_warning' => 'Leiti :count potentsiaalselt kasutamata pildifaili. Kas oled kindel, et soovid need kustutada?', + 'maint_image_cleanup_success' => 'Leiti ja kustutati :count potentsiaalselt kasutamata pildifaili!', + 'maint_image_cleanup_nothing_found' => 'Kasutamata pildifaile ei leitud, pole midagi kustutada!', + 'maint_send_test_email' => 'Saada testimiseks e-kiri', + 'maint_send_test_email_desc' => 'See saadab testimiseks e-kirja su profiilis märgitud aadressile.', + 'maint_send_test_email_run' => 'Saada test e-kiri', + 'maint_send_test_email_success' => 'E-kiri saadetud aadressile :address', + 'maint_send_test_email_mail_subject' => 'Test Email', + 'maint_send_test_email_mail_greeting' => 'E-kirjade saatmine tundub toimivat!', + 'maint_send_test_email_mail_text' => 'Hea töö! Kui sa selle e-kirja kätte said, on su e-posti seaded õigesti määratud.', + 'maint_recycle_bin_desc' => 'Kustutatud riiulid, raamatud, peatükid ja lehed saadetakse prügikasti, et neid saaks taastada või lõplikult kustutada. Vanemad objektid võidakse teatud aja järel automaatselt prügikastist kustutada.', + 'maint_recycle_bin_open' => 'Ava prügikast', + + // Recycle Bin + 'recycle_bin' => 'Prügikast', + 'recycle_bin_desc' => 'Siin saad taastada kustutatud objekte, või neid süsteemist lõplikult eemaldada. Nimekiri on filtreerimata, mitte nagu mujal tegevusloendites, kus rakenduvad õigused.', + 'recycle_bin_deleted_item' => 'Kustutatud objekt', + 'recycle_bin_deleted_parent' => 'Ülemobjekt', + 'recycle_bin_deleted_by' => 'Kustutaja', + 'recycle_bin_deleted_at' => 'Kustutamise aeg', + 'recycle_bin_permanently_delete' => 'Kustuta lõplikult', + 'recycle_bin_restore' => 'Taasta', + 'recycle_bin_contents_empty' => 'Prügikast on hetkel tühi', + 'recycle_bin_empty' => 'Tühjenda prügikast', + 'recycle_bin_empty_confirm' => 'See kustutab lõplikult kõik objektid prügikastis, kaasa arvatud nende sisu. Kas oled kindel, et soovid prügikasti tühjendada?', + 'recycle_bin_destroy_confirm' => 'See kustutab lõplikult valitud objekti koos loetletud alamobjektidega, ja seda sisu ei ole enam võimalik taastada. Kas oled kindel, et soovid selle objekti kustutada?', + 'recycle_bin_destroy_list' => 'Kustutatavad objektid', + 'recycle_bin_restore_list' => 'Taastatavad objektid', + 'recycle_bin_restore_confirm' => 'See taastab valitud objekti koos kõigi alamobjektidega nende algsesse asukohta. Kui see asukoht on ka vahepeal kustutatud ja on nüüd prügikastis, tuleb ka see taastada.', + 'recycle_bin_restore_deleted_parent' => 'Selle objekti ülemobjekt on ka kustutatud. Taastada ei saa enne, kui ülemobjekt on taastatud.', + 'recycle_bin_restore_parent' => 'Taasta ülemobjekt', + 'recycle_bin_destroy_notification' => 'Prügikastist kustutati :count objekti.', + 'recycle_bin_restore_notification' => 'Prügikastist taastati :count objekti.', + + // Audit Log + 'audit' => 'Auditilogi', + 'audit_desc' => 'Auditilogi kuvab nimekirja tegevustest, mida süsteem jälgib. See nimekiri on filtreerimata, erinevalt muudest loenditest süsteemis, kus rakenduvad õigused.', + 'audit_event_filter' => 'Sündmuse filter', + 'audit_event_filter_no_filter' => 'Ilma filtrita', + 'audit_deleted_item' => 'Kustutatud objekt', + 'audit_deleted_item_name' => 'Nimi: :name', + 'audit_table_user' => 'Kasutaja', + 'audit_table_event' => 'Sündmus', + 'audit_table_related' => 'Seotud objekt või detail', + 'audit_table_ip' => 'IP-aadress', + 'audit_table_date' => 'Tegevuse aeg', + 'audit_date_from' => 'Kuupäev alates', + 'audit_date_to' => 'Kuupäev kuni', + + // Role Settings + 'roles' => 'Rollid', + 'role_user_roles' => 'Kasutajate rollid', + 'role_create' => 'Lisa uus roll', + 'role_create_success' => 'Roll on lisatud', + 'role_delete' => 'Kustuta roll', + 'role_delete_confirm' => 'See kustutab rolli nimega \':roleName\'.', + 'role_delete_users_assigned' => 'Selle rolliga on seotud :userCount kasutajat. Kui soovid neile selle asemel uue rolli määrata, siis vali see allpool.', + 'role_delete_no_migration' => "Ära määra uut rolli", + 'role_delete_sure' => 'Kas oled kindel, et soovid selle rolli kustutada?', + 'role_delete_success' => 'Roll on kustutatud', + 'role_edit' => 'Muuda rolli', + 'role_details' => 'Rolli detailid', + 'role_name' => 'Rolli nimi', + 'role_desc' => 'Rolli lühike kirjeldus', + 'role_mfa_enforced' => 'Vajab mitmeastmelist autentimist', + 'role_external_auth_id' => 'Välise autentimise ID-d', + 'role_system' => 'Süsteemsed õigused', + 'role_manage_users' => 'Kasutajate haldamine', + 'role_manage_roles' => 'Rollide ja õiguste haldamine', + 'role_manage_entity_permissions' => 'Kõigi raamatute, peatükkide ja lehtede õiguste haldamine', + 'role_manage_own_entity_permissions' => 'Oma raamatute, peatükkide ja lehtede õiguste haldamine', + 'role_manage_page_templates' => 'Mallide haldamine', + 'role_access_api' => 'Süsteemi API ligipääs', + 'role_manage_settings' => 'Rakenduse seadete haldamine', + 'role_export_content' => 'Sisu eksport', + 'role_asset' => 'Sisu õigused', + 'roles_system_warning' => 'Pane tähele, et ülalolevad kolm õigust võimaldavad kasutajal enda või teiste kasutajate õiguseid muuta. Määra nende õigustega roll ainult usaldusväärsetele kasutajatele.', + 'role_asset_desc' => 'Need load kontrollivad vaikimisi ligipääsu süsteemis olevale sisule. Raamatute, peatükkide ja lehtede õigused rakenduvad esmajärjekorras.', + 'role_asset_admins' => 'Administraatoritel on automaatselt ligipääs kogu sisule, aga need valikud võivad peida või näidata kasutajaliidese elemente.', + 'role_all' => 'Kõik', + 'role_own' => 'Enda omad', + 'role_controlled_by_asset' => 'Õigused määratud seotud objekti kaudu', + 'role_save' => 'Salvesta roll', + 'role_update_success' => 'Roll on muudetud', + 'role_users' => 'Selle rolliga kasutajad', + 'role_users_none' => 'Seda rolli ei ole hetkel ühelgi kasutajal', + + // Users + 'users' => 'Kasutajad', + 'user_profile' => 'Kasutajaprofiil', + 'users_add_new' => 'Lisa uus kasutaja', + 'users_search' => 'Otsi kasutajaid', + 'users_latest_activity' => 'Viimane tegevus', + 'users_details' => 'Kasutaja andmed', + 'users_details_desc' => 'Määra kasutajale nimi ja e-posti aadress. E-posti aadressi kasutatakse rakendusse sisse logimiseks.', + 'users_details_desc_no_email' => 'Määra kasutajale nimi, mille järgi teised ta ära tunnevad.', + 'users_role' => 'Kasutaja rollid', + 'users_role_desc' => 'Vali, millised rollid sellel kasutajal on. Kui talle on valitud mitu rolli, siis nende õigused kombineeritakse ja kasutaja saab kõigi rollide õigused.', + 'users_password' => 'Kasutaja parool', + 'users_password_desc' => 'Määra kasutajale parool, millega rakendusse sisse logida. See peab olema vähemalt 6 tähemärki.', + 'users_send_invite_text' => 'Sa võid kasutajale saata e-postiga kutse, mis võimaldab neil ise parooli seada. Vastasel juhul määra parool ise.', + 'users_send_invite_option' => 'Saada e-postiga kutse', + 'users_external_auth_id' => 'Välise autentimise ID', + 'users_external_auth_id_desc' => 'Selle ID abil identifitseeritakse kasutajat välise autentimissüsteemiga suhtlemisel.', + 'users_password_warning' => 'Täida allolevad väljad ainult siis, kui soovid oma parooli muuta.', + 'users_system_public' => 'See kasutaja tähistab kõiki külalisi, kes su rakendust vaatavad. Selle kontoga ei saa sisse logida, see määratakse automaatselt.', + 'users_delete' => 'Kustuta kasutaja', + 'users_delete_named' => 'Kustuta kasutaja :userName', + 'users_delete_warning' => 'See kustutab kasutaja nimega \':userName\' süsteemist täielikult.', + 'users_delete_confirm' => 'Kas oled kindel, et soovid selle kasutaja kustutada?', + 'users_migrate_ownership' => 'Teisalda omandus', + 'users_migrate_ownership_desc' => 'Vali siin kasutaja, kui soovid talle üle viia kõik selle kasutaja objektid.', + 'users_none_selected' => 'Kasutaja valimata', + 'users_delete_success' => 'Kasutaja on kustutatud', + 'users_edit' => 'Muuda kasutajat', + 'users_edit_profile' => 'Muuda profiili', + 'users_edit_success' => 'Kasutaja on muudetud', + 'users_avatar' => 'Kasutaja profiilipilt', + 'users_avatar_desc' => 'Vali sellele kasutajale profiilipilt. See peaks olema umbes 256x256 pikslit.', + 'users_preferred_language' => 'Eelistatud keel', + 'users_preferred_language_desc' => 'See valik muudab rakenduse kasutajaliidese keelt. Kasutajate loodud sisu see ei mõjuta.', + 'users_social_accounts' => 'Sotsiaalmeedia kontod', + 'users_social_accounts_info' => 'Here you can connect your other accounts for quicker and easier login. Disconnecting an account here does not revoke previously authorized access. Revoke access from your profile settings on the connected social account.', + 'users_social_connect' => 'Lisa konto', + 'users_social_disconnect' => 'Eemalda konto', + 'users_social_connected' => ':socialAccount konto lisati su profiilile.', + 'users_social_disconnected' => ':socialAccount konto eemaldati su profiililt.', + 'users_api_tokens' => 'API tunnused', + 'users_api_tokens_none' => 'Sellel kasutajal pole API tunnuseid', + 'users_api_tokens_create' => 'Lisa tunnus', + 'users_api_tokens_expires' => 'Aegub', + 'users_api_tokens_docs' => 'API dokumentatsioon', + 'users_mfa' => 'Mitmeastmeline autentimine', + 'users_mfa_desc' => 'Seadista mitmeastmeline autentimine, et oma kasutajakonto turvalisust tõsta.', + 'users_mfa_x_methods' => ':count method configured|:count methods configured', + 'users_mfa_configure' => 'Configure Methods', + + // API Tokens + 'user_api_token_create' => 'Lisa API tunnus', + 'user_api_token_name' => 'Nimi', + 'user_api_token_name_desc' => 'Anna oma tunnusele inimloetav nimi, et selle eesmärk paremini meeles püsiks.', + 'user_api_token_expiry' => 'Kehtiv kuni', + 'user_api_token_expiry_desc' => 'Määra kuupäev, millal see tunnus aegub. Pärast seda kuupäeva ei saa selle tunnusega enam päringuid teha. Välja tühjaks jätmine määrab aegumiskuupäeva 100 aastat tulevikku.', + 'user_api_token_create_secret_message' => 'Kohe pärast selle tunnuse loomist genereeritakse ja kuvatakse tunnuse ID ja salajane võti. Võtit kuvatakse ainult ühe korra, seega kopeeri selle väärtus enne jätkamist turvalisse kohta.', + 'user_api_token_create_success' => 'API tunnus on lisatud', + 'user_api_token_update_success' => 'API tunnus on muudetud', + 'user_api_token' => 'API tunnus', + 'user_api_token_id' => 'Tunnuse ID', + 'user_api_token_id_desc' => 'See on API tunnuse süsteemne mittemuudetav identifikaator, mis tuleb API päringutele kaasa panna.', + 'user_api_token_secret' => 'Tunnuse võti', + 'user_api_token_secret_desc' => 'See on API tunnuse salajane võti, mis tuleb API päringutele kaasa panna. Seda kuvatakse ainult ühe korra, seega kopeeri see turvalisse kohta.', + 'user_api_token_created' => 'Tunnus lisatud :timeAgo', + 'user_api_token_updated' => 'Tunnus muudetud :timeAgo', + 'user_api_token_delete' => 'Kustuta tunnus', + 'user_api_token_delete_warning' => 'See kustutab API tunnuse nimega \':tokenName\' süsteemist.', + 'user_api_token_delete_confirm' => 'Kas oled kindel, et soovid selle API tunnuse kustutada?', + 'user_api_token_delete_success' => 'API tunnus on kustutatud', + + //! If editing translations files directly please ignore this in all + //! languages apart from en. Content will be auto-copied from en. + //!//////////////////////////////// + 'language_select' => [ + 'en' => 'English', + 'ar' => 'العربية', + 'bg' => 'Bǎlgarski', + 'bs' => 'Bosanski', + 'ca' => 'Català', + 'cs' => 'Česky', + 'da' => 'Dansk', + 'de' => 'Deutsch (Sie)', + 'de_informal' => 'Deutsch (Du)', + 'es' => 'Español', + 'es_AR' => 'Español Argentina', + 'fr' => 'Français', + 'he' => 'עברית', + 'hr' => 'Hrvatski', + 'hu' => 'Magyar', + 'id' => 'Bahasa Indonesia', + 'it' => 'Italian', + 'ja' => '日本語', + 'ko' => '한국어', + 'lt' => 'Lietuvių Kalba', + 'lv' => 'Latviešu Valoda', + 'nl' => 'Nederlands', + 'nb' => 'Norsk (Bokmål)', + 'pl' => 'Polski', + 'pt' => 'Português', + 'pt_BR' => 'Português do Brasil', + 'ru' => 'Русский', + 'sk' => 'Slovensky', + 'sl' => 'Slovenščina', + 'sv' => 'Svenska', + 'tr' => 'Türkçe', + 'uk' => 'Українська', + 'vi' => 'Tiếng Việt', + 'zh_CN' => '简体中文', + 'zh_TW' => '繁體中文', + ] + //!//////////////////////////////// +]; diff --git a/resources/lang/et/validation.php b/resources/lang/et/validation.php new file mode 100644 index 000000000..7fde26c47 --- /dev/null +++ b/resources/lang/et/validation.php @@ -0,0 +1,116 @@ + 'The :attribute must be accepted.', + 'active_url' => ':attribute ei ole kehtiv URL.', + 'after' => ':attribute peab olema kuupäev pärast :date.', + 'alpha' => ':attribute võib sisaldada ainult tähti.', + 'alpha_dash' => ':attribute võib sisaldada ainult tähti, numbreid, sidekriipse ja alakriipse.', + 'alpha_num' => ':attribute võib sisaldada ainult tähti ja numbreid.', + 'array' => ':attribute peab olema massiiv.', + 'backup_codes' => 'The provided code is not valid or has already been used.', + 'before' => ':attribute peab olema kuupäev enne :date.', + 'between' => [ + 'numeric' => ':attribute peab jääma vahemikku :min ja :max.', + 'file' => ':attribute peab olema :min ja :max kilobaidi vahel.', + 'string' => ':attribute peab olema :min ja :max tähemärgi vahel.', + 'array' => ':attribute peab olema :min ja :max elemendi vahel.', + ], + 'boolean' => ':attribute peab olema tõene või väär.', + 'confirmed' => 'The :attribute confirmation does not match.', + 'date' => ':attribute ei ole kehtiv kuupäev.', + 'date_format' => ':attribute ei ühti formaadiga :format.', + 'different' => ':attribute ja :other peavad olema erinevad.', + 'digits' => ':attribute peab olema :digits-kohaline arv.', + 'digits_between' => ':attribute peab olema :min ja :max numbri vahel.', + 'email' => ':attribute peab olema kehtiv e-posti aadress.', + 'ends_with' => ':attribute lõpus peab olema üks järgmistest väärtustest: :values', + 'filled' => ':attribute väli on kohustuslik.', + 'gt' => [ + 'numeric' => ':attribute peab olema suurem kui :value.', + 'file' => ':attribute peab olema suurem kui :value kilobaiti.', + 'string' => ':attribute peab sisaldama rohkem kui :value tähemärki.', + 'array' => ':attribute peab sisaldama rohkem kui :value elementi.', + ], + 'gte' => [ + 'numeric' => ':attribute peab olema suurem kui või võrdne :value.', + 'file' => ':attribute peab olema :value kilobaiti või rohkem.', + 'string' => ':attribute peab sisaldama :value või rohkem tähemärki.', + 'array' => ':attribute peab sisaldama :value või rohkem elementi.', + ], + 'exists' => 'Valitud :attribute on vigane.', + 'image' => ':attribute peab olema pildifail.', + 'image_extension' => 'The :attribute must have a valid & supported image extension.', + 'in' => 'Valitud :attribute on vigane.', + 'integer' => ':attribute peab olema täisarv.', + 'ip' => ':attribute peab olema kehtiv IP-aadress.', + 'ipv4' => ':attribute peab olema kehtiv IPv4 aadress.', + 'ipv6' => ':attribute peab olema kehtiv IPv6 aadress.', + 'json' => ':attribute peab olema kehtiv JSON-vormingus tekst.', + 'lt' => [ + 'numeric' => ':attribute peab olema väiksem kui :value.', + 'file' => ':attribute peab olema väiksem kui :value kilobaiti.', + 'string' => ':attribute peab sisaldama vähem kui :value tähemärki.', + 'array' => ':attribute peab sisaldama vähem kui :value elementi.', + ], + 'lte' => [ + 'numeric' => ':attribute peab olema :value või vähem.', + 'file' => ':attribute peab olema :value kilobaiti või vähem.', + 'string' => ':attribute peab sisaldama :value või vähem tähemärki.', + 'array' => ':attribute ei tohi sisaldada rohkem kui :value elementi.', + ], + 'max' => [ + 'numeric' => ':attribute ei tohi olla suurem kui :max.', + 'file' => ':attribute ei tohi olla suurem kui :max kilobaiti.', + 'string' => ':attribute ei tohi sisaldada rohkem kui :max tähemärki.', + 'array' => ':attribute ei tohi sisaldada rohkem kui :max elementi.', + ], + 'mimes' => ':attribute peab olema seda tüüpi fail: :values.', + 'min' => [ + 'numeric' => ':attribute peab olema vähemalt :min.', + 'file' => ':attribute peab olema vähemalt :min kilobaiti.', + 'string' => ':attribute peab sisaldama vähemalt :min tähemärki.', + 'array' => ':attribute peab sisaldama vähemalt :min elementi.', + ], + 'not_in' => 'The selected :attribute is invalid.', + 'not_regex' => 'The :attribute format is invalid.', + 'numeric' => 'The :attribute must be a number.', + 'regex' => 'The :attribute format is invalid.', + 'required' => ':attribute on kohustuslik.', + 'required_if' => ':attribute on kohustuslik, kui :other on :value.', + 'required_with' => ':attribute on kohustuslik, kui :values on olemas.', + 'required_with_all' => ':attribute on kohustuslik, kui :values on olemas.', + 'required_without' => ':attribute on kohustuslik, kui :values ei ole olemas.', + 'required_without_all' => 'The :attribute field is required when none of :values are present.', + 'same' => ':attribute ja :other peavad klappima.', + 'safe_url' => 'Link ei pruugi olla turvaline.', + 'size' => [ + 'numeric' => ':attribute peab olema :size.', + 'file' => ':attribute peab olema :size kilobaiti.', + 'string' => ':attribute peab sisaldama :size tähemärki.', + 'array' => ':attribute peab sisaldama :size elemente.', + ], + 'string' => 'The :attribute must be a string.', + 'timezone' => 'The :attribute must be a valid zone.', + 'totp' => 'Kood ei ole korrektne või on aegunud.', + 'unique' => ':attribute on juba võetud.', + 'url' => ':attribute on vigases formaadis.', + 'uploaded' => 'Faili üleslaadimine ebaõnnestus. Server ei pruugi sellise suurusega faile vastu võtta.', + + // Custom validation lines + 'custom' => [ + 'password-confirm' => [ + 'required_with' => 'Parooli kinnitus on nõutud', + ], + ], + + // Custom validation attributes + 'attributes' => [], +]; diff --git a/resources/lang/fa/errors.php b/resources/lang/fa/errors.php index 9b0281bc0..bbea8d35a 100644 --- a/resources/lang/fa/errors.php +++ b/resources/lang/fa/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'آدرس داده ای برای این کاربر در داده های ارائه شده توسط سیستم احراز هویت خارجی یافت نشد', 'saml_invalid_response_id' => 'درخواست از سیستم احراز هویت خارجی توسط فرایندی که توسط این نرم افزار آغاز شده است شناخته نمی شود. بازگشت به سیستم پس از ورود به سیستم می تواند باعث این مسئله شود.', 'saml_fail_authed' => 'ورود به سیستم :system انجام نشد، سیستم مجوز موفقیت آمیز ارائه نکرد', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'عملی تعریف نشده است', 'social_login_bad_response' => "خطای دریافت شده در هنگام ورود به سیستم:\n:error", 'social_account_in_use' => 'این حساب :socialAccount از قبل در حال استفاده است، سعی کنید از طریق گزینه :socialAccount وارد سیستم شوید.', diff --git a/resources/lang/fr/errors.php b/resources/lang/fr/errors.php index d7f00d8e1..41cf1e148 100644 --- a/resources/lang/fr/errors.php +++ b/resources/lang/fr/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'Impossible de trouver une adresse e-mail, pour cet utilisateur, dans les données fournies par le système d\'authentification externe', 'saml_invalid_response_id' => 'La requête du système d\'authentification externe n\'est pas reconnue par un processus démarré par cette application. Naviguer après une connexion peut causer ce problème.', 'saml_fail_authed' => 'Connexion avec :system échouée, le système n\'a pas fourni l\'autorisation réussie', + 'oidc_already_logged_in' => 'Déjà connecté', + 'oidc_user_not_registered' => 'L\'utilisateur :name n\'est pas enregistré et l\'enregistrement automatique est désactivé', + 'oidc_no_email_address' => 'Impossible de trouver une adresse e-mail pour cet utilisateur, dans les données fournies par le système d\'authentification externe', + 'oidc_fail_authed' => 'La connexion en utilisant :system a échoué, le système n\'a pas fourni d\'autorisation avec succès', 'social_no_action_defined' => 'Pas d\'action définie', 'social_login_bad_response' => "Erreur pendant la tentative de connexion à :socialAccount : \n:error", 'social_account_in_use' => 'Ce compte :socialAccount est déjà utilisé. Essayez de vous connecter via :socialAccount.', diff --git a/resources/lang/he/errors.php b/resources/lang/he/errors.php index 5c879216c..f5836082d 100644 --- a/resources/lang/he/errors.php +++ b/resources/lang/he/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', 'saml_invalid_response_id' => 'The request from the external authentication system is not recognised by a process started by this application. Navigating back after a login could cause this issue.', 'saml_fail_authed' => 'Login using :system failed, system did not provide successful authorization', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'לא הוגדרה פעולה', 'social_login_bad_response' => "Error received during :socialAccount login: \n:error", 'social_account_in_use' => 'החשבון :socialAccount כבר בשימוש. אנא נסה להתחבר באמצעות אפשרות :socialAccount', diff --git a/resources/lang/hr/errors.php b/resources/lang/hr/errors.php index 456210829..1022ca495 100644 --- a/resources/lang/hr/errors.php +++ b/resources/lang/hr/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'Nismo pronašli email adresu za ovog korisnika u vanjskim sustavima', 'saml_invalid_response_id' => 'Sustav za autentifikaciju nije prepoznat. Ovaj problem možda je nastao zbog vraćanja nakon prijave.', 'saml_fail_authed' => 'Prijava pomoću :system nije uspjela zbog neuspješne autorizacije', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'Nije definirana nijedna radnja', 'social_login_bad_response' => "Error received during :socialAccount login: \n:error", 'social_account_in_use' => 'Ovaj :socialAccount račun se već koristi. Pokušajte se prijaviti pomoću :socialAccount računa.', diff --git a/resources/lang/hu/errors.php b/resources/lang/hu/errors.php index a16bef529..84baf5062 100644 --- a/resources/lang/hu/errors.php +++ b/resources/lang/hu/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'Ehhez a felhasználóhoz nem található email cím a külső hitelesítő rendszer által átadott adatokban', 'saml_invalid_response_id' => 'A külső hitelesítő rendszerből érkező kérést nem ismerte fel az alkalmazás által indított folyamat. Bejelentkezés után az előző oldalra történő visszalépés okozhatja ezt a hibát.', 'saml_fail_authed' => 'Bejelentkezés :system használatával sikertelen, a rendszer nem biztosított sikeres hitelesítést', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'Nincs művelet meghatározva', 'social_login_bad_response' => "Hiba történt :socialAccount bejelentkezés közben:\n:error", 'social_account_in_use' => ':socialAccount fiók már használatban van. :socialAccount opción keresztül érdemes megpróbálni a bejelentkezést.', diff --git a/resources/lang/id/activities.php b/resources/lang/id/activities.php index bac965be4..8c7562bf7 100644 --- a/resources/lang/id/activities.php +++ b/resources/lang/id/activities.php @@ -48,8 +48,8 @@ return [ 'favourite_remove_notification' => '":name" telah dihapus dari favorit Anda', // MFA - 'mfa_setup_method_notification' => 'Multi-factor method successfully configured', - 'mfa_remove_method_notification' => 'Multi-factor method successfully removed', + 'mfa_setup_method_notification' => 'Metode multi-faktor sukses dikonfigurasi', + 'mfa_remove_method_notification' => 'Metode multi-faktor sukses dihapus', // Other 'commented_on' => 'berkomentar pada', diff --git a/resources/lang/id/auth.php b/resources/lang/id/auth.php index d800ebbcc..84fc3a16e 100644 --- a/resources/lang/id/auth.php +++ b/resources/lang/id/auth.php @@ -80,7 +80,7 @@ return [ 'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.', 'mfa_setup_configured' => 'Already configured', 'mfa_setup_reconfigure' => 'Reconfigure', - 'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?', + 'mfa_setup_remove_confirmation' => 'Apakah Anda yakin ingin menghapus metode autentikasi multi-faktor ini?', 'mfa_setup_action' => 'Setup', 'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.', 'mfa_option_totp_title' => 'Mobile App', diff --git a/resources/lang/id/errors.php b/resources/lang/id/errors.php index 9244c96e1..4f71eeb00 100644 --- a/resources/lang/id/errors.php +++ b/resources/lang/id/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'Tidak dapat menemukan sebuah alamat email untuk pengguna ini, dalam data yang diberikan oleh sistem autentikasi eksternal', 'saml_invalid_response_id' => 'Permintaan dari sistem otentikasi eksternal tidak dikenali oleh sebuah proses yang dimulai oleh aplikasi ini. Menavigasi kembali setelah masuk dapat menyebabkan masalah ini.', 'saml_fail_authed' => 'Masuk menggunakan :system gagal, sistem tidak memberikan otorisasi yang berhasil', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'Tidak ada tindakan yang ditentukan', 'social_login_bad_response' => "Kesalahan yang diterima selama masuk menggunakan :socialAccount : \n:error", 'social_account_in_use' => 'Akun :socialAccount ini sudah digunakan, Coba masuk melalui opsi :socialAccount.', diff --git a/resources/lang/it/auth.php b/resources/lang/it/auth.php index 3e1500a6f..2a33a3184 100755 --- a/resources/lang/it/auth.php +++ b/resources/lang/it/auth.php @@ -82,31 +82,31 @@ return [ 'mfa_setup_reconfigure' => 'Riconfigura', 'mfa_setup_remove_confirmation' => 'Sei sicuro di voler rimuovere questo metodo di autenticazione multi-fattore?', 'mfa_setup_action' => 'Imposta', - 'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.', - 'mfa_option_totp_title' => 'Mobile App', - 'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.', - 'mfa_option_backup_codes_title' => 'Backup Codes', - 'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.', - 'mfa_gen_confirm_and_enable' => 'Confirm and Enable', - 'mfa_gen_backup_codes_title' => 'Backup Codes Setup', - 'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.', - 'mfa_gen_backup_codes_download' => 'Download Codes', - 'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once', - 'mfa_gen_totp_title' => 'Mobile App Setup', - 'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.', - 'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.', - 'mfa_gen_totp_verify_setup' => 'Verify Setup', - 'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:', - 'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here', - 'mfa_verify_access' => 'Verify Access', - 'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.', - 'mfa_verify_no_methods' => 'No Methods Configured', - 'mfa_verify_no_methods_desc' => 'No multi-factor authentication methods could be found for your account. You\'ll need to set up at least one method before you gain access.', - 'mfa_verify_use_totp' => 'Verify using a mobile app', - 'mfa_verify_use_backup_codes' => 'Verify using a backup code', - 'mfa_verify_backup_code' => 'Backup Code', - 'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:', - 'mfa_verify_backup_code_enter_here' => 'Enter backup code here', - 'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:', + 'mfa_backup_codes_usage_limit_warning' => 'Hai meno di 5 codici di backup rimanenti. Genera e memorizza un nuovo set prima di esaurire i codici per evitare di essere bloccato dal tuo account.', + 'mfa_option_totp_title' => 'App mobile', + 'mfa_option_totp_desc' => 'Per utilizzare l\'autenticazione multi-fattore avrai bisogno di un\'applicazione mobile che supporti TOTP come Google Authenticator, Authy o Microsoft Authenticator.', + 'mfa_option_backup_codes_title' => 'Codici di backup', + 'mfa_option_backup_codes_desc' => 'Salva in modo sicuro una serie di codici di backup monouso che puoi inserire per verificare la tua identità.', + 'mfa_gen_confirm_and_enable' => 'Conferma e abilita', + 'mfa_gen_backup_codes_title' => 'Configurazione codici di backup', + 'mfa_gen_backup_codes_desc' => 'Conserva l\'elenco di codici qui sotto in un luogo sicuro. Quando accedi al sistema potrai utilizzare uno dei codici come meccanismo di autenticazione secondario.', + 'mfa_gen_backup_codes_download' => 'Scarica codici', + 'mfa_gen_backup_codes_usage_warning' => 'Ogni codice può essere utilizzato solo una volta', + 'mfa_gen_totp_title' => 'Impostazione App Mobile', + 'mfa_gen_totp_desc' => 'Per utilizzare l\'autenticazione multi-fattore avrai bisogno di un\'applicazione mobile che supporti TOTP come Google Authenticator, Authy o Microsoft Authenticator.', + 'mfa_gen_totp_scan' => 'Scansiona il codice QR qui sotto utilizzando la tua app di autenticazione preferita per iniziare.', + 'mfa_gen_totp_verify_setup' => 'Verifica configurazione', + 'mfa_gen_totp_verify_setup_desc' => 'Verifica che tutto funzioni inserendo un codice, generato all\'interno della tua app di autenticazione, nella casella di testo sottostante:', + 'mfa_gen_totp_provide_code_here' => 'Inserisci qui il codice generato dall\'app', + 'mfa_verify_access' => 'Verifica accesso', + 'mfa_verify_access_desc' => 'Il tuo account utente richiede che tu confermi la tua identità tramite un ulteriore livello di verifica prima di ottenere l\'accesso. Verifica usando uno dei tuoi metodi configurati per continuare.', + 'mfa_verify_no_methods' => 'Nessun metodo configurato', + 'mfa_verify_no_methods_desc' => 'Non è stato possibile trovare metodi di autenticazione multi-fattore per il tuo account. Devi impostare almeno un metodo prima di ottenere l\'accesso.', + 'mfa_verify_use_totp' => 'Verifica utilizzando un\'app mobile', + 'mfa_verify_use_backup_codes' => 'Verifica utilizzando un codice di backup', + 'mfa_verify_backup_code' => 'Codice di backup', + 'mfa_verify_backup_code_desc' => 'Inserisci uno dei tuoi rimanenti codici di backup qui sotto:', + 'mfa_verify_backup_code_enter_here' => 'Inserisci qui il codice di backup', + 'mfa_verify_totp_desc' => 'Inserisci il codice, generato tramite la tua app mobile, qui sotto:', 'mfa_setup_login_notification' => 'Metodo multi-fattore configurato, si prega di effettuare nuovamente il login utilizzando il metodo configurato.', ]; \ No newline at end of file diff --git a/resources/lang/it/entities.php b/resources/lang/it/entities.php index b9cd4caf3..64354b751 100755 --- a/resources/lang/it/entities.php +++ b/resources/lang/it/entities.php @@ -234,7 +234,7 @@ return [ 'pages_initial_name' => 'Nuova Pagina', 'pages_editing_draft_notification' => 'Stai modificando una bozza che è stata salvata il :timeDiff.', 'pages_draft_edited_notification' => 'Questa pagina è stata aggiornata. È consigliabile scartare questa bozza.', - 'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.', + 'pages_draft_page_changed_since_creation' => 'Questa pagina è stata aggiornata da quando è stata creata questa bozza. Si consiglia di scartare questa bozza o fare attenzione a non sovrascrivere alcun cambiamento di pagina.', 'pages_draft_edit_active' => [ 'start_a' => ':count hanno iniziato a modificare questa pagina', 'start_b' => ':userName ha iniziato a modificare questa pagina', diff --git a/resources/lang/it/errors.php b/resources/lang/it/errors.php index 9a20c744b..2176b44ba 100755 --- a/resources/lang/it/errors.php +++ b/resources/lang/it/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'Impossibile trovare un indirizzo email per questo utente nei dati forniti dal sistema di autenticazione esterno', 'saml_invalid_response_id' => 'La richiesta dal sistema di autenticazione esterno non è riconosciuta da un processo iniziato da questa applicazione. Tornare indietro dopo un login potrebbe causare questo problema.', 'saml_fail_authed' => 'Accesso con :system non riuscito, il sistema non ha fornito l\'autorizzazione corretta', + 'oidc_already_logged_in' => 'Hai già effettuato il login', + 'oidc_user_not_registered' => 'L\'utente :name non è registrato e la registrazione automatica è disabilitata', + 'oidc_no_email_address' => 'Impossibile trovare un indirizzo email, per questo utente, nei dati forniti dal sistema di autenticazione esterno', + 'oidc_fail_authed' => 'Accesso con :system non riuscito, il sistema non ha fornito l\'autorizzazione', 'social_no_action_defined' => 'Nessuna azione definita', 'social_login_bad_response' => "Ricevuto error durante il login con :socialAccount : \n:error", 'social_account_in_use' => 'Questo account :socialAccount è già utilizzato, prova a loggarti usando l\'opzione :socialAccount.', diff --git a/resources/lang/ja/activities.php b/resources/lang/ja/activities.php index 3dc749b67..c5ce3ffc6 100644 --- a/resources/lang/ja/activities.php +++ b/resources/lang/ja/activities.php @@ -36,22 +36,22 @@ return [ 'book_sort_notification' => '並び順を変更しました', // Bookshelves - 'bookshelf_create' => '本棚を作成:', + 'bookshelf_create' => 'が本棚を作成:', 'bookshelf_create_notification' => '本棚を作成しました', - 'bookshelf_update' => '本棚を更新:', + 'bookshelf_update' => 'が本棚を更新:', 'bookshelf_update_notification' => '本棚を更新しました', - 'bookshelf_delete' => 'ブックが削除されました。', + 'bookshelf_delete' => 'が本棚を削除:', 'bookshelf_delete_notification' => '本棚を削除しました', // Favourites - 'favourite_add_notification' => '":name" has been added to your favourites', - 'favourite_remove_notification' => '":name" has been removed from your favourites', + 'favourite_add_notification' => '":name"がお気に入りに追加されました', + 'favourite_remove_notification' => '":name"がお気に入りから削除されました', // MFA - 'mfa_setup_method_notification' => 'Multi-factor method successfully configured', - 'mfa_remove_method_notification' => 'Multi-factor method successfully removed', + 'mfa_setup_method_notification' => '多要素認証が正常に設定されました', + 'mfa_remove_method_notification' => '多要素認証が正常に解除されました', // Other - 'commented_on' => 'コメントする', - 'permissions_update' => 'updated permissions', + 'commented_on' => 'がコメント:', + 'permissions_update' => 'が権限を更新:', ]; diff --git a/resources/lang/ja/auth.php b/resources/lang/ja/auth.php index 8dcac7aa4..98f56e73e 100644 --- a/resources/lang/ja/auth.php +++ b/resources/lang/ja/auth.php @@ -43,7 +43,7 @@ return [ 'reset_password' => 'パスワードリセット', 'reset_password_send_instructions' => '以下にEメールアドレスを入力すると、パスワードリセットリンクが記載されたメールが送信されます。', 'reset_password_send_button' => 'リセットリンクを送信', - 'reset_password_sent' => 'A password reset link will be sent to :email if that email address is found in the system.', + 'reset_password_sent' => 'メールアドレスがシステムで見つかった場合、パスワードリセットリンクが:emailに送信されます。', 'reset_password_success' => 'パスワードがリセットされました。', 'email_reset_subject' => ':appNameのパスワードをリセット', 'email_reset_text' => 'このメールは、パスワードリセットがリクエストされたため送信されています。', @@ -66,47 +66,47 @@ return [ 'email_not_confirmed_resend_button' => '確認メールを再送信', // User Invite - 'user_invite_email_subject' => 'You have been invited to join :appName!', - 'user_invite_email_greeting' => 'An account has been created for you on :appName.', - 'user_invite_email_text' => 'Click the button below to set an account password and gain access:', - 'user_invite_email_action' => 'Set Account Password', - 'user_invite_page_welcome' => 'Welcome to :appName!', - 'user_invite_page_text' => 'To finalise your account and gain access you need to set a password which will be used to log-in to :appName on future visits.', - 'user_invite_page_confirm_button' => 'Confirm Password', - 'user_invite_success' => 'Password set, you now have access to :appName!', + 'user_invite_email_subject' => ':appNameに招待されました!', + 'user_invite_email_greeting' => ':appNameにあなたのアカウントが作成されました。', + 'user_invite_email_text' => 'アカウントのパスワードを設定してアクセスできるようにするため、下のボタンをクリックしてください:', + 'user_invite_email_action' => 'アカウントのパスワード設定', + 'user_invite_page_welcome' => ':appNameへようこそ!', + 'user_invite_page_text' => 'アカウントの設定を完了してアクセスするには、今後の訪問時に:appNameにログインするためのパスワードを設定する必要があります。', + 'user_invite_page_confirm_button' => 'パスワードを確定', + 'user_invite_success' => 'パスワードが設定されました。:appNameにアクセスできるようになりました!', // Multi-factor Authentication - 'mfa_setup' => 'Setup Multi-Factor Authentication', - 'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.', - 'mfa_setup_configured' => 'Already configured', - 'mfa_setup_reconfigure' => 'Reconfigure', - 'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?', - 'mfa_setup_action' => 'Setup', - 'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.', - 'mfa_option_totp_title' => 'Mobile App', - 'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.', - 'mfa_option_backup_codes_title' => 'Backup Codes', - 'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.', - 'mfa_gen_confirm_and_enable' => 'Confirm and Enable', - 'mfa_gen_backup_codes_title' => 'Backup Codes Setup', - 'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.', - 'mfa_gen_backup_codes_download' => 'Download Codes', - 'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once', - 'mfa_gen_totp_title' => 'Mobile App Setup', - 'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.', - 'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.', - 'mfa_gen_totp_verify_setup' => 'Verify Setup', - 'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:', - 'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here', + 'mfa_setup' => '多要素認証を設定', + 'mfa_setup_desc' => 'アカウントのセキュリティを強化するために、多要素認証を設定してください。', + 'mfa_setup_configured' => '既に設定されています', + 'mfa_setup_reconfigure' => '再設定', + 'mfa_setup_remove_confirmation' => 'この多要素認証方法を削除してもよろしいですか?', + 'mfa_setup_action' => '設定', + 'mfa_backup_codes_usage_limit_warning' => 'バックアップコードは残り5つ以下です。アカウントのロックアウトを防ぐため、コードがなくなる前に新しいセットを生成して保存してください。', + 'mfa_option_totp_title' => 'モバイルアプリ', + 'mfa_option_totp_desc' => '多要素認証を使用するには、Google Authenticator、Authy、Microsoft AuthenticatorなどのTOTPをサポートするモバイルアプリケーションが必要です。', + 'mfa_option_backup_codes_title' => 'バックアップコード', + 'mfa_option_backup_codes_desc' => '本人確認のために入力する、一度しか使えないバックアップコードを安全に保存します。', + 'mfa_gen_confirm_and_enable' => '確認して有効化', + 'mfa_gen_backup_codes_title' => 'バックアップコードの設定', + 'mfa_gen_backup_codes_desc' => '以下のコードのリストを安全な場所に保管してください。システムにアクセスする際、コードのいずれかを第二の認証手段として使用できます。', + 'mfa_gen_backup_codes_download' => 'コードをダウンロード', + 'mfa_gen_backup_codes_usage_warning' => '各コードは一度だけ使用できます', + 'mfa_gen_totp_title' => 'モバイルアプリの設定', + 'mfa_gen_totp_desc' => '多要素認証を使用するには、Google Authenticator、Authy、Microsoft AuthenticatorなどのTOTPをサポートするモバイルアプリケーションが必要です。', + 'mfa_gen_totp_scan' => '利用したい認証アプリで以下のQRコードをスキャンしてください。', + 'mfa_gen_totp_verify_setup' => '設定を検証', + 'mfa_gen_totp_verify_setup_desc' => '認証アプリで生成されたコードを下の入力ボックスに入力し、すべてが機能していることを確認してください。', + 'mfa_gen_totp_provide_code_here' => 'アプリが生成したコードを入力', 'mfa_verify_access' => 'Verify Access', 'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.', 'mfa_verify_no_methods' => 'No Methods Configured', 'mfa_verify_no_methods_desc' => 'No multi-factor authentication methods could be found for your account. You\'ll need to set up at least one method before you gain access.', 'mfa_verify_use_totp' => 'Verify using a mobile app', 'mfa_verify_use_backup_codes' => 'Verify using a backup code', - 'mfa_verify_backup_code' => 'Backup Code', + 'mfa_verify_backup_code' => 'バックアップコード', 'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:', 'mfa_verify_backup_code_enter_here' => 'Enter backup code here', 'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:', - 'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.', + 'mfa_setup_login_notification' => '多要素認証が構成されました。設定された手段を利用して再度ログインしてください。', ]; \ No newline at end of file diff --git a/resources/lang/ja/common.php b/resources/lang/ja/common.php index a5f2f9429..f7a0fdad6 100644 --- a/resources/lang/ja/common.php +++ b/resources/lang/ja/common.php @@ -11,50 +11,50 @@ return [ 'save' => '保存', 'continue' => '続ける', 'select' => '選択', - 'toggle_all' => 'Toggle All', + 'toggle_all' => '一括切替', 'more' => 'その他', // Form Labels 'name' => '名称', 'description' => '概要', 'role' => '権限', - 'cover_image' => 'Cover image', - 'cover_image_description' => 'この画像は約 300x170px をする必要があります。', + 'cover_image' => 'カバー画像', + 'cover_image_description' => 'この画像はおよそ440x250pxの大きさが必要です。', // Actions 'actions' => '実行', 'view' => '表示', - 'view_all' => 'View All', + 'view_all' => 'すべて表示', 'create' => '作成', 'update' => '更新', 'edit' => '編集', 'sort' => '並び順', 'move' => '移動', - 'copy' => 'Copy', + 'copy' => 'コピー', 'reply' => '返信', 'delete' => '削除', - 'delete_confirm' => 'Confirm Deletion', + 'delete_confirm' => '確認して削除', 'search' => '検索', 'search_clear' => '検索をクリア', 'reset' => 'リセット', 'remove' => '削除', 'add' => '追加', 'configure' => 'Configure', - 'fullscreen' => 'Fullscreen', - 'favourite' => 'Favourite', - 'unfavourite' => 'Unfavourite', - 'next' => 'Next', - 'previous' => 'Previous', + 'fullscreen' => '全画面', + 'favourite' => 'お気に入り', + 'unfavourite' => 'お気に入りから削除', + 'next' => '次へ', + 'previous' => '前へ', // Sort Options - 'sort_options' => 'Sort Options', - 'sort_direction_toggle' => 'Sort Direction Toggle', - 'sort_ascending' => 'Sort Ascending', - 'sort_descending' => 'Sort Descending', - 'sort_name' => 'Name', - 'sort_default' => 'Default', - 'sort_created_at' => 'Created Date', - 'sort_updated_at' => 'Updated Date', + 'sort_options' => '並べ替えオプション', + 'sort_direction_toggle' => '並べ替え方向の切り替え', + 'sort_ascending' => '昇順に並べ替え', + 'sort_descending' => '降順に並べ替え', + 'sort_name' => '名前', + 'sort_default' => 'デフォルト', + 'sort_created_at' => '作成日', + 'sort_updated_at' => '更新日', // Misc 'deleted_user' => '削除済みユーザ', @@ -67,16 +67,16 @@ return [ 'details' => '詳細', 'grid_view' => 'グリッド形式', 'list_view' => 'リスト形式', - 'default' => 'Default', - 'breadcrumb' => 'Breadcrumb', + 'default' => 'デフォルト', + 'breadcrumb' => 'パンくずリスト', // Header 'header_menu_expand' => 'Expand Header Menu', 'profile_menu' => 'Profile Menu', 'view_profile' => 'プロフィール表示', 'edit_profile' => 'プロフィール編集', - 'dark_mode' => 'Dark Mode', - 'light_mode' => 'Light Mode', + 'dark_mode' => 'ダークモード', + 'light_mode' => 'ライトモード', // Layout tabs 'tab_info' => 'Info', @@ -90,6 +90,6 @@ return [ // Footer Link Options // Not directly used but available for convenience to users. - 'privacy_policy' => 'Privacy Policy', - 'terms_of_service' => 'Terms of Service', + 'privacy_policy' => 'プライバシーポリシー', + 'terms_of_service' => '利用規約', ]; diff --git a/resources/lang/ja/components.php b/resources/lang/ja/components.php index c4e444337..54a1092d2 100644 --- a/resources/lang/ja/components.php +++ b/resources/lang/ja/components.php @@ -15,7 +15,7 @@ return [ 'image_load_more' => 'さらに読み込む', 'image_image_name' => '画像名', 'image_delete_used' => 'この画像は以下のページで利用されています。', - 'image_delete_confirm_text' => 'Are you sure you want to delete this image?', + 'image_delete_confirm_text' => 'この画像を削除してもよろしいですか?', 'image_select_image' => '画像を選択', 'image_dropzone' => '画像をドロップするか、クリックしてアップロード', 'images_deleted' => '画像を削除しました', diff --git a/resources/lang/ja/entities.php b/resources/lang/ja/entities.php index e93169ac6..62d806eec 100644 --- a/resources/lang/ja/entities.php +++ b/resources/lang/ja/entities.php @@ -11,7 +11,7 @@ return [ 'recently_updated_pages' => '最近更新されたページ', 'recently_created_chapters' => '最近作成されたチャプター', 'recently_created_books' => '最近作成されたブック', - 'recently_created_shelves' => 'Recently Created Shelves', + 'recently_created_shelves' => '最近作成された本棚', 'recently_update' => '最近更新', 'recently_viewed' => '閲覧履歴', 'recent_activity' => 'アクティビティ', @@ -27,8 +27,8 @@ return [ 'images' => '画像', 'my_recent_drafts' => '最近の下書き', 'my_recently_viewed' => '閲覧履歴', - 'my_most_viewed_favourites' => 'My Most Viewed Favourites', - 'my_favourites' => 'My Favourites', + 'my_most_viewed_favourites' => '最も閲覧したお気に入り', + 'my_favourites' => 'お気に入り', 'no_pages_viewed' => 'なにもページを閲覧していません', 'no_pages_recently_created' => '最近作成されたページはありません', 'no_pages_recently_updated' => '最近更新されたページはありません。', @@ -52,19 +52,19 @@ return [ 'search_no_pages' => 'ページが見つかりませんでした。', 'search_for_term' => ':term の検索結果', 'search_more' => 'さらに表示', - 'search_advanced' => 'Advanced Search', - 'search_terms' => 'Search Terms', + 'search_advanced' => '高度な検索', + 'search_terms' => '検索語句', 'search_content_type' => '種類', 'search_exact_matches' => '完全一致', 'search_tags' => 'タグ検索', - 'search_options' => 'Options', + 'search_options' => 'オプション', 'search_viewed_by_me' => '自分が閲覧したことがある', 'search_not_viewed_by_me' => '自分が閲覧したことがない', 'search_permissions_set' => '権限が設定されている', 'search_created_by_me' => '自分が作成した', 'search_updated_by_me' => '自分が更新した', - 'search_owned_by_me' => 'Owned by me', - 'search_date_options' => 'Date Options', + 'search_owned_by_me' => '自分が所有している', + 'search_date_options' => '日付オプション', 'search_updated_before' => '以前に更新', 'search_updated_after' => '以降に更新', 'search_created_before' => '以前に作成', @@ -73,29 +73,29 @@ return [ 'search_update' => 'フィルタを更新', // Shelves - 'shelf' => 'Shelf', - 'shelves' => 'Shelves', + 'shelf' => '本棚', + 'shelves' => '本棚', 'x_shelves' => ':count Shelf|:count Shelves', - 'shelves_long' => 'Bookshelves', + 'shelves_long' => '本棚', 'shelves_empty' => 'No shelves have been created', - 'shelves_create' => 'Create New Shelf', - 'shelves_popular' => 'Popular Shelves', - 'shelves_new' => 'New Shelves', - 'shelves_new_action' => 'New Shelf', + 'shelves_create' => '新しい本棚を作成', + 'shelves_popular' => '人気の本棚', + 'shelves_new' => '新しい本棚', + 'shelves_new_action' => '新しい本棚', 'shelves_popular_empty' => 'The most popular shelves will appear here.', 'shelves_new_empty' => 'The most recently created shelves will appear here.', - 'shelves_save' => 'Save Shelf', - 'shelves_books' => 'Books on this shelf', - 'shelves_add_books' => 'Add books to this shelf', - 'shelves_drag_books' => 'Drag books here to add them to this shelf', + 'shelves_save' => '本棚を保存', + 'shelves_books' => 'この本棚のブック', + 'shelves_add_books' => 'この本棚にブックを追加', + 'shelves_drag_books' => 'ブックをここにドラッグすると本棚に追加されます', 'shelves_empty_contents' => 'This shelf has no books assigned to it', 'shelves_edit_and_assign' => 'Edit shelf to assign books', - 'shelves_edit_named' => 'Edit Bookshelf :name', - 'shelves_edit' => 'Edit Bookshelf', - 'shelves_delete' => 'Delete Bookshelf', - 'shelves_delete_named' => 'Delete Bookshelf :name', - 'shelves_delete_explain' => "This will delete the bookshelf with the name ':name'. Contained books will not be deleted.", - 'shelves_delete_confirmation' => 'Are you sure you want to delete this bookshelf?', + 'shelves_edit_named' => '本棚「:name」を編集', + 'shelves_edit' => '本棚を編集', + 'shelves_delete' => '本棚を削除', + 'shelves_delete_named' => '本棚「:name」を削除', + 'shelves_delete_explain' => "これにより、この本棚「:name」が削除されます。含まれているブックは削除されません。", + 'shelves_delete_confirmation' => '本当にこの本棚を削除してよろしいですか?', 'shelves_permissions' => 'Bookshelf Permissions', 'shelves_permissions_updated' => 'Bookshelf Permissions Updated', 'shelves_permissions_active' => 'Bookshelf Permissions Active', @@ -106,14 +106,14 @@ return [ 'shelves_copy_permission_success' => 'Bookshelf permissions copied to :count books', // Books - 'book' => 'Book', + 'book' => 'ブック', 'books' => 'ブック', 'x_books' => ':count ブック', 'books_empty' => 'まだブックは作成されていません', 'books_popular' => '人気のブック', 'books_recent' => '最近のブック', 'books_new' => '新しいブック', - 'books_new_action' => 'New Book', + 'books_new_action' => '新しいブック', 'books_popular_empty' => 'ここに人気のブックが表示されます。', 'books_new_empty' => 'The most recently created books will appear here.', 'books_create' => '新しいブックを作成', @@ -135,12 +135,12 @@ return [ 'books_search_this' => 'このブックから検索', 'books_navigation' => '目次', 'books_sort' => '並び順を変更', - 'books_sort_named' => 'ブック「:bookName」を並び替え', - 'books_sort_name' => 'Sort by Name', - 'books_sort_created' => 'Sort by Created Date', - 'books_sort_updated' => 'Sort by Updated Date', - 'books_sort_chapters_first' => 'Chapters First', - 'books_sort_chapters_last' => 'Chapters Last', + 'books_sort_named' => 'ブック「:bookName」を並べ替え', + 'books_sort_name' => '名前で並べ替え', + 'books_sort_created' => '作成日で並べ替え', + 'books_sort_updated' => '更新日で並べ替え', + 'books_sort_chapters_first' => 'チャプターを先に', + 'books_sort_chapters_last' => 'チャプターを後に', 'books_sort_show_other' => '他のブックを表示', 'books_sort_save' => '並び順を保存', @@ -184,7 +184,7 @@ return [ 'pages_delete_confirm' => 'このページを削除してもよろしいですか?', 'pages_delete_draft_confirm' => 'このページの下書きを削除してもよろしいですか?', 'pages_editing_named' => 'ページ :pageName を編集', - 'pages_edit_draft_options' => 'Draft Options', + 'pages_edit_draft_options' => '下書きオプション', 'pages_edit_save_draft' => '下書きを保存', 'pages_edit_draft' => 'ページの下書きを編集', 'pages_editing_draft' => '下書きを編集中', @@ -202,13 +202,13 @@ return [ 'pages_md_preview' => 'プレビュー', 'pages_md_insert_image' => '画像を挿入', 'pages_md_insert_link' => 'エンティティへのリンクを挿入', - 'pages_md_insert_drawing' => 'Insert Drawing', + 'pages_md_insert_drawing' => '描画を追加', 'pages_not_in_chapter' => 'チャプターが設定されていません', 'pages_move' => 'ページを移動', 'pages_move_success' => 'ページを ":parentName" へ移動しました', - 'pages_copy' => 'Copy Page', - 'pages_copy_desination' => 'Copy Destination', - 'pages_copy_success' => 'Page successfully copied', + 'pages_copy' => 'ページをコピー', + 'pages_copy_desination' => 'コピー先', + 'pages_copy_success' => 'ページが正常にコピーされました', 'pages_permissions' => 'ページの権限設定', 'pages_permissions_success' => 'ページの権限を更新しました', 'pages_revision' => 'Revision', @@ -218,7 +218,7 @@ return [ 'pages_revision_restored_from' => 'Restored from #:id; :summary', 'pages_revisions_created_by' => '作成者', 'pages_revisions_date' => '日付', - 'pages_revisions_number' => 'リビジョン', + 'pages_revisions_number' => '#', 'pages_revisions_numbered' => 'Revision #:id', 'pages_revisions_numbered_changes' => 'Revision #:id Changes', 'pages_revisions_changelog' => '説明', @@ -243,21 +243,21 @@ return [ 'message' => ':start :time. 他のユーザによる更新を上書きしないよう注意してください。', ], 'pages_draft_discarded' => '下書きが破棄されました。エディタは現在の内容へ復元されています。', - 'pages_specific' => 'Specific Page', + 'pages_specific' => '特定のページ', 'pages_is_template' => 'Page Template', // Editor Sidebar 'page_tags' => 'タグ', - 'chapter_tags' => 'Chapter Tags', - 'book_tags' => 'Book Tags', - 'shelf_tags' => 'Shelf Tags', + 'chapter_tags' => 'チャプターのタグ', + 'book_tags' => 'ブックのタグ', + 'shelf_tags' => '本棚のタグ', 'tag' => 'タグ', - 'tags' => 'Tags', - 'tag_name' => 'Tag Name', + 'tags' => 'タグ', + 'tag_name' => 'タグの名前', 'tag_value' => '内容 (オプション)', 'tags_explain' => "タグを設定すると、コンテンツの管理が容易になります。\nより高度な管理をしたい場合、タグに内容を設定できます。", 'tags_add' => 'タグを追加', - 'tags_remove' => 'Remove this tag', + 'tags_remove' => 'このタグを削除', 'attachments' => '添付ファイル', 'attachments_explain' => 'ファイルをアップロードまたはリンクを添付することができます。これらはサイドバーで確認できます。', 'attachments_explain_instant_save' => 'この変更は即座に保存されます。', diff --git a/resources/lang/ja/errors.php b/resources/lang/ja/errors.php index 4d1776f12..526f10608 100644 --- a/resources/lang/ja/errors.php +++ b/resources/lang/ja/errors.php @@ -13,7 +13,7 @@ return [ 'email_already_confirmed' => 'Eメールは既に確認済みです。ログインしてください。', 'email_confirmation_invalid' => 'この確認トークンは無効か、または既に使用済みです。登録を再試行してください。', 'email_confirmation_expired' => '確認トークンは有効期限切れです。確認メールを再送しました。', - 'email_confirmation_awaiting' => 'The email address for the account in use needs to be confirmed', + 'email_confirmation_awaiting' => '使用中のアカウントのメールアドレスを確認する必要があります', 'ldap_fail_anonymous' => '匿名バインドを用いたLDAPアクセスに失敗しました', 'ldap_fail_authed' => '識別名, パスワードを用いたLDAPアクセスに失敗しました', 'ldap_extension_not_installed' => 'LDAP PHP extensionがインストールされていません', @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', 'saml_invalid_response_id' => 'The request from the external authentication system is not recognised by a process started by this application. Navigating back after a login could cause this issue.', 'saml_fail_authed' => 'Login using :system failed, system did not provide successful authorization', + 'oidc_already_logged_in' => '既にログインしています', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'アクションが定義されていません', 'social_login_bad_response' => "Error received during :socialAccount login: \n:error", 'social_account_in_use' => ':socialAccountアカウントは既に使用されています。:socialAccountのオプションからログインを試行してください。', @@ -33,7 +37,7 @@ return [ 'social_account_register_instructions' => 'まだアカウントをお持ちでない場合、:socialAccountオプションから登録できます。', 'social_driver_not_found' => 'Social driverが見つかりません。', 'social_driver_not_configured' => 'あなたの:socialAccount設定は正しく構成されていません。', - 'invite_token_expired' => 'This invitation link has expired. You can instead try to reset your account password.', + 'invite_token_expired' => 'この招待リンクの有効期限が切れています。 代わりにアカウントのパスワードをリセットしてみてください。', // System 'path_not_writable' => 'ファイルパス :filePath へアップロードできませんでした。サーバ上での書き込みが許可されているか確認してください。', @@ -42,7 +46,7 @@ return [ 'server_upload_limit' => 'このサイズの画像をアップロードすることは許可されていません。ファイルサイズを小さくし、再試行してください。', 'uploaded' => 'The server does not allow uploads of this size. Please try a smaller file size.', 'image_upload_error' => '画像アップロード時にエラーが発生しました。', - 'image_upload_type_error' => 'The image type being uploaded is invalid', + 'image_upload_type_error' => 'アップロード中の画像の種類が無効です', 'file_upload_timeout' => 'ファイルのアップロードがタイムアウトしました。', // Attachments @@ -50,11 +54,11 @@ return [ // Pages 'page_draft_autosave_fail' => '下書きの保存に失敗しました。インターネットへ接続してください。', - 'page_custom_home_deletion' => 'Cannot delete a page while it is set as a homepage', + 'page_custom_home_deletion' => 'ホームページに設定されているページは削除できません', // Entities 'entity_not_found' => 'エンティティが見つかりません', - 'bookshelf_not_found' => 'Bookshelf not found', + 'bookshelf_not_found' => '本棚が見つかりません', 'book_not_found' => 'ブックが見つかりません', 'page_not_found' => 'ページが見つかりません', 'chapter_not_found' => 'チャプターが見つかりません', @@ -82,10 +86,10 @@ return [ // Error pages '404_page_not_found' => 'ページが見つかりません', 'sorry_page_not_found' => 'ページを見つけることができませんでした。', - 'sorry_page_not_found_permission_warning' => 'If you expected this page to exist, you might not have permission to view it.', - 'image_not_found' => 'Image Not Found', - 'image_not_found_subtitle' => 'Sorry, The image file you were looking for could not be found.', - 'image_not_found_details' => 'If you expected this image to exist it might have been deleted.', + 'sorry_page_not_found_permission_warning' => 'このページが存在すると思われる場合は、閲覧の権限がない可能性があります。', + 'image_not_found' => '画像が見つかりません', + 'image_not_found_subtitle' => '画像を見つけることができませんでした。', + 'image_not_found_details' => 'この画像が存在することを予期していた場合は、削除された可能性があります。', 'return_home' => 'ホームに戻る', 'error_occurred' => 'エラーが発生しました', 'app_down' => ':appNameは現在停止しています', diff --git a/resources/lang/ja/passwords.php b/resources/lang/ja/passwords.php index e92a35500..cf4d43114 100644 --- a/resources/lang/ja/passwords.php +++ b/resources/lang/ja/passwords.php @@ -8,7 +8,7 @@ return [ 'password' => 'パスワードは6文字以上である必要があります。', 'user' => "このEメールアドレスに一致するユーザが見つかりませんでした。", - 'token' => 'The password reset token is invalid for this email address.', + 'token' => 'このメールアドレスのパスワードリセットトークンは無効です。', 'sent' => 'パスワードリセットリンクを送信しました。', 'reset' => 'パスワードはリセットされました。', diff --git a/resources/lang/ja/settings.php b/resources/lang/ja/settings.php index c769174e7..b604270ec 100644 --- a/resources/lang/ja/settings.php +++ b/resources/lang/ja/settings.php @@ -29,19 +29,19 @@ return [ 'app_editor_desc' => 'ここで選択されたエディタを全ユーザが使用します。', 'app_custom_html' => 'カスタムheadタグ', 'app_custom_html_desc' => 'スタイルシートやアナリティクスコード追加したい場合、ここを編集します。これはの最下部に挿入されます。', - 'app_custom_html_disabled_notice' => 'Custom HTML head content is disabled on this settings page to ensure any breaking changes can be reverted.', + 'app_custom_html_disabled_notice' => '重大な変更を元に戻せるよう、この設定ページではカスタムのHTML headコンテンツが無効になっています。', 'app_logo' => 'ロゴ', 'app_logo_desc' => '高さ43pxで表示されます。これを上回る場合、自動で縮小されます。', 'app_primary_color' => 'プライマリカラー', 'app_primary_color_desc' => '16進数カラーコードで入力します。空にした場合、デフォルトの色にリセットされます。', - 'app_homepage' => 'Application Homepage', - 'app_homepage_desc' => 'Select a view to show on the homepage instead of the default view. Page permissions are ignored for selected pages.', + 'app_homepage' => 'アプリケーションのホームページ', + 'app_homepage_desc' => 'デフォルトのビューの代わりにホームページに表示するビューを選択します。選択したページの権限は無視されます。', 'app_homepage_select' => 'ページを選択', 'app_footer_links' => 'フッタのリンク', - 'app_footer_links_desc' => 'Add links to show within the site footer. These will be displayed at the bottom of most pages, including those that do not require login. You can use a label of "trans::" to use system-defined translations. For example: Using "trans::common.privacy_policy" will provide the translated text "Privacy Policy" and "trans::common.terms_of_service" will provide the translated text "Terms of Service".', + 'app_footer_links_desc' => 'サイトフッタ内に表示するリンクを追加します。これらはログインを必要としないページを含め、ほとんどのページの下部に表示されます。「trans::」のラベルを使用して、システム定義の翻訳を使用できます。例えば「trans::common.privacy_policy」を使用すると翻訳されたテキスト「プライバシーポリシー」が提供され、「trans::common.terms_of_service」を使用すると翻訳されたテキスト「利用規約」が提供されます。', 'app_footer_links_label' => '表示するテキスト', 'app_footer_links_url' => 'リンク先の URL', - 'app_footer_links_add' => 'Add Footer Link', + 'app_footer_links_add' => 'フッタのリンクを追加', 'app_disable_comments' => 'コメントを無効にする', 'app_disable_comments_toggle' => 'コメントを無効にする', 'app_disable_comments_desc' => 'アプリケーション内のすべてのページのコメントを無効にします。既存のコメントは表示されません。', @@ -49,17 +49,17 @@ return [ // Color settings 'content_colors' => 'コンテンツの色', 'content_colors_desc' => 'ページ構成階層のすべての要素に色を設定します。読みやすさを考慮して、デフォルトの色と同じような明るさの色を選ぶことをお勧めします。', - 'bookshelf_color' => 'Shelf Color', - 'book_color' => 'Book Color', - 'chapter_color' => 'Chapter Color', - 'page_color' => 'Page Color', - 'page_draft_color' => 'Page Draft Color', + 'bookshelf_color' => '本棚の色', + 'book_color' => 'ブックの色', + 'chapter_color' => 'チャプターの色', + 'page_color' => 'ページの色', + 'page_draft_color' => '下書きページの色', // Registration Settings 'reg_settings' => '登録設定', 'reg_enable' => '登録を有効にする', 'reg_enable_toggle' => '登録を有効にする', - 'reg_enable_desc' => 'When registration is enabled user will be able to sign themselves up as an application user. Upon registration they are given a single, default user role.', + 'reg_enable_desc' => '登録を有効にすると、ユーザーはアプリケーションユーザーとしてサインアップできるようになります。登録するとデフォルトの役割が1つ与えられます。', 'reg_default_role' => '新規登録時のデフォルト役割', 'reg_enable_external_warning' => '外部のLDAPまたはSAML認証が有効の場合、上記のオプションは無視されます。存在しないメンバーのユーザーアカウントは、使用している外部システムでの認証に成功した場合に自動的に作成されます。', 'reg_email_confirmation' => '確認メール', @@ -71,58 +71,58 @@ return [ // Maintenance settings 'maint' => 'メンテナンス', - 'maint_image_cleanup' => 'Cleanup Images', + 'maint_image_cleanup' => '画像のクリーンアップ', 'maint_image_cleanup_desc' => "ページや履歴の内容をスキャンして、どの画像や図面が現在使用されているか、どの画像が余っているかをチェックします。この機能を実行する前に、データベースと画像の完全なバックアップを作成してください。", 'maint_delete_images_only_in_revisions' => 'また、古いページのリビジョンにしか存在しない画像も削除します。', 'maint_image_cleanup_run' => 'クリーンアップを実行', 'maint_image_cleanup_warning' => ':count 個、使用されていない可能性のある画像が見つかりました。これらの画像を削除してもよろしいですか?', - 'maint_image_cleanup_success' => ':count potentially unused images found and deleted!', - 'maint_image_cleanup_nothing_found' => 'No unused images found, Nothing deleted!', + 'maint_image_cleanup_success' => '使われていない可能性のある画像を:count個発見し、削除しました。', + 'maint_image_cleanup_nothing_found' => '未使用の画像がないため、何も削除しませんでした。', 'maint_send_test_email' => 'テストメールを送信', - 'maint_send_test_email_desc' => 'This sends a test email to your email address specified in your profile.', - 'maint_send_test_email_run' => 'Send test email', - 'maint_send_test_email_success' => 'Email sent to :address', + 'maint_send_test_email_desc' => 'プロフィールに指定されたメールアドレスにテストメールを送信します。', + 'maint_send_test_email_run' => 'テストメールを送信', + 'maint_send_test_email_success' => ':addressにメールを送信しました', 'maint_send_test_email_mail_subject' => 'テストメール', - 'maint_send_test_email_mail_greeting' => 'Email delivery seems to work!', - 'maint_send_test_email_mail_text' => 'Congratulations! As you received this email notification, your email settings seem to be configured properly.', - 'maint_recycle_bin_desc' => 'Deleted shelves, books, chapters & pages are sent to the recycle bin so they can be restored or permanently deleted. Older items in the recycle bin may be automatically removed after a while depending on system configuration.', - 'maint_recycle_bin_open' => 'Open Recycle Bin', + 'maint_send_test_email_mail_greeting' => 'メール配信は正常に動作しているようです!', + 'maint_send_test_email_mail_text' => 'おめでとうございます!この通知メールが届いたということは、あなたのメール設定は適切であると思われます。', + 'maint_recycle_bin_desc' => '削除された本棚・ブック・チャプター・ページはごみ箱に送られるため、復元したり完全に削除したりできます。システムの設定によっては、ごみ箱の古いアイテムがしばらくすると自動的に削除される場合があります。', + 'maint_recycle_bin_open' => 'ごみ箱を開く', // Recycle Bin - 'recycle_bin' => 'Recycle Bin', - 'recycle_bin_desc' => 'Here you can restore items that have been deleted or choose to permanently remove them from the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.', - 'recycle_bin_deleted_item' => 'Deleted Item', - 'recycle_bin_deleted_parent' => 'Parent', - 'recycle_bin_deleted_by' => 'Deleted By', - 'recycle_bin_deleted_at' => 'Deletion Time', - 'recycle_bin_permanently_delete' => 'Permanently Delete', - 'recycle_bin_restore' => 'Restore', - 'recycle_bin_contents_empty' => 'The recycle bin is currently empty', - 'recycle_bin_empty' => 'Empty Recycle Bin', - 'recycle_bin_empty_confirm' => 'This will permanently destroy all items in the recycle bin including content contained within each item. Are you sure you want to empty the recycle bin?', - 'recycle_bin_destroy_confirm' => 'This action will permanently delete this item, along with any child elements listed below, from the system and you will not be able to restore this content. Are you sure you want to permanently delete this item?', - 'recycle_bin_destroy_list' => 'Items to be Destroyed', - 'recycle_bin_restore_list' => 'Items to be Restored', - 'recycle_bin_restore_confirm' => 'This action will restore the deleted item, including any child elements, to their original location. If the original location has since been deleted, and is now in the recycle bin, the parent item will also need to be restored.', + 'recycle_bin' => 'ごみ箱', + 'recycle_bin_desc' => '削除されたアイテムを復元するか、システムから完全に削除できます。このリストは、権限フィルターが適用されているシステム内の同様のアクティビティリストとは異なり、フィルタリングされていません。', + 'recycle_bin_deleted_item' => '削除されたアイテム', + 'recycle_bin_deleted_parent' => '親', + 'recycle_bin_deleted_by' => '削除した人', + 'recycle_bin_deleted_at' => '削除日時', + 'recycle_bin_permanently_delete' => '完全に削除', + 'recycle_bin_restore' => '復元', + 'recycle_bin_contents_empty' => 'ごみ箱は現在空です', + 'recycle_bin_empty' => 'ごみ箱を空にする', + 'recycle_bin_empty_confirm' => 'ごみ箱のすべてのアイテムが、各アイテムに含まれるコンテンツも含めて完全に削除されます。本当にごみ箱を空にしますか?', + 'recycle_bin_destroy_confirm' => 'この操作により、このアイテムと以下にリストされている子要素がシステムから完全に削除され、このコンテンツを復元できなくなります。このアイテムを完全に削除してもよろしいですか?', + 'recycle_bin_destroy_list' => '削除されるアイテム', + 'recycle_bin_restore_list' => '復元されるアイテム', + 'recycle_bin_restore_confirm' => 'この操作により、すべての子要素を含む削除されたアイテムが元の場所に復元されます。元の場所が削除されてごみ箱に入っている場合は、親アイテムも復元する必要があります。', 'recycle_bin_restore_deleted_parent' => 'The parent of this item has also been deleted. These will remain deleted until that parent is also restored.', - 'recycle_bin_restore_parent' => 'Restore Parent', - 'recycle_bin_destroy_notification' => 'Deleted :count total items from the recycle bin.', - 'recycle_bin_restore_notification' => 'Restored :count total items from the recycle bin.', + 'recycle_bin_restore_parent' => '親を復元', + 'recycle_bin_destroy_notification' => 'ごみ箱から合計:count個のアイテムを削除しました。', + 'recycle_bin_restore_notification' => 'ごみ箱から合計:count個のアイテムを復元しました。', // Audit Log - 'audit' => 'Audit Log', - 'audit_desc' => 'This audit log displays a list of activities tracked in the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.', - 'audit_event_filter' => 'Event Filter', - 'audit_event_filter_no_filter' => 'No Filter', - 'audit_deleted_item' => 'Deleted Item', - 'audit_deleted_item_name' => 'Name: :name', - 'audit_table_user' => 'User', - 'audit_table_event' => 'Event', - 'audit_table_related' => 'Related Item or Detail', - 'audit_table_ip' => 'IP Address', - 'audit_table_date' => 'Activity Date', - 'audit_date_from' => 'Date Range From', - 'audit_date_to' => 'Date Range To', + 'audit' => '監査ログ', + 'audit_desc' => 'この監査ログには、システムで追跡されているアクティビティのリストが表示されます。このリストは、権限フィルターが適用されているシステム内の同様のアクティビティリストとは異なり、フィルタリングされていません。', + 'audit_event_filter' => 'イベントフィルター', + 'audit_event_filter_no_filter' => 'フィルターなし', + 'audit_deleted_item' => '削除されたアイテム', + 'audit_deleted_item_name' => '名前: :name', + 'audit_table_user' => 'ユーザー', + 'audit_table_event' => 'イベント', + 'audit_table_related' => '関連アイテムまたは詳細', + 'audit_table_ip' => 'IPアドレス', + 'audit_table_date' => 'アクティビティの日時', + 'audit_date_from' => '開始日', + 'audit_date_to' => '終了日', // Role Settings 'roles' => '役割', @@ -139,99 +139,99 @@ return [ 'role_details' => '概要', 'role_name' => '役割名', 'role_desc' => '役割の説明', - 'role_mfa_enforced' => 'Requires Multi-Factor Authentication', + 'role_mfa_enforced' => '多要素認証を要求する', 'role_external_auth_id' => 'External Authentication IDs', 'role_system' => 'システム権限', 'role_manage_users' => 'ユーザ管理', 'role_manage_roles' => '役割と権限の管理', 'role_manage_entity_permissions' => '全てのブック, チャプター, ページに対する権限の管理', 'role_manage_own_entity_permissions' => '自身のブック, チャプター, ページに対する権限の管理', - 'role_manage_page_templates' => 'Manage page templates', - 'role_access_api' => 'Access system API', + 'role_manage_page_templates' => 'ページテンプレートの管理', + 'role_access_api' => 'システムのAPIへのアクセス', 'role_manage_settings' => 'アプリケーション設定の管理', - 'role_export_content' => 'Export content', + 'role_export_content' => 'コンテンツのエクスポート', 'role_asset' => 'アセット権限', - 'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.', + 'roles_system_warning' => '上記の3つの権限のいずれかを付与することは、ユーザーが自分の特権またはシステム内の他のユーザーの特権を変更できる可能性があることに注意してください。これらの権限は信頼できるユーザーにのみ割り当ててください。', 'role_asset_desc' => '各アセットに対するデフォルトの権限を設定します。ここで設定した権限が優先されます。', - 'role_asset_admins' => 'Admins are automatically given access to all content but these options may show or hide UI options.', + 'role_asset_admins' => '管理者にはすべてのコンテンツへのアクセス権が自動的に付与されますが、これらのオプションはUIオプションを表示または非表示にする場合があります。', 'role_all' => '全て', 'role_own' => '自身', 'role_controlled_by_asset' => 'このアセットに対し、右記の操作を許可:', 'role_save' => '役割を保存', 'role_update_success' => '役割を更新しました', - 'role_users' => 'この役割を持つユーザ', - 'role_users_none' => 'この役割が付与されたユーザは居ません', + 'role_users' => 'この役割を持つユーザー', + 'role_users_none' => 'この役割が付与されたユーザーはいません', // Users - 'users' => 'ユーザ', + 'users' => 'ユーザー', 'user_profile' => 'ユーザプロフィール', - 'users_add_new' => 'ユーザを追加', - 'users_search' => 'ユーザ検索', - 'users_latest_activity' => 'Latest Activity', - 'users_details' => 'User Details', - 'users_details_desc' => 'Set a display name and an email address for this user. The email address will be used for logging into the application.', - 'users_details_desc_no_email' => 'Set a display name for this user so others can recognise them.', - 'users_role' => 'ユーザ役割', - 'users_role_desc' => 'Select which roles this user will be assigned to. If a user is assigned to multiple roles the permissions from those roles will stack and they will receive all abilities of the assigned roles.', + 'users_add_new' => 'ユーザーを追加', + 'users_search' => 'ユーザー検索', + 'users_latest_activity' => '最新のアクティビティ', + 'users_details' => 'ユーザーの詳細', + 'users_details_desc' => 'このユーザーの表示名とメールアドレスを設定します。メールアドレスは、アプリケーションへのログインに使用されます。', + 'users_details_desc_no_email' => 'このユーザーの表示名を設定して、他のユーザーが認識できるようにします。', + 'users_role' => 'ユーザーの役割', + 'users_role_desc' => 'このユーザーに割り当てる役割を選択します。ユーザーが複数の役割に割り当てられている場合は、それらの役割の権限が重ね合わされ、割り当てられた役割のすべての権限が与えられます。', 'users_password' => 'ユーザー パスワード', - 'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 6 characters long.', - 'users_send_invite_text' => 'You can choose to send this user an invitation email which allows them to set their own password otherwise you can set their password yourself.', - 'users_send_invite_option' => 'Send user invite email', + 'users_password_desc' => 'アプリケーションへのログインに使用するパスワードを設定します。これは少なくとも6文字以上である必要があります。', + 'users_send_invite_text' => 'このユーザーに招待メールを送信してユーザー自身にパスワードを設定してもらうか、あなたがここでパスワードを設定するかを選択できます。', + 'users_send_invite_option' => 'ユーザーに招待メールを送信', 'users_external_auth_id' => '外部認証ID', 'users_external_auth_id_desc' => 'This is the ID used to match this user when communicating with your external authentication system.', 'users_password_warning' => 'パスワードを変更したい場合のみ入力してください', - 'users_system_public' => 'このユーザはアプリケーションにアクセスする全てのゲストを表します。ログインはできませんが、自動的に割り当てられます。', + 'users_system_public' => 'このユーザーはアプリケーションにアクセスする全てのゲストを表します。ログインはできませんが、自動的に割り当てられます。', 'users_delete' => 'ユーザを削除', 'users_delete_named' => 'ユーザ「:userName」を削除', 'users_delete_warning' => 'ユーザ「:userName」を完全に削除します。', 'users_delete_confirm' => '本当にこのユーザを削除してよろしいですか?', - 'users_migrate_ownership' => 'Migrate Ownership', - 'users_migrate_ownership_desc' => 'Select a user here if you want another user to become the owner of all items currently owned by this user.', - 'users_none_selected' => 'No user selected', - 'users_delete_success' => 'User successfully removed', - 'users_edit' => 'ユーザ編集', + 'users_migrate_ownership' => '所有権を移行', + 'users_migrate_ownership_desc' => '別のユーザーをこのユーザーが現在所有しているすべてのアイテムの所有者にする場合は、ここでユーザーを選択します。', + 'users_none_selected' => 'ユーザが選択されていません', + 'users_delete_success' => 'ユーザーを正常に削除しました', + 'users_edit' => 'ユーザー編集', 'users_edit_profile' => 'プロフィール編集', 'users_edit_success' => 'ユーザを更新しました', 'users_avatar' => 'アバター', 'users_avatar_desc' => '256pxの正方形である必要があります。', 'users_preferred_language' => '使用言語', - 'users_preferred_language_desc' => 'This option will change the language used for the user-interface of the application. This will not affect any user-created content.', + 'users_preferred_language_desc' => 'このオプションは、アプリケーションのユーザーインターフェイスに使用される言語を変更します。これは、ユーザーが作成したコンテンツには影響しません。', 'users_social_accounts' => 'ソーシャルアカウント', 'users_social_accounts_info' => 'アカウントを接続すると、ログインが簡単になります。ここでアカウントの接続を解除すると、そのアカウントを経由したログインを禁止できます。接続解除後、各ソーシャルアカウントの設定にてこのアプリケーションへのアクセス許可を解除してください。', 'users_social_connect' => 'アカウントを接続', 'users_social_disconnect' => 'アカウントを接続解除', 'users_social_connected' => '「:socialAccount」がプロフィールに接続されました。', 'users_social_disconnected' => '「:socialAccount」がプロフィールから接続解除されました。', - 'users_api_tokens' => 'API Tokens', - 'users_api_tokens_none' => 'No API tokens have been created for this user', - 'users_api_tokens_create' => 'Create Token', - 'users_api_tokens_expires' => 'Expires', - 'users_api_tokens_docs' => 'API Documentation', - 'users_mfa' => 'Multi-Factor Authentication', - 'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.', - 'users_mfa_x_methods' => ':count method configured|:count methods configured', - 'users_mfa_configure' => 'Configure Methods', + 'users_api_tokens' => 'APIトークン', + 'users_api_tokens_none' => 'このユーザーにはAPIトークンが作成されていません', + 'users_api_tokens_create' => 'トークンを作成', + 'users_api_tokens_expires' => '有効期限', + 'users_api_tokens_docs' => 'APIドキュメント', + 'users_mfa' => '多要素認証', + 'users_mfa_desc' => 'アカウントのセキュリティを強化するために、多要素認証を設定してください。', + 'users_mfa_x_methods' => ':count個の手段が設定されています|:count個の手段が設定されています', + 'users_mfa_configure' => '手段を設定', // API Tokens - 'user_api_token_create' => 'Create API Token', - 'user_api_token_name' => 'Name', - 'user_api_token_name_desc' => 'Give your token a readable name as a future reminder of its intended purpose.', - 'user_api_token_expiry' => 'Expiry Date', - 'user_api_token_expiry_desc' => 'Set a date at which this token expires. After this date, requests made using this token will no longer work. Leaving this field blank will set an expiry 100 years into the future.', - 'user_api_token_create_secret_message' => 'Immediately after creating this token a "Token ID" & "Token Secret" will be generated and displayed. The secret will only be shown a single time so be sure to copy the value to somewhere safe and secure before proceeding.', - 'user_api_token_create_success' => 'API token successfully created', - 'user_api_token_update_success' => 'API token successfully updated', - 'user_api_token' => 'API Token', - 'user_api_token_id' => 'Token ID', - 'user_api_token_id_desc' => 'This is a non-editable system generated identifier for this token which will need to be provided in API requests.', - 'user_api_token_secret' => 'Token Secret', - 'user_api_token_secret_desc' => 'This is a system generated secret for this token which will need to be provided in API requests. This will only be displayed this one time so copy this value to somewhere safe and secure.', - 'user_api_token_created' => 'Token created :timeAgo', - 'user_api_token_updated' => 'Token updated :timeAgo', - 'user_api_token_delete' => 'Delete Token', - 'user_api_token_delete_warning' => 'This will fully delete this API token with the name \':tokenName\' from the system.', - 'user_api_token_delete_confirm' => 'Are you sure you want to delete this API token?', - 'user_api_token_delete_success' => 'API token successfully deleted', + 'user_api_token_create' => 'APIトークンの作成', + 'user_api_token_name' => '名前', + 'user_api_token_name_desc' => '利用目的を忘れないよう、トークンに読みやすい名前を付けます。', + 'user_api_token_expiry' => '有効期限', + 'user_api_token_expiry_desc' => 'このトークンの有効期限が切れる日付を設定します。この日付を過ぎると、このトークンを使用したリクエストは機能しなくなります。このフィールドを空白のままにすると、100年先に有効期限が設定されます。', + 'user_api_token_create_secret_message' => 'このトークンを作成するとすぐに、「トークンID」と「トークンシークレット」が生成されて表示されます。シークレットは一度しか表示されないため、続行する前に必ず値を安全な場所にコピーしてください。', + 'user_api_token_create_success' => 'APIトークンが正常に作成されました', + 'user_api_token_update_success' => 'APIトークンが正常に更新されました', + 'user_api_token' => 'APIトークン', + 'user_api_token_id' => 'トークンID', + 'user_api_token_id_desc' => 'これは、システムが生成した編集不可能なトークンの識別子で、APIリクエストで提供する必要があります。', + 'user_api_token_secret' => 'トークンシークレット', + 'user_api_token_secret_desc' => 'これは、システムで生成されたトークンシークレットであり、APIリクエストで提供する必要があります。これは一度しか表示されないので、この値を安全な場所にコピーしてください。', + 'user_api_token_created' => 'トークンの作成: :timeAgo', + 'user_api_token_updated' => 'トークンの更新: :timeAgo', + 'user_api_token_delete' => 'トークンを削除', + 'user_api_token_delete_warning' => 'これにより、このAPIトークン「:tokenName」がシステムから完全に削除されます。', + 'user_api_token_delete_confirm' => 'このAPIトークンを削除してもよろしいですか?', + 'user_api_token_delete_success' => 'APIトークンが正常に削除されました', //! If editing translations files directly please ignore this in all //! languages apart from en. Content will be auto-copied from en. diff --git a/resources/lang/ja/validation.php b/resources/lang/ja/validation.php index 2ef8b0119..f8a7f1326 100644 --- a/resources/lang/ja/validation.php +++ b/resources/lang/ja/validation.php @@ -15,7 +15,7 @@ return [ 'alpha_dash' => ':attributeは文字, 数値, ハイフンのみが含められます。', 'alpha_num' => ':attributeは文字と数値のみが含められます。', 'array' => ':attributeは配列である必要があります。', - 'backup_codes' => 'The provided code is not valid or has already been used.', + 'backup_codes' => '提供されたコードは無効か、またはすでに使用されています。', 'before' => ':attributeは:date以前である必要があります。', 'between' => [ 'numeric' => ':attributeは:min〜:maxである必要があります。', @@ -31,40 +31,40 @@ return [ 'digits' => ':attributeは:digitsデジットである必要があります', 'digits_between' => ':attributeは:min〜:maxである必要があります。', 'email' => ':attributeは正しいEメールアドレスである必要があります。', - 'ends_with' => 'The :attribute must end with one of the following: :values', + 'ends_with' => ':attributeは:valuesのいずれかで終わる必要があります。', 'filled' => ':attributeは必須です。', 'gt' => [ - 'numeric' => 'The :attribute must be greater than :value.', - 'file' => 'The :attribute must be greater than :value kilobytes.', - 'string' => 'The :attribute must be greater than :value characters.', - 'array' => 'The :attribute must have more than :value items.', + 'numeric' => ':attributeは:valueより大きな値である必要があります。', + 'file' => ':attributeは:valueキロバイトより大きなファイルである必要があります。', + 'string' => ':attributeは:value文字より長い必要があります。', + 'array' => ':attributeには:value個より多くのアイテムを指定する必要があります。', ], 'gte' => [ - 'numeric' => 'The :attribute must be greater than or equal :value.', - 'file' => 'The :attribute must be greater than or equal :value kilobytes.', - 'string' => 'The :attribute must be greater than or equal :value characters.', - 'array' => 'The :attribute must have :value items or more.', + 'numeric' => ':attributeは:value以上の値である必要があります。', + 'file' => ':attributeは:valueキロバイト以上のファイルである必要があります。', + 'string' => ':attributeは:value文字以上である必要があります。', + 'array' => ':attributeには:value個以上のアイテムを指定する必要があります。', ], 'exists' => '選択された:attributeは不正です。', 'image' => ':attributeは画像である必要があります。', - 'image_extension' => 'The :attribute must have a valid & supported image extension.', + 'image_extension' => ':attributeは有効かつサポートされている拡張子の画像である必要があります。', 'in' => '選択された:attributeは不正です。', 'integer' => ':attributeは数値である必要があります。', 'ip' => ':attributeは正しいIPアドレスである必要があります。', - 'ipv4' => 'The :attribute must be a valid IPv4 address.', - 'ipv6' => 'The :attribute must be a valid IPv6 address.', - 'json' => 'The :attribute must be a valid JSON string.', + 'ipv4' => ':attributeは有効なIPv4アドレスである必要があります。', + 'ipv6' => ':attributeは有効なIPv6アドレスである必要があります。', + 'json' => ':attributeは有効なJSON文字列である必要があります。', 'lt' => [ - 'numeric' => 'The :attribute must be less than :value.', - 'file' => 'The :attribute must be less than :value kilobytes.', - 'string' => 'The :attribute must be less than :value characters.', - 'array' => 'The :attribute must have less than :value items.', + 'numeric' => ':attributeは:valueより小さな値である必要があります。', + 'file' => ':attributeは:valueキロバイトより小さなファイルである必要があります。', + 'string' => ':attributeは:value文字より短い必要があります。', + 'array' => ':attributeには:value個より少ないアイテムを指定する必要があります。', ], 'lte' => [ - 'numeric' => 'The :attribute must be less than or equal :value.', - 'file' => 'The :attribute must be less than or equal :value kilobytes.', - 'string' => 'The :attribute must be less than or equal :value characters.', - 'array' => 'The :attribute must not have more than :value items.', + 'numeric' => ':attributeは:value以下の値である必要があります。', + 'file' => ':attributeは:valueキロバイト以下のファイルである必要があります。', + 'string' => ':attributeは:value文字以下である必要があります。', + 'array' => ':attributeには:value個以下のアイテムを指定する必要があります。', ], 'max' => [ 'numeric' => ':attributeは:maxを越えることができません。', @@ -80,7 +80,7 @@ return [ 'array' => ':attributeは:min個以上である必要があります。', ], 'not_in' => '選択された:attributeは不正です。', - 'not_regex' => 'The :attribute format is invalid.', + 'not_regex' => ':attributeの形式は不正です。', 'numeric' => ':attributeは数値である必要があります。', 'regex' => ':attributeのフォーマットは不正です。', 'required' => ':attributeは必須です。', @@ -90,7 +90,7 @@ return [ 'required_without' => ':valuesが設定されていない場合、:attributeは必須です。', 'required_without_all' => ':valuesが設定されていない場合、:attributeは必須です。', 'same' => ':attributeと:otherは一致している必要があります。', - 'safe_url' => 'The provided link may not be safe.', + 'safe_url' => '提供されたリンクは安全ではない可能性があります。', 'size' => [ 'numeric' => ':attributeは:sizeである必要があります。', 'file' => ':attributeは:sizeキロバイトである必要があります。', @@ -99,10 +99,10 @@ return [ ], 'string' => ':attributeは文字列である必要があります。', 'timezone' => ':attributeは正しいタイムゾーンである必要があります。', - 'totp' => 'The provided code is not valid or has expired.', + 'totp' => '提供されたコードが無効または期限切れです。', 'unique' => ':attributeは既に使用されています。', 'url' => ':attributeのフォーマットは不正です。', - 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', + 'uploaded' => 'ファイルをアップロードできませんでした。サーバーがこのサイズのファイルを受け付けていない可能性があります。', // Custom validation lines 'custom' => [ diff --git a/resources/lang/ko/errors.php b/resources/lang/ko/errors.php index b2a2c7a3a..b52ee91bc 100644 --- a/resources/lang/ko/errors.php +++ b/resources/lang/ko/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => '이 사용자에 대하여 외부 인증시스템에 의해 제공된 데이타 중 이메일 주소를 찾을 수 없습니다.', 'saml_invalid_response_id' => '이 응용프로그램에 의해 시작된 프로세스에 의하면 외부 인증시스템으로 온 요청이 인식되지 않습니다. 인증 후에 뒤로가기 기능을 사용했을 경우 이런 현상이 발생할 수 있습니다.', 'saml_fail_authed' => '시스템 로그인에 실패하였습니다. ( 해당 시스템이 인증성공값을 제공하지 않았습니다. )', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => '무슨 활동인지 알 수 없습니다.', 'social_login_bad_response' => ":socialAccount에 로그인할 수 없습니다. : \\n:error", 'social_account_in_use' => ':socialAccount(을)를 가진 사용자가 있습니다. :socialAccount로 로그인하세요.', diff --git a/resources/lang/lt/errors.php b/resources/lang/lt/errors.php index c16c37a9b..1ceeb03e1 100644 --- a/resources/lang/lt/errors.php +++ b/resources/lang/lt/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'Nerandamas šio naudotojo elektroninio pašto adresas išorinės autentifikavimo sistemos pateiktuose duomenyse', 'saml_invalid_response_id' => 'Prašymas iš išorinės autentifikavimo sistemos nėra atpažintas proceso, kurį pradėjo ši programa. Naršymas po prisijungimo gali sukelti šią problemą.', 'saml_fail_authed' => 'Prisijungimas, naudojant :system nepavyko, sistema nepateikė sėkmingo leidimo.', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'Neapibrėžtas joks veiksmas', 'social_login_bad_response' => "Error received during :socialAccount login: \n:error", 'social_account_in_use' => 'Ši :socialAccount paskyra jau yra naudojama, pabandykite prisijungti per :socialAccount pasirinkimą.', diff --git a/resources/lang/lv/errors.php b/resources/lang/lv/errors.php index c1746d65a..8c72da976 100644 --- a/resources/lang/lv/errors.php +++ b/resources/lang/lv/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'Ārējās autentifikācijas sistēmas sniegtajos datos nevarēja atrast šī lietotāja e-pasta adresi', 'saml_invalid_response_id' => 'Ārējās autentifikācijas sistēmas pieprasījums neatpazīst procesu, kuru sākusi šī lietojumprogramma. Pārvietojoties atpakaļ pēc pieteikšanās var rasties šāda problēma.', 'saml_fail_authed' => 'Piekļuve ar :system neizdevās, sistēma nepieļāva veiksmīgu autorizāciju', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'Darbības nav definētas', 'social_login_bad_response' => "Saņemta kļūda izmantojot :socialAccount piekļuvi:\n:error", 'social_account_in_use' => 'Šis :socialAccount konts jau tiek izmantots, mēģiniet ieiet ar :socialAccount piekļuves iespēju.', diff --git a/resources/lang/nb/errors.php b/resources/lang/nb/errors.php index 4713be3a4..d6f0288b9 100644 --- a/resources/lang/nb/errors.php +++ b/resources/lang/nb/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'Denne kontoinformasjonen finnes ikke i det eksterne autentiseringssystemet.', 'saml_invalid_response_id' => 'Forespørselen fra det eksterne autentiseringssystemet gjenkjennes ikke av en prosess som startes av dette programmet. Å navigere tilbake etter pålogging kan forårsake dette problemet.', 'saml_fail_authed' => 'Innlogging gjennom :system feilet. Fikk ikke kontakt med autentiseringstjeneren.', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'Ingen handlinger er definert', 'social_login_bad_response' => "Feilmelding mottat fra :socialAccount innloggingstjeneste: \n:error", 'social_account_in_use' => 'Denne :socialAccount kontoen er allerede registrert, Prøv å logge inn med :socialAccount alternativet.', diff --git a/resources/lang/nl/errors.php b/resources/lang/nl/errors.php index 751831473..9bca1d87d 100644 --- a/resources/lang/nl/errors.php +++ b/resources/lang/nl/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'Kan geen e-mailadres voor deze gebruiker vinden in de gegevens die door het externe verificatiesysteem worden verstrekt', 'saml_invalid_response_id' => 'Het verzoek van het externe verificatiesysteem is niet herkend door een door deze applicatie gestart proces. Het terug navigeren na een login kan dit probleem veroorzaken.', 'saml_fail_authed' => 'Inloggen met :system mislukt, het systeem gaf geen succesvolle autorisatie', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'Geen actie gedefineërd', 'social_login_bad_response' => "Fout ontvangen tijdens :socialAccount login: \n:error", 'social_account_in_use' => 'Dit :socialAccount account is al in gebruik, Probeer in te loggen met de :socialAccount optie.', diff --git a/resources/lang/pl/auth.php b/resources/lang/pl/auth.php index d2439f2d3..a1f66fdbe 100644 --- a/resources/lang/pl/auth.php +++ b/resources/lang/pl/auth.php @@ -76,37 +76,37 @@ return [ 'user_invite_success' => 'Hasło zostało ustawione, teraz masz dostęp do :appName!', // Multi-factor Authentication - 'mfa_setup' => 'Setup Multi-Factor Authentication', - 'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.', - 'mfa_setup_configured' => 'Already configured', - 'mfa_setup_reconfigure' => 'Reconfigure', - 'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?', - 'mfa_setup_action' => 'Setup', - 'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.', - 'mfa_option_totp_title' => 'Mobile App', - 'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.', - 'mfa_option_backup_codes_title' => 'Backup Codes', - 'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.', - 'mfa_gen_confirm_and_enable' => 'Confirm and Enable', - 'mfa_gen_backup_codes_title' => 'Backup Codes Setup', - 'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.', - 'mfa_gen_backup_codes_download' => 'Download Codes', - 'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once', - 'mfa_gen_totp_title' => 'Mobile App Setup', - 'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.', - 'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.', - 'mfa_gen_totp_verify_setup' => 'Verify Setup', - 'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:', - 'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here', - 'mfa_verify_access' => 'Verify Access', - 'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.', - 'mfa_verify_no_methods' => 'No Methods Configured', - 'mfa_verify_no_methods_desc' => 'No multi-factor authentication methods could be found for your account. You\'ll need to set up at least one method before you gain access.', - 'mfa_verify_use_totp' => 'Verify using a mobile app', - 'mfa_verify_use_backup_codes' => 'Verify using a backup code', - 'mfa_verify_backup_code' => 'Backup Code', - 'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:', - 'mfa_verify_backup_code_enter_here' => 'Enter backup code here', - 'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:', - 'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.', + 'mfa_setup' => 'Skonfiguruj uwierzytelnianie wieloskładnikowe', + 'mfa_setup_desc' => 'Skonfiguruj uwierzytelnianie wieloskładnikowe jako dodatkową warstwę bezpieczeństwa dla swojego konta użytkownika.', + 'mfa_setup_configured' => 'Już skonfigurowane', + 'mfa_setup_reconfigure' => 'Ponownie konfiguruj', + 'mfa_setup_remove_confirmation' => 'Czy na pewno chcesz usunąć tę metodę uwierzytelniania wieloskładnikowego?', + 'mfa_setup_action' => 'Konfiguracja', + 'mfa_backup_codes_usage_limit_warning' => 'Pozostało Ci mniej niż 5 kodów zapasowych, Wygeneruj i przechowuj nowy zestaw zanim skończysz kody, aby zapobiec zablokowaniu się z konta.', + 'mfa_option_totp_title' => 'Aplikacja mobilna', + 'mfa_option_totp_desc' => 'Aby korzystać z uwierzytelniania wieloskładnikowego, potrzebujesz aplikacji mobilnej, która obsługuje TOTP, takiej jak Google Authenticator, Authy lub Microsoft Authenticator.', + 'mfa_option_backup_codes_title' => 'Kody zapasowe', + 'mfa_option_backup_codes_desc' => 'Bezpiecznie przechowuj zestaw jednorazowych kodów zapasowych, które możesz wprowadzić, aby zweryfikować swoją tożsamość.', + 'mfa_gen_confirm_and_enable' => 'Potwierdź i włącz', + 'mfa_gen_backup_codes_title' => 'Ustawienia kopii zapasowych kodów', + 'mfa_gen_backup_codes_desc' => 'Przechowuj poniższą listę kodów w bezpiecznym miejscu. Przy dostępie do systemu będziesz mógł użyć jednego z kodów jako drugiego mechanizmu uwierzytelniania.', + 'mfa_gen_backup_codes_download' => 'Pobierz kody', + 'mfa_gen_backup_codes_usage_warning' => 'Każdy kod może być użyty tylko raz', + 'mfa_gen_totp_title' => 'Ustawienia aplikacji mobilnej', + 'mfa_gen_totp_desc' => 'Aby korzystać z uwierzytelniania wieloskładnikowego, potrzebujesz aplikacji mobilnej, która obsługuje TOTP, takiej jak Google Authenticator, Authy lub Microsoft Authenticator.', + 'mfa_gen_totp_scan' => 'Zeskanuj poniższy kod QR za pomocą preferowanej aplikacji uwierzytelniającej, aby rozpocząć.', + 'mfa_gen_totp_verify_setup' => 'Sprawdź ustawienia', + 'mfa_gen_totp_verify_setup_desc' => 'Sprawdź, czy wszystko działa wprowadzając kod wygenerowany w twojej aplikacji uwierzytelniającej, w poniższym polu:', + 'mfa_gen_totp_provide_code_here' => 'Tutaj podaj kod wygenerowany przez aplikację', + 'mfa_verify_access' => 'Sprawdź dostęp', + 'mfa_verify_access_desc' => 'Twoje konto wymaga potwierdzenia tożsamości poprzez dodatkowy poziom weryfikacji, zanim uzyskasz dostęp. Zweryfikuj za pomocą jednej z skonfigurowanych metod, aby kontynuować.', + 'mfa_verify_no_methods' => 'Brak skonfigurowanych metod', + 'mfa_verify_no_methods_desc' => 'Nie można znaleźć metod uwierzytelniania wieloskładnikowego. Musisz skonfigurować co najmniej jedną metodę zanim uzyskasz dostęp.', + 'mfa_verify_use_totp' => 'Zweryfikuj używając aplikacji mobilnej', + 'mfa_verify_use_backup_codes' => 'Zweryfikuj używając kodu zapasowego', + 'mfa_verify_backup_code' => 'Kod zapasowy', + 'mfa_verify_backup_code_desc' => 'Wprowadź poniżej jeden z pozostałych kodów zapasowych:', + 'mfa_verify_backup_code_enter_here' => 'Wprowadź kod zapasowy tutaj', + 'mfa_verify_totp_desc' => 'Wprowadź kod, wygenerowany przy użyciu aplikacji mobilnej poniżej:', + 'mfa_setup_login_notification' => 'Metoda wieloskładnikowa skonfigurowana, zaloguj się ponownie za pomocą skonfigurowanej metody.', ]; \ No newline at end of file diff --git a/resources/lang/pl/common.php b/resources/lang/pl/common.php index 42a0a312b..1e718a63c 100644 --- a/resources/lang/pl/common.php +++ b/resources/lang/pl/common.php @@ -39,10 +39,10 @@ return [ 'reset' => 'Resetuj', 'remove' => 'Usuń', 'add' => 'Dodaj', - 'configure' => 'Configure', + 'configure' => 'Konfiguruj', 'fullscreen' => 'Pełny ekran', - 'favourite' => 'Favourite', - 'unfavourite' => 'Unfavourite', + 'favourite' => 'Ulubione', + 'unfavourite' => 'Usuń z ulubionych', 'next' => 'Dalej', 'previous' => 'Wstecz', @@ -71,7 +71,7 @@ return [ 'breadcrumb' => 'Ścieżka nawigacji', // Header - 'header_menu_expand' => 'Expand Header Menu', + 'header_menu_expand' => 'Rozwiń menu nagłówka', 'profile_menu' => 'Menu profilu', 'view_profile' => 'Zobacz profil', 'edit_profile' => 'Edytuj profil', @@ -80,9 +80,9 @@ return [ // Layout tabs 'tab_info' => 'Informacje', - 'tab_info_label' => 'Tab: Show Secondary Information', + 'tab_info_label' => 'Zakładka: Pokaż informacje drugorzędne', 'tab_content' => 'Treść', - 'tab_content_label' => 'Tab: Show Primary Content', + 'tab_content_label' => 'Zakładka: Pokaż podstawową zawartość', // Email Content 'email_action_help' => 'Jeśli masz problem z kliknięciem przycisku ":actionText", skopiuj i wklej poniższy adres URL w nowej karcie swojej przeglądarki:', diff --git a/resources/lang/pl/entities.php b/resources/lang/pl/entities.php index cb5343c28..af1f7eecd 100644 --- a/resources/lang/pl/entities.php +++ b/resources/lang/pl/entities.php @@ -27,8 +27,8 @@ return [ 'images' => 'Obrazki', 'my_recent_drafts' => 'Moje ostatnie wersje robocze', 'my_recently_viewed' => 'Moje ostatnio wyświetlane', - 'my_most_viewed_favourites' => 'My Most Viewed Favourites', - 'my_favourites' => 'My Favourites', + 'my_most_viewed_favourites' => 'Moje najczęściej przeglądane ulubione', + 'my_favourites' => 'Moje ulubione', 'no_pages_viewed' => 'Nie przeglądałeś jeszcze żadnych stron', 'no_pages_recently_created' => 'Nie utworzono ostatnio żadnych stron', 'no_pages_recently_updated' => 'Nie zaktualizowano ostatnio żadnych stron', @@ -36,7 +36,7 @@ return [ 'export_html' => 'Plik HTML', 'export_pdf' => 'Plik PDF', 'export_text' => 'Plik tekstowy', - 'export_md' => 'Markdown File', + 'export_md' => 'Pliki Markdown', // Permissions and restrictions 'permissions' => 'Uprawnienia', @@ -63,7 +63,7 @@ return [ 'search_permissions_set' => 'Zbiór uprawnień', 'search_created_by_me' => 'Utworzone przeze mnie', 'search_updated_by_me' => 'Zaktualizowane przeze mnie', - 'search_owned_by_me' => 'Owned by me', + 'search_owned_by_me' => 'Należące do mnie', 'search_date_options' => 'Opcje dat', 'search_updated_before' => 'Zaktualizowane przed', 'search_updated_after' => 'Zaktualizowane po', @@ -99,7 +99,7 @@ return [ 'shelves_permissions' => 'Uprawnienia półki', 'shelves_permissions_updated' => 'Uprawnienia półki zostały zaktualizowane', 'shelves_permissions_active' => 'Uprawnienia półki są aktywne', - 'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.', + 'shelves_permissions_cascade_warning' => 'Uprawnienia na półkach nie są automatycznie kaskadowane do zawartych w nich książek. Dzieje się tak dlatego, że książka może istnieć na wielu półkach. Zezwolenia można jednak skopiować do książek podrzędnych, korzystając z opcji znajdującej się poniżej.', 'shelves_copy_permissions_to_books' => 'Skopiuj uprawnienia do książek', 'shelves_copy_permissions' => 'Skopiuj uprawnienia', 'shelves_copy_permissions_explain' => 'To spowoduje zastosowanie obecnych ustawień uprawnień dla tej półki do wszystkich książek w niej zawartych. Przed aktywacją upewnij się, że wszelkie zmiany w uprawnieniach do tej półki zostały zapisane.', @@ -234,7 +234,7 @@ return [ 'pages_initial_name' => 'Nowa strona', 'pages_editing_draft_notification' => 'Edytujesz obecnie wersje roboczą, która była ostatnio zapisana :timeDiff.', 'pages_draft_edited_notification' => 'Od tego czasu ta strona była zmieniana. Zalecane jest odrzucenie tej wersji roboczej.', - 'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.', + 'pages_draft_page_changed_since_creation' => 'Ta strona została zaktualizowana od czasu utworzenia tego szkicu. Zaleca się, aby odrzucić ten szkic lub nie nadpisywać żadnych zmian na stronie.', 'pages_draft_edit_active' => [ 'start_a' => ':count użytkowników rozpoczęło edytowanie tej strony', 'start_b' => ':userName edytuje stronę', diff --git a/resources/lang/pl/errors.php b/resources/lang/pl/errors.php index 488b753c6..8d3ce30ed 100644 --- a/resources/lang/pl/errors.php +++ b/resources/lang/pl/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'Nie można odnaleźć adresu email dla tego użytkownika w danych dostarczonych przez zewnętrzny system uwierzytelniania', 'saml_invalid_response_id' => 'Żądanie z zewnętrznego systemu uwierzytelniania nie zostało rozpoznane przez proces rozpoczęty przez tę aplikację. Cofnięcie po zalogowaniu mogło spowodować ten problem.', 'saml_fail_authed' => 'Logowanie przy użyciu :system nie powiodło się, system nie mógł pomyślnie ukończyć uwierzytelniania', + 'oidc_already_logged_in' => 'Już zalogowany', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'Brak zdefiniowanej akcji', 'social_login_bad_response' => "Podczas próby logowania :socialAccount wystąpił błąd: \n:error", 'social_account_in_use' => 'To konto :socialAccount jest już w użyciu. Spróbuj zalogować się za pomocą opcji :socialAccount.', diff --git a/resources/lang/pl/settings.php b/resources/lang/pl/settings.php index 18121a9f2..0b22c1700 100644 --- a/resources/lang/pl/settings.php +++ b/resources/lang/pl/settings.php @@ -92,7 +92,7 @@ return [ 'recycle_bin' => 'Kosz', 'recycle_bin_desc' => 'Tutaj możesz przywrócić elementy, które zostały usunięte lub usunąć je z systemu. Ta lista jest niefiltrowana w odróżnieniu od podobnych list aktywności w systemie, w którym stosowane są filtry uprawnień.', 'recycle_bin_deleted_item' => 'Usunięta pozycja', - 'recycle_bin_deleted_parent' => 'Parent', + 'recycle_bin_deleted_parent' => 'Nadrzędny', 'recycle_bin_deleted_by' => 'Usunięty przez', 'recycle_bin_deleted_at' => 'Czas usunięcia', 'recycle_bin_permanently_delete' => 'Usuń trwale', @@ -100,18 +100,18 @@ return [ 'recycle_bin_contents_empty' => 'Kosz jest pusty', 'recycle_bin_empty' => 'Opróżnij kosz', 'recycle_bin_empty_confirm' => 'To na stałe zniszczy wszystkie przedmioty w koszu, w tym zawartość w każdym elemencie. Czy na pewno chcesz opróżnić kosz?', - 'recycle_bin_destroy_confirm' => 'This action will permanently delete this item, along with any child elements listed below, from the system and you will not be able to restore this content. Are you sure you want to permanently delete this item?', + 'recycle_bin_destroy_confirm' => 'Ta akcja trwale usunie ten element, wraz z elementami podrzędnymi wymienionymi poniżej, z systemu i nie będziesz w stanie przywrócić tej zawartości. Czy na pewno chcesz trwale usunąć ten element?', 'recycle_bin_destroy_list' => 'Elementy do usunięcia', 'recycle_bin_restore_list' => 'Elementy do przywrócenia', - 'recycle_bin_restore_confirm' => 'This action will restore the deleted item, including any child elements, to their original location. If the original location has since been deleted, and is now in the recycle bin, the parent item will also need to be restored.', - 'recycle_bin_restore_deleted_parent' => 'The parent of this item has also been deleted. These will remain deleted until that parent is also restored.', - 'recycle_bin_restore_parent' => 'Restore Parent', + 'recycle_bin_restore_confirm' => 'Ta akcja przywróci usunięty element, w tym elementy podrzędne, do ich oryginalnej lokalizacji. Jeśli oryginalna lokalizacja została od tego czasu usunięta, a teraz znajduje się w koszu, element nadrzędny będzie również musiał zostać przywrócony.', + 'recycle_bin_restore_deleted_parent' => 'Usunięto również nadrzędny element. Zostaną one usunięte, dopóki nie przywróci się tego nadrzędnego elementu.', + 'recycle_bin_restore_parent' => 'Przywróć nadrzędne', 'recycle_bin_destroy_notification' => 'Usunięto :count przedmiotów z kosza.', 'recycle_bin_restore_notification' => 'Przywrócono :count przedmiotów z kosza.', // Audit Log 'audit' => 'Dziennik audytu', - 'audit_desc' => 'This audit log displays a list of activities tracked in the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.', + 'audit_desc' => 'Ten dziennik audytu wyświetla listę działań śledzonych w systemie. Ta lista jest niefiltrowana w odróżnieniu od podobnych list aktywności w systemie, w którym stosowane są filtry uprawnień.', 'audit_event_filter' => 'Filtry Wydarzeń', 'audit_event_filter_no_filter' => 'Brak filtra', 'audit_deleted_item' => 'Usunięta pozycja', @@ -139,7 +139,7 @@ return [ 'role_details' => 'Szczegóły roli', 'role_name' => 'Nazwa roli', 'role_desc' => 'Krótki opis roli', - 'role_mfa_enforced' => 'Requires Multi-Factor Authentication', + 'role_mfa_enforced' => 'Wymaga uwierzytelniania wieloetapowego', 'role_external_auth_id' => 'Zewnętrzne identyfikatory uwierzytelniania', 'role_system' => 'Uprawnienia systemowe', 'role_manage_users' => 'Zarządzanie użytkownikami', @@ -149,7 +149,7 @@ return [ 'role_manage_page_templates' => 'Zarządzaj szablonami stron', 'role_access_api' => 'Dostęp do systemowego API', 'role_manage_settings' => 'Zarządzanie ustawieniami aplikacji', - 'role_export_content' => 'Export content', + 'role_export_content' => 'Eksportuj zawartość', 'role_asset' => 'Zarządzanie zasobami', 'roles_system_warning' => 'Pamiętaj, że dostęp do trzech powyższych uprawnień może pozwolić użytkownikowi na zmianę własnych uprawnień lub uprawnień innych osób w systemie. Przypisz tylko role z tymi uprawnieniami do zaufanych użytkowników.', 'role_asset_desc' => 'Te ustawienia kontrolują zarządzanie zasobami systemu. Uprawnienia książek, rozdziałów i stron nadpisują te ustawienia.', @@ -207,10 +207,10 @@ return [ 'users_api_tokens_create' => 'Utwórz token', 'users_api_tokens_expires' => 'Wygasa', 'users_api_tokens_docs' => 'Dokumentacja API', - 'users_mfa' => 'Multi-Factor Authentication', - 'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.', - 'users_mfa_x_methods' => ':count method configured|:count methods configured', - 'users_mfa_configure' => 'Configure Methods', + 'users_mfa' => 'Uwierzytelnianie wieloskładnikowe', + 'users_mfa_desc' => 'Skonfiguruj uwierzytelnianie wieloskładnikowe jako dodatkową warstwę bezpieczeństwa dla swojego konta użytkownika.', + 'users_mfa_x_methods' => ':count metoda skonfigurowana|:count metody skonfigurowane', + 'users_mfa_configure' => 'Konfiguruj metody', // API Tokens 'user_api_token_create' => 'Utwórz klucz API', diff --git a/resources/lang/pl/validation.php b/resources/lang/pl/validation.php index d852d46b9..ab4c9da7b 100644 --- a/resources/lang/pl/validation.php +++ b/resources/lang/pl/validation.php @@ -15,7 +15,7 @@ return [ 'alpha_dash' => ':attribute może zawierać wyłącznie litery, cyfry i myślniki.', 'alpha_num' => ':attribute może zawierać wyłącznie litery i cyfry.', 'array' => ':attribute musi być tablicą.', - 'backup_codes' => 'The provided code is not valid or has already been used.', + 'backup_codes' => 'Podany kod jest nieprawidłowy lub został już użyty.', 'before' => ':attribute musi być datą poprzedzającą :date.', 'between' => [ 'numeric' => ':attribute musi zawierać się w przedziale od :min do :max.', @@ -99,7 +99,7 @@ return [ ], 'string' => ':attribute musi być ciągiem znaków.', 'timezone' => ':attribute musi być prawidłową strefą czasową.', - 'totp' => 'The provided code is not valid or has expired.', + 'totp' => 'Podany kod jest nieprawidłowy lub wygasł.', 'unique' => ':attribute zostało już zajęte.', 'url' => 'Format :attribute jest nieprawidłowy.', 'uploaded' => 'Plik nie może zostać wysłany. Serwer nie akceptuje plików o takim rozmiarze.', diff --git a/resources/lang/pt/errors.php b/resources/lang/pt/errors.php index 011b5b3a2..b708b04c9 100644 --- a/resources/lang/pt/errors.php +++ b/resources/lang/pt/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'Não foi possível encontrar um endereço de e-mail para este utilizador nos dados providenciados pelo sistema de autenticação externa', 'saml_invalid_response_id' => 'A requisição do sistema de autenticação externa não foi reconhecia por um processo iniciado por esta aplicação. Navegar para o caminho anterior após o inicio de sessão pode provocar este problema.', 'saml_fail_authed' => 'Inicio de sessão com :system falhou. O sistema não forneceu uma autorização bem sucedida', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'Nenhuma ação definida', 'social_login_bad_response' => "Erro recebido durante o inicio de sessão :socialAccount: \n:error", 'social_account_in_use' => 'Esta conta :socialAccount já está em uso. Por favor, tente entrar utilizando a opção :socialAccount.', diff --git a/resources/lang/pt_BR/errors.php b/resources/lang/pt_BR/errors.php index d0e5c4439..63928f594 100644 --- a/resources/lang/pt_BR/errors.php +++ b/resources/lang/pt_BR/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'Não foi possível encontrar um endereço de e-mail para este usuário nos dados providos pelo sistema de autenticação externa', 'saml_invalid_response_id' => 'A requisição do sistema de autenticação externa não foi reconhecia por um processo iniciado por esta aplicação. Após o login, navegar para o caminho anterior pode causar um problema.', 'saml_fail_authed' => 'Login utilizando :system falhou. Sistema não forneceu autorização bem sucedida', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'Nenhuma ação definida', 'social_login_bad_response' => "Erro recebido durante o login :socialAccount: \n:error", 'social_account_in_use' => 'Essa conta :socialAccount já está em uso. Por favor, tente entrar utilizando a opção :socialAccount.', diff --git a/resources/lang/ru/errors.php b/resources/lang/ru/errors.php index 96c792e1d..1edef426c 100644 --- a/resources/lang/ru/errors.php +++ b/resources/lang/ru/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'Не удалось найти email для этого пользователя в данных, предоставленных внешней системой аутентификации', 'saml_invalid_response_id' => 'Запрос от внешней системы аутентификации не распознается процессом, запущенным этим приложением. Переход назад после входа в систему может вызвать эту проблему.', 'saml_fail_authed' => 'Вход с помощью :system не удался, система не предоставила успешную авторизацию', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'Действие не определено', 'social_login_bad_response' => "При попытке входа с :socialAccount произошла ошибка: \\n:error", 'social_account_in_use' => 'Этот :socialAccount аккаунт уже используется, попробуйте войти с параметрами :socialAccount.', diff --git a/resources/lang/sk/errors.php b/resources/lang/sk/errors.php index bb30243e8..63a773e4d 100644 --- a/resources/lang/sk/errors.php +++ b/resources/lang/sk/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', 'saml_invalid_response_id' => 'The request from the external authentication system is not recognised by a process started by this application. Navigating back after a login could cause this issue.', 'saml_fail_authed' => 'Login using :system failed, system did not provide successful authorization', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'Nebola definovaná žiadna akcia', 'social_login_bad_response' => "Error received during :socialAccount login: \n:error", 'social_account_in_use' => 'Tento :socialAccount účet sa už používa, skúste sa prihlásiť pomocou možnosti :socialAccount.', diff --git a/resources/lang/sl/errors.php b/resources/lang/sl/errors.php index b0e253be0..fcfddf505 100644 --- a/resources/lang/sl/errors.php +++ b/resources/lang/sl/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'Nisem našel e-naslova za tega uporabnika v podatkih iz zunanjega sistema za preverjanje pristnosti', 'saml_invalid_response_id' => 'Zahteva iz zunanjega sistema za preverjanje pristnosti ni prepoznana s strani procesa zagnanega s strani te aplikacije. Pomik nazaj po prijavi je lahko vzrok teh težav.', 'saml_fail_authed' => 'Prijava z uporabo :system ni uspela, sistem ni zagotovil uspešne avtorizacije', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'Akcija ni določena', 'social_login_bad_response' => "Napaka pri :socialAccount prijavi:\n:error", 'social_account_in_use' => 'Ta :socialAccount je že v uporabi. Poskusite se prijaviti z :socialAccount možnostjo.', diff --git a/resources/lang/sv/errors.php b/resources/lang/sv/errors.php index b8216a42f..28dff2559 100644 --- a/resources/lang/sv/errors.php +++ b/resources/lang/sv/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'Kunde inte hitta en e-postadress för den här användaren i data som tillhandahålls av det externa autentiseringssystemet', 'saml_invalid_response_id' => 'En begäran från det externa autentiseringssystemet känns inte igen av en process som startats av denna applikation. Att navigera bakåt efter en inloggning kan orsaka detta problem.', 'saml_fail_authed' => 'Inloggning med :system misslyckades, systemet godkände inte auktoriseringen', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'Ingen åtgärd definierad', 'social_login_bad_response' => "Ett fel inträffade vid inloggning genom :socialAccount: \n:error", 'social_account_in_use' => 'Detta konto från :socialAccount används redan. Testa att logga in med :socialAccount istället.', diff --git a/resources/lang/tr/errors.php b/resources/lang/tr/errors.php index 5048b079c..2b1ac4c64 100644 --- a/resources/lang/tr/errors.php +++ b/resources/lang/tr/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'Harici kimlik doğrulama sisteminden gelen veriler, bu kullanıcının e-posta adresini içermiyor', 'saml_invalid_response_id' => 'Harici doğrulama sistemi tarafından sağlanan bir veri talebi, bu uygulama tarafından başlatılan bir işlem tarafından tanınamadı. Giriş yaptıktan sonra geri dönmek bu soruna yol açmış olabilir.', 'saml_fail_authed' => ':system kullanarak giriş yapma başarısız oldu; sistem, başarılı bir kimlik doğrulama sağlayamadı', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'Herhangi bir eylem tanımlanmamış', 'social_login_bad_response' => ":socialAccount girişi sırasında bir hata meydana geldi: \n:error", 'social_account_in_use' => 'Bu :socialAccount zaten kullanımda, :socialAccount hesabıyla giriş yapmayı deneyin.', diff --git a/resources/lang/uk/errors.php b/resources/lang/uk/errors.php index c7d2545f9..ae0f84165 100644 --- a/resources/lang/uk/errors.php +++ b/resources/lang/uk/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'Не вдалося знайти електронну адресу для цього користувача у даних, наданих зовнішньою системою аутентифікації', 'saml_invalid_response_id' => 'Запит із зовнішньої системи аутентифікації не розпізнається процесом, розпочатим цим додатком. Повернення назад після входу могла спричинити цю проблему.', 'saml_fail_authed' => 'Вхід із використанням «:system» не вдався, система не здійснила успішну авторизацію', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'Жодних дій не визначено', 'social_login_bad_response' => "Помилка, отримана під час входу з :socialAccount помилка : \n:error", 'social_account_in_use' => 'Цей :socialAccount обліковий запис вже використовується, спробуйте ввійти з параметрами :socialAccount.', diff --git a/resources/lang/vi/errors.php b/resources/lang/vi/errors.php index cfd2b9746..ff1abbf8e 100644 --- a/resources/lang/vi/errors.php +++ b/resources/lang/vi/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => 'Không tìm thấy địa chỉ email cho người dùng này trong dữ liệu được cung cấp bới hệ thống xác thực ngoài', 'saml_invalid_response_id' => 'Yêu cầu từ hệ thống xác thực bên ngoài không được nhận diện bởi quy trình chạy cho ứng dụng này. Điều hướng trở lại sau khi đăng nhập có thể đã gây ra vấn đề này.', 'saml_fail_authed' => 'Đăng nhập sử dụng :system thất bại, hệ thống không cung cấp được sự xác thực thành công', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'Không có hành động được xác định', 'social_login_bad_response' => "Xảy ra lỗi trong lúc đăng nhập :socialAccount: \n:error", 'social_account_in_use' => 'Tài khoản :socialAccount này đang được sử dụng, Vui lòng thử đăng nhập bằng tùy chọn :socialAccount.', diff --git a/resources/lang/zh_CN/errors.php b/resources/lang/zh_CN/errors.php index 7ad049ed4..569c8482c 100644 --- a/resources/lang/zh_CN/errors.php +++ b/resources/lang/zh_CN/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => '无法找到有效Email地址,此用户数据由外部身份验证系统托管', 'saml_invalid_response_id' => '来自外部身份验证系统的请求没有被本应用程序认证,在登录后返回上一页可能会导致此问题。', 'saml_fail_authed' => '使用 :system 登录失败,登录系统未返回成功登录授权信息。', + 'oidc_already_logged_in' => '您已经登陆了', + 'oidc_user_not_registered' => '用户 :name 尚未注册,自助注册功能已被禁用', + 'oidc_no_email_address' => '无法找到有效的 Email 地址,此用户数据由外部身份验证系统托管', + 'oidc_fail_authed' => '使用 :system 登录失败,登录系统未返回成功登录授权信息', 'social_no_action_defined' => '没有定义行为', 'social_login_bad_response' => "在 :socialAccount 登录时遇到错误:\n:error", 'social_account_in_use' => ':socialAccount 账户已被使用,请尝试通过 :socialAccount 选项登录。', diff --git a/resources/lang/zh_TW/errors.php b/resources/lang/zh_TW/errors.php index 0d898552f..2a4483054 100644 --- a/resources/lang/zh_TW/errors.php +++ b/resources/lang/zh_TW/errors.php @@ -23,6 +23,10 @@ return [ 'saml_no_email_address' => '在外部認證系統提供的資料中找不到該使用者的電子郵件地址', 'saml_invalid_response_id' => '此應用程式啟動的處理程序無法識別來自外部認證系統的請求。登入後回上一頁可能會造成此問題。', 'saml_fail_authed' => '使用 :system 登入失敗,系統未提供成功的授權', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => '未定義動作', 'social_login_bad_response' => "在 :socialAccount 登入時遇到錯誤: \n:error", 'social_account_in_use' => ':socialAccount 帳號已被使用,請嘗試透過 :socialAccount 選項登入。', From a75cfd1f2553de3f2cae85153f6da889a6a34bc9 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 25 Oct 2021 14:49:03 +0100 Subject: [PATCH 48/50] Added estonian to language logic --- app/Config/app.php | 2 +- app/Http/Middleware/Localization.php | 2 ++ resources/lang/en/settings.php | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/Config/app.php b/app/Config/app.php index 120644aed..f90a7dd76 100755 --- a/app/Config/app.php +++ b/app/Config/app.php @@ -61,7 +61,7 @@ return [ 'locale' => env('APP_LANG', 'en'), // Locales available - 'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW'], + 'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'et', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW'], // Application Fallback Locale 'fallback_locale' => 'en', diff --git a/app/Http/Middleware/Localization.php b/app/Http/Middleware/Localization.php index e82465146..d8e1253e5 100644 --- a/app/Http/Middleware/Localization.php +++ b/app/Http/Middleware/Localization.php @@ -15,6 +15,7 @@ class Localization /** * Map of BookStack locale names to best-estimate system locale names. + * Locales can often be found by running `locale -a` on a linux system. */ protected $localeMap = [ 'ar' => 'ar', @@ -27,6 +28,7 @@ class Localization 'en' => 'en_GB', 'es' => 'es_ES', 'es_AR' => 'es_AR', + 'et' => 'et_EE', 'fr' => 'fr_FR', 'he' => 'he_IL', 'hr' => 'hr_HR', diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php index 0ab168b66..46df0d07d 100755 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', From fa466139f0acf21e64aef0131eb3c7149037740f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 25 Oct 2021 14:49:21 +0100 Subject: [PATCH 49/50] Updated translators before v21.10 release --- .github/translators.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/translators.txt b/.github/translators.txt index ff91f4033..db8515707 100644 --- a/.github/translators.txt +++ b/.github/translators.txt @@ -192,3 +192,7 @@ Atalonica :: Catalan 慕容潭谈 (591442386) :: Chinese Simplified Radim Pesek (ramess18) :: Czech anastasiia.motylko :: Ukrainian +Indrek Haav (IndrekHaav) :: Estonian +na3shkw :: Japanese +Giancarlo Di Massa (digitall-it) :: Italian +M Nafis Al Mukhdi (mnafisalmukhdi1) :: Indonesian From a5401eb00acbae579caca617caa30964fe230d03 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 25 Oct 2021 15:01:32 +0100 Subject: [PATCH 50/50] New Crowdin updates (#3005) * New translations settings.php (Spanish, Argentina) * New translations settings.php (Polish) * New translations settings.php (Estonian) * New translations errors.php (Spanish, Argentina) * New translations settings.php (Japanese) * New translations activities.php (German Informal) * New translations auth.php (German Informal) * New translations settings.php (French) * New translations settings.php (Spanish) * New translations settings.php (Arabic) * New translations settings.php (Bulgarian) * New translations settings.php (Catalan) * New translations settings.php (Norwegian Bokmal) * New translations settings.php (German Informal) * New translations settings.php (Bosnian) * New translations settings.php (Czech) * New translations settings.php (Slovak) * New translations settings.php (Danish) * New translations settings.php (German) * New translations settings.php (Hebrew) * New translations settings.php (Hungarian) * New translations settings.php (Italian) * New translations settings.php (Korean) * New translations settings.php (Lithuanian) * New translations settings.php (Dutch) * New translations settings.php (Portuguese) * New translations settings.php (Russian) * New translations settings.php (Slovenian) * New translations settings.php (Latvian) * New translations settings.php (Swedish) * New translations settings.php (Turkish) * New translations settings.php (Ukrainian) * New translations settings.php (Chinese Simplified) * New translations settings.php (Chinese Traditional) * New translations settings.php (Vietnamese) * New translations settings.php (Portuguese, Brazilian) * New translations settings.php (Indonesian) * New translations settings.php (Persian) * New translations settings.php (Croatian) * New translations validation.php (German Informal) --- resources/lang/ar/settings.php | 1 + resources/lang/bg/settings.php | 1 + resources/lang/bs/settings.php | 1 + resources/lang/ca/settings.php | 1 + resources/lang/cs/settings.php | 1 + resources/lang/da/settings.php | 1 + resources/lang/de/settings.php | 1 + resources/lang/de_informal/activities.php | 4 +- resources/lang/de_informal/auth.php | 62 +++++++++++------------ resources/lang/de_informal/settings.php | 3 +- resources/lang/de_informal/validation.php | 4 +- resources/lang/es/settings.php | 1 + resources/lang/es_AR/errors.php | 8 +-- resources/lang/es_AR/settings.php | 1 + resources/lang/et/settings.php | 1 + resources/lang/fa/settings.php | 1 + resources/lang/fr/settings.php | 1 + resources/lang/he/settings.php | 1 + resources/lang/hr/settings.php | 1 + resources/lang/hu/settings.php | 1 + resources/lang/id/settings.php | 1 + resources/lang/it/settings.php | 1 + resources/lang/ja/settings.php | 1 + resources/lang/ko/settings.php | 1 + resources/lang/lt/settings.php | 1 + resources/lang/lv/settings.php | 1 + resources/lang/nb/settings.php | 1 + resources/lang/nl/settings.php | 1 + resources/lang/pl/settings.php | 1 + resources/lang/pt/settings.php | 1 + resources/lang/pt_BR/settings.php | 1 + resources/lang/ru/settings.php | 1 + resources/lang/sk/settings.php | 1 + resources/lang/sl/settings.php | 1 + resources/lang/sv/settings.php | 1 + resources/lang/tr/settings.php | 1 + resources/lang/uk/settings.php | 1 + resources/lang/vi/settings.php | 1 + resources/lang/zh_CN/settings.php | 1 + resources/lang/zh_TW/settings.php | 1 + 40 files changed, 76 insertions(+), 40 deletions(-) diff --git a/resources/lang/ar/settings.php b/resources/lang/ar/settings.php index 2ceb849bc..b4ccd9482 100755 --- a/resources/lang/ar/settings.php +++ b/resources/lang/ar/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/bg/settings.php b/resources/lang/bg/settings.php index 5c1e1c903..e6afe7101 100644 --- a/resources/lang/bg/settings.php +++ b/resources/lang/bg/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/bs/settings.php b/resources/lang/bs/settings.php index 0ab168b66..46df0d07d 100644 --- a/resources/lang/bs/settings.php +++ b/resources/lang/bs/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/ca/settings.php b/resources/lang/ca/settings.php index 3a3fdddc1..74e692798 100755 --- a/resources/lang/ca/settings.php +++ b/resources/lang/ca/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/cs/settings.php b/resources/lang/cs/settings.php index 8ca0d538f..b2e840044 100644 --- a/resources/lang/cs/settings.php +++ b/resources/lang/cs/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/da/settings.php b/resources/lang/da/settings.php index cfb4ed908..804a67bba 100644 --- a/resources/lang/da/settings.php +++ b/resources/lang/da/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'Hebraisk', 'hr' => 'Hrvatski', diff --git a/resources/lang/de/settings.php b/resources/lang/de/settings.php index d24319c18..5d6c19d10 100644 --- a/resources/lang/de/settings.php +++ b/resources/lang/de/settings.php @@ -251,6 +251,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'Hebräisch', 'hr' => 'Hrvatski', diff --git a/resources/lang/de_informal/activities.php b/resources/lang/de_informal/activities.php index fec33bec2..9f8f22fba 100644 --- a/resources/lang/de_informal/activities.php +++ b/resources/lang/de_informal/activities.php @@ -48,8 +48,8 @@ return [ 'favourite_remove_notification' => '":name" wurde aus Ihren Favoriten entfernt', // MFA - 'mfa_setup_method_notification' => 'Multi-factor method successfully configured', - 'mfa_remove_method_notification' => 'Multi-factor method successfully removed', + 'mfa_setup_method_notification' => 'Multi-Faktor-Methode erfolgreich konfiguriert', + 'mfa_remove_method_notification' => 'Multi-Faktor-Methode erfolgreich entfernt', // Other 'commented_on' => 'kommentiert', diff --git a/resources/lang/de_informal/auth.php b/resources/lang/de_informal/auth.php index d09008e5a..70da5bae2 100644 --- a/resources/lang/de_informal/auth.php +++ b/resources/lang/de_informal/auth.php @@ -76,37 +76,37 @@ return [ 'user_invite_success' => 'Das Passwort wurde gesetzt, du hast nun Zugriff auf :appName!', // Multi-factor Authentication - 'mfa_setup' => 'Setup Multi-Factor Authentication', - 'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.', - 'mfa_setup_configured' => 'Already configured', - 'mfa_setup_reconfigure' => 'Reconfigure', - 'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?', - 'mfa_setup_action' => 'Setup', - 'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.', + 'mfa_setup' => 'Multi-Faktor-Authentifizierung einrichten', + 'mfa_setup_desc' => 'Richten Sie Multi-Faktor-Authentifizierung als zusätzliche Sicherheitsstufe für Ihr Benutzerkonto ein.', + 'mfa_setup_configured' => 'Bereits konfiguriert', + 'mfa_setup_reconfigure' => 'Umkonfigurieren', + 'mfa_setup_remove_confirmation' => 'Sind Sie sicher, dass Sie diese Multi-Faktor-Authentifizierungsmethode entfernen möchten?', + 'mfa_setup_action' => 'Einrichtung', + 'mfa_backup_codes_usage_limit_warning' => 'Sie haben weniger als 5 Backup-Codes übrig, Bitte erstellen und speichern Sie ein neues Set bevor Sie keine Codes mehr haben, um zu verhindern, dass Sie von Ihrem Konto gesperrt werden.', 'mfa_option_totp_title' => 'Mobile App', - 'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.', - 'mfa_option_backup_codes_title' => 'Backup Codes', - 'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.', - 'mfa_gen_confirm_and_enable' => 'Confirm and Enable', - 'mfa_gen_backup_codes_title' => 'Backup Codes Setup', - 'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.', + 'mfa_option_totp_desc' => 'Um Mehrfach-Faktor-Authentifizierung nutzen zu können, benötigen Sie eine mobile Anwendung, die TOTP unterstützt, wie Google Authenticator, Authy oder Microsoft Authenticator.', + 'mfa_option_backup_codes_title' => 'Backup Code', + 'mfa_option_backup_codes_desc' => 'Speichern Sie sicher eine Reihe von einmaligen Backup-Codes, die Sie eingeben können, um Ihre Identität zu überprüfen.', + 'mfa_gen_confirm_and_enable' => 'Bestätigen und aktivieren', + 'mfa_gen_backup_codes_title' => 'Backup-Codes einrichten', + 'mfa_gen_backup_codes_desc' => 'Speichern Sie die folgende Liste der Codes an einem sicheren Ort. Wenn Sie auf das System zugreifen, können Sie einen der Codes als zweiten Authentifizierungsmechanismus verwenden.', 'mfa_gen_backup_codes_download' => 'Download Codes', - 'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once', - 'mfa_gen_totp_title' => 'Mobile App Setup', - 'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.', - 'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.', - 'mfa_gen_totp_verify_setup' => 'Verify Setup', - 'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:', - 'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here', - 'mfa_verify_access' => 'Verify Access', - 'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.', - 'mfa_verify_no_methods' => 'No Methods Configured', - 'mfa_verify_no_methods_desc' => 'No multi-factor authentication methods could be found for your account. You\'ll need to set up at least one method before you gain access.', - 'mfa_verify_use_totp' => 'Verify using a mobile app', - 'mfa_verify_use_backup_codes' => 'Verify using a backup code', - 'mfa_verify_backup_code' => 'Backup Code', - 'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:', - 'mfa_verify_backup_code_enter_here' => 'Enter backup code here', - 'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:', - 'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.', + 'mfa_gen_backup_codes_usage_warning' => 'Jeder Code kann nur einmal verwendet werden', + 'mfa_gen_totp_title' => 'Mobile App einrichten', + 'mfa_gen_totp_desc' => 'Um Mehrfach-Faktor-Authentifizierung nutzen zu können, benötigen Sie eine mobile Anwendung, die TOTP unterstützt, wie Google Authenticator, Authy oder Microsoft Authenticator.', + 'mfa_gen_totp_scan' => 'Scannen Sie den QR-Code unten mit Ihrer bevorzugten Authentifizierungs-App, um loszulegen.', + 'mfa_gen_totp_verify_setup' => 'Setup überprüfen', + 'mfa_gen_totp_verify_setup_desc' => 'Überprüfen Sie, dass alles funktioniert, indem Sie einen Code in Ihrer Authentifizierungs-App in das Eingabefeld unten eingeben:', + 'mfa_gen_totp_provide_code_here' => 'Geben Sie hier Ihre App generierten Code ein', + 'mfa_verify_access' => 'Zugriff überprüfen', + 'mfa_verify_access_desc' => 'Ihr Benutzerkonto erfordert, dass Sie Ihre Identität über eine zusätzliche Verifikationsebene bestätigen, bevor Sie den Zugriff gewähren. Überprüfen Sie mit einer Ihrer konfigurierten Methoden, um fortzufahren.', + 'mfa_verify_no_methods' => 'Keine Methoden konfiguriert', + 'mfa_verify_no_methods_desc' => 'Es konnten keine Mehrfach-Faktor-Authentifizierungsmethoden für Ihr Konto gefunden werden. Sie müssen mindestens eine Methode einrichten, bevor Sie Zugriff erhalten.', + 'mfa_verify_use_totp' => 'Mit einer mobilen App verifizieren', + 'mfa_verify_use_backup_codes' => 'Mit einem Backup-Code überprüfen', + 'mfa_verify_backup_code' => 'Backup-Code', + 'mfa_verify_backup_code_desc' => 'Geben Sie einen Ihrer verbleibenden Backup-Codes unten ein:', + 'mfa_verify_backup_code_enter_here' => 'Backup-Code hier eingeben', + 'mfa_verify_totp_desc' => 'Geben Sie den Code ein, der mit Ihrer mobilen App generiert wurde:', + 'mfa_setup_login_notification' => 'Multi-Faktor-Methode konfiguriert. Bitte melden Sie sich jetzt erneut mit der konfigurierten Methode an.', ]; \ No newline at end of file diff --git a/resources/lang/de_informal/settings.php b/resources/lang/de_informal/settings.php index 53d8f8359..8fdae9863 100644 --- a/resources/lang/de_informal/settings.php +++ b/resources/lang/de_informal/settings.php @@ -122,7 +122,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 'audit_table_user' => 'Benutzer', 'audit_table_event' => 'Ereignis', 'audit_table_related' => 'Verknüpfter Eintrag oder Detail', - 'audit_table_ip' => 'IP Address', + 'audit_table_ip' => 'IP Adresse', 'audit_table_date' => 'Aktivitätsdatum', 'audit_date_from' => 'Zeitraum von', 'audit_date_to' => 'Zeitraum bis', @@ -251,6 +251,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/de_informal/validation.php b/resources/lang/de_informal/validation.php index 7eb385ac9..6603eccc8 100644 --- a/resources/lang/de_informal/validation.php +++ b/resources/lang/de_informal/validation.php @@ -15,7 +15,7 @@ return [ 'alpha_dash' => ':attribute kann nur Buchstaben, Zahlen und Bindestriche enthalten.', 'alpha_num' => ':attribute kann nur Buchstaben und Zahlen enthalten.', 'array' => ':attribute muss ein Array sein.', - 'backup_codes' => 'The provided code is not valid or has already been used.', + 'backup_codes' => 'Der angegebene Code ist ungültig oder wurde bereits verwendet.', 'before' => ':attribute muss ein Datum vor :date sein.', 'between' => [ 'numeric' => ':attribute muss zwischen :min und :max liegen.', @@ -99,7 +99,7 @@ return [ ], 'string' => ':attribute muss eine Zeichenkette sein.', 'timezone' => ':attribute muss eine valide zeitzone sein.', - 'totp' => 'The provided code is not valid or has expired.', + 'totp' => 'Der angegebene Code ist ungültig oder abgelaufen.', 'unique' => ':attribute wird bereits verwendet.', 'url' => ':attribute ist kein valides Format.', 'uploaded' => 'Die Datei konnte nicht hochgeladen werden. Der Server akzeptiert möglicherweise keine Dateien dieser Größe.', diff --git a/resources/lang/es/settings.php b/resources/lang/es/settings.php index bf9c89a63..81416e70e 100644 --- a/resources/lang/es/settings.php +++ b/resources/lang/es/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/es_AR/errors.php b/resources/lang/es_AR/errors.php index fe12922ba..a58392bcd 100644 --- a/resources/lang/es_AR/errors.php +++ b/resources/lang/es_AR/errors.php @@ -23,10 +23,10 @@ return [ 'saml_no_email_address' => 'No se pudo encontrar una dirección de correo electrónico, para este usuario, en los datos proporcionados por el sistema de autenticación externo', 'saml_invalid_response_id' => 'La solicitud del sistema de autenticación externo no está reconocida por un proceso iniciado por esta aplicación. Navegar hacia atrás después de un inicio de sesión podría causar este problema.', 'saml_fail_authed' => 'El inicio de sesión con :system falló, el sistema no proporcionó una autorización correcta', - 'oidc_already_logged_in' => 'Already logged in', - 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', - 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', - 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', + 'oidc_already_logged_in' => 'Ya tenías la sesión iniciada', + 'oidc_user_not_registered' => 'El usuario :name no está registrado y el registro automático está deshabilitado', + 'oidc_no_email_address' => 'No se pudo encontrar una dirección de correo electrónico, para este usuario, en los datos proporcionados por el sistema de autenticación externo', + 'oidc_fail_authed' => 'El inicio de sesión con :system falló, el sistema no proporcionó una autorización correcta', 'social_no_action_defined' => 'Acción no definida', 'social_login_bad_response' => "SE recibió un Error durante el acceso con :socialAccount : \n:error", 'social_account_in_use' => 'la cuenta :socialAccount ya se encuentra en uso, intente loguearse a través de la opcón :socialAccount .', diff --git a/resources/lang/es_AR/settings.php b/resources/lang/es_AR/settings.php index 027f22421..46f72fa6b 100644 --- a/resources/lang/es_AR/settings.php +++ b/resources/lang/es_AR/settings.php @@ -249,6 +249,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/et/settings.php b/resources/lang/et/settings.php index 08436ca8b..bb2dba47c 100644 --- a/resources/lang/et/settings.php +++ b/resources/lang/et/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/fa/settings.php b/resources/lang/fa/settings.php index 0ab168b66..46df0d07d 100644 --- a/resources/lang/fa/settings.php +++ b/resources/lang/fa/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/fr/settings.php b/resources/lang/fr/settings.php index aa58aeee0..18958c825 100644 --- a/resources/lang/fr/settings.php +++ b/resources/lang/fr/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'Hébreu', 'hr' => 'Hrvatski', diff --git a/resources/lang/he/settings.php b/resources/lang/he/settings.php index e15886779..499ec3cd3 100755 --- a/resources/lang/he/settings.php +++ b/resources/lang/he/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/hr/settings.php b/resources/lang/hr/settings.php index 547f27a83..25b5e3186 100644 --- a/resources/lang/hr/settings.php +++ b/resources/lang/hr/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/hu/settings.php b/resources/lang/hu/settings.php index 9cc3f840d..ea36c9641 100644 --- a/resources/lang/hu/settings.php +++ b/resources/lang/hu/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/id/settings.php b/resources/lang/id/settings.php index c01cbdb01..95499acde 100644 --- a/resources/lang/id/settings.php +++ b/resources/lang/id/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/it/settings.php b/resources/lang/it/settings.php index c5e016b35..3abb1c45e 100755 --- a/resources/lang/it/settings.php +++ b/resources/lang/it/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/ja/settings.php b/resources/lang/ja/settings.php index b604270ec..4e2af0212 100644 --- a/resources/lang/ja/settings.php +++ b/resources/lang/ja/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/ko/settings.php b/resources/lang/ko/settings.php index 6c81bc735..f9c0290a6 100755 --- a/resources/lang/ko/settings.php +++ b/resources/lang/ko/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => '히브리어', 'hr' => 'Hrvatski', diff --git a/resources/lang/lt/settings.php b/resources/lang/lt/settings.php index f5795edbc..c69694951 100644 --- a/resources/lang/lt/settings.php +++ b/resources/lang/lt/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/lv/settings.php b/resources/lang/lv/settings.php index 0108a9aa5..79aca4e54 100644 --- a/resources/lang/lv/settings.php +++ b/resources/lang/lv/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/nb/settings.php b/resources/lang/nb/settings.php index cfa82f87c..134208d7d 100644 --- a/resources/lang/nb/settings.php +++ b/resources/lang/nb/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/nl/settings.php b/resources/lang/nl/settings.php index 1cbc677ae..df5c93b97 100644 --- a/resources/lang/nl/settings.php +++ b/resources/lang/nl/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/pl/settings.php b/resources/lang/pl/settings.php index 0b22c1700..b9abe3311 100644 --- a/resources/lang/pl/settings.php +++ b/resources/lang/pl/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/pt/settings.php b/resources/lang/pt/settings.php index e85ce74b2..83a3f5da0 100644 --- a/resources/lang/pt/settings.php +++ b/resources/lang/pt/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/pt_BR/settings.php b/resources/lang/pt_BR/settings.php index c5b113da3..e2d29f459 100644 --- a/resources/lang/pt_BR/settings.php +++ b/resources/lang/pt_BR/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/ru/settings.php b/resources/lang/ru/settings.php index e4bd85340..cfa802f34 100755 --- a/resources/lang/ru/settings.php +++ b/resources/lang/ru/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/sk/settings.php b/resources/lang/sk/settings.php index 9ec036802..875239aca 100644 --- a/resources/lang/sk/settings.php +++ b/resources/lang/sk/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/sl/settings.php b/resources/lang/sl/settings.php index cadba7bce..a25488d15 100644 --- a/resources/lang/sl/settings.php +++ b/resources/lang/sl/settings.php @@ -249,6 +249,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/sv/settings.php b/resources/lang/sv/settings.php index 1aa51ee38..9065407f7 100644 --- a/resources/lang/sv/settings.php +++ b/resources/lang/sv/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/tr/settings.php b/resources/lang/tr/settings.php index aca4a0628..b95f28b39 100755 --- a/resources/lang/tr/settings.php +++ b/resources/lang/tr/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'İbranice', 'hr' => 'Hrvatski', diff --git a/resources/lang/uk/settings.php b/resources/lang/uk/settings.php index 2c96d4a2b..602fa9bcd 100644 --- a/resources/lang/uk/settings.php +++ b/resources/lang/uk/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/vi/settings.php b/resources/lang/vi/settings.php index 7dbed9018..25dd2da0e 100644 --- a/resources/lang/vi/settings.php +++ b/resources/lang/vi/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/zh_CN/settings.php b/resources/lang/zh_CN/settings.php index ebed8029e..6fa0e84d4 100755 --- a/resources/lang/zh_CN/settings.php +++ b/resources/lang/zh_CN/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => 'עברית', 'hr' => 'Hrvatski', diff --git a/resources/lang/zh_TW/settings.php b/resources/lang/zh_TW/settings.php index aa0a87993..53871de45 100644 --- a/resources/lang/zh_TW/settings.php +++ b/resources/lang/zh_TW/settings.php @@ -248,6 +248,7 @@ return [ 'de_informal' => 'Deutsch (Du)', 'es' => 'Español', 'es_AR' => 'Español Argentina', + 'et' => 'Eesti Keel', 'fr' => 'Français', 'he' => '希伯來語', 'hr' => 'Hrvatski',
    ParameterParameter Details Examples