]> BookStack Code Mirror - bookstack/commitdiff
Merge branch 'openid' of https://github.com/jasperweyne/BookStack into jasperweyne...
authorDan Brown <redacted>
Wed, 6 Oct 2021 12:17:30 +0000 (13:17 +0100)
committerDan Brown <redacted>
Wed, 6 Oct 2021 12:18:21 +0000 (13:18 +0100)
19 files changed:
1  2 
.env.example.complete
app/Auth/Access/ExternalAuthService.php
app/Auth/Access/LoginService.php
app/Auth/Access/Saml2Service.php
app/Config/auth.php
app/Http/Controllers/UserController.php
app/Http/Middleware/VerifyCsrfToken.php
app/Providers/AuthServiceProvider.php
composer.json
composer.lock
resources/lang/en/errors.php
resources/views/auth/parts/login-form-openid.blade.php
resources/views/common/header.blade.php
resources/views/settings/index.blade.php
resources/views/settings/roles/form.blade.php
resources/views/settings/roles/parts/form.blade.php
resources/views/users/parts/form.blade.php
routes/web.php
tests/Auth/AuthTest.php

Simple merge
index 7bd3679ac0653829989a090778ed7e0062bc2c4e,7f15307aea4f54ba97f3432356d345b9478f24ad..b0c9e8e7b7cda5a730181d871d410ca907750488
@@@ -4,10 -2,47 +4,48 @@@ namespace BookStack\Auth\Access
  
  use BookStack\Auth\Role;
  use BookStack\Auth\User;
 +use Illuminate\Support\Collection;
+ use Illuminate\Database\Eloquent\Builder;
+ use Illuminate\Support\Str;
  
  class ExternalAuthService
  {
 -        $user = $this->user->newQuery()
+     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
+     {
 -                'name' => $userDetails['name'],
 -                'email' => $userDetails['email'],
 -                'password' => Str::random(32),
++        $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.
index e02296b37309fa731f6d916a9ae416b22da8685f,0000000000000000000000000000000000000000..b36adb5220f7de11ecf7c1ee706b41dd32251124
mode 100644,000000..100644
--- /dev/null
@@@ -1,164 -1,0 +1,164 @@@
-             $guards = ['standard', 'ldap', 'saml2'];
 +<?php
 +
 +namespace BookStack\Auth\Access;
 +
 +use BookStack\Actions\ActivityType;
 +use BookStack\Auth\Access\Mfa\MfaSession;
 +use BookStack\Auth\User;
 +use BookStack\Exceptions\StoppedAuthenticationException;
 +use BookStack\Facades\Activity;
 +use BookStack\Facades\Theme;
 +use BookStack\Theming\ThemeEvents;
 +use Exception;
 +
 +class LoginService
 +{
 +    protected const LAST_LOGIN_ATTEMPTED_SESSION_KEY = 'auth-login-last-attempted';
 +
 +    protected $mfaSession;
 +    protected $emailConfirmationService;
 +
 +    public function __construct(MfaSession $mfaSession, EmailConfirmationService $emailConfirmationService)
 +    {
 +        $this->mfaSession = $mfaSession;
 +        $this->emailConfirmationService = $emailConfirmationService;
 +    }
 +
 +    /**
 +     * Log the given user into the system.
 +     * Will start a login of the given user but will prevent if there's
 +     * a reason to (MFA or Unconfirmed Email).
 +     * Returns a boolean to indicate the current login result.
 +     *
 +     * @throws StoppedAuthenticationException
 +     */
 +    public function login(User $user, string $method, bool $remember = false): void
 +    {
 +        if ($this->awaitingEmailConfirmation($user) || $this->needsMfaVerification($user)) {
 +            $this->setLastLoginAttemptedForUser($user, $method, $remember);
 +
 +            throw new StoppedAuthenticationException($user, $this);
 +        }
 +
 +        $this->clearLastLoginAttempted();
 +        auth()->login($user, $remember);
 +        Activity::add(ActivityType::AUTH_LOGIN, "{$method}; {$user->logDescriptor()}");
 +        Theme::dispatch(ThemeEvents::AUTH_LOGIN, $method, $user);
 +
 +        // Authenticate on all session guards if a likely admin
 +        if ($user->can('users-manage') && $user->can('user-roles-manage')) {
++            $guards = ['standard', 'ldap', 'saml2', 'openid'];
 +            foreach ($guards as $guard) {
 +                auth($guard)->login($user);
 +            }
 +        }
 +    }
 +
 +    /**
 +     * Reattempt a system login after a previous stopped attempt.
 +     *
 +     * @throws Exception
 +     */
 +    public function reattemptLoginFor(User $user)
 +    {
 +        if ($user->id !== ($this->getLastLoginAttemptUser()->id ?? null)) {
 +            throw new Exception('Login reattempt user does align with current session state');
 +        }
 +
 +        $lastLoginDetails = $this->getLastLoginAttemptDetails();
 +        $this->login($user, $lastLoginDetails['method'], $lastLoginDetails['remember'] ?? false);
 +    }
 +
 +    /**
 +     * Get the last user that was attempted to be logged in.
 +     * Only exists if the last login attempt had correct credentials
 +     * but had been prevented by a secondary factor.
 +     */
 +    public function getLastLoginAttemptUser(): ?User
 +    {
 +        $id = $this->getLastLoginAttemptDetails()['user_id'];
 +
 +        return User::query()->where('id', '=', $id)->first();
 +    }
 +
 +    /**
 +     * Get the details of the last login attempt.
 +     * Checks upon a ttl of about 1 hour since that last attempted login.
 +     *
 +     * @return array{user_id: ?string, method: ?string, remember: bool}
 +     */
 +    protected function getLastLoginAttemptDetails(): array
 +    {
 +        $value = session()->get(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY);
 +        if (!$value) {
 +            return ['user_id' => null, 'method' => null];
 +        }
 +
 +        [$id, $method, $remember, $time] = explode(':', $value);
 +        $hourAgo = time() - (60 * 60);
 +        if ($time < $hourAgo) {
 +            $this->clearLastLoginAttempted();
 +
 +            return ['user_id' => null, 'method' => null];
 +        }
 +
 +        return ['user_id' => $id, 'method' => $method, 'remember' => boolval($remember)];
 +    }
 +
 +    /**
 +     * Set the last login attempted user.
 +     * Must be only used when credentials are correct and a login could be
 +     * achieved but a secondary factor has stopped the login.
 +     */
 +    protected function setLastLoginAttemptedForUser(User $user, string $method, bool $remember)
 +    {
 +        session()->put(
 +            self::LAST_LOGIN_ATTEMPTED_SESSION_KEY,
 +            implode(':', [$user->id, $method, $remember, time()])
 +        );
 +    }
 +
 +    /**
 +     * Clear the last login attempted session value.
 +     */
 +    protected function clearLastLoginAttempted(): void
 +    {
 +        session()->remove(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY);
 +    }
 +
 +    /**
 +     * Check if MFA verification is needed.
 +     */
 +    public function needsMfaVerification(User $user): bool
 +    {
 +        return !$this->mfaSession->isVerifiedForUser($user) && $this->mfaSession->isRequiredForUser($user);
 +    }
 +
 +    /**
 +     * Check if the given user is awaiting email confirmation.
 +     */
 +    public function awaitingEmailConfirmation(User $user): bool
 +    {
 +        return $this->emailConfirmationService->confirmationRequired() && !$user->email_confirmed;
 +    }
 +
 +    /**
 +     * Attempt the login of a user using the given credentials.
 +     * Meant to mirror Laravel's default guard 'attempt' method
 +     * but in a manner that always routes through our login system.
 +     * May interrupt the flow if extra authentication requirements are imposed.
 +     *
 +     * @throws StoppedAuthenticationException
 +     */
 +    public function attempt(array $credentials, string $method, bool $remember = false): bool
 +    {
 +        $result = auth()->attempt($credentials, $remember);
 +        if ($result) {
 +            $user = auth()->user();
 +            auth()->logout();
 +            $this->login($user, $method, $remember);
 +        }
 +
 +        return $result;
 +    }
 +}
index 6cbfdac0b2808a646ea284fab404aa01dbc2fa21,4c1fce8643184b476ae5646d0b9855894f3a911a..74e8c7726e4280e58f89af532fe9b8a29dbacdc3
@@@ -5,10 -3,8 +5,9 @@@ namespace BookStack\Auth\Access
  use BookStack\Auth\User;
  use BookStack\Exceptions\JsonDebugException;
  use BookStack\Exceptions\SamlException;
 +use BookStack\Exceptions\StoppedAuthenticationException;
  use BookStack\Exceptions\UserRegistrationException;
  use Exception;
- use Illuminate\Support\Str;
  use OneLogin\Saml2\Auth;
  use OneLogin\Saml2\Error;
  use OneLogin\Saml2\IdPMetadataParser;
@@@ -27,11 -21,11 +26,13 @@@ class Saml2Service extends ExternalAuth
      /**
       * Saml2Service constructor.
       */
-     public function __construct(RegistrationService $registrationService, LoginService $loginService)
 -    public function __construct(RegistrationService $registrationService, User $user)
++    public function __construct(RegistrationService $registrationService, LoginService $loginService, User $user),
      {
+         parent::__construct($registrationService, $user);
+         
          $this->config = config('saml2');
 +        $this->registrationService = $registrationService;
 +        $this->loginService = $loginService;
      }
  
      /**
index 404b5352dcc2b45537d4407a634171cee7a6c69e,a1824bc78292858468b54cb090b775538c3d2ef7..5b39bafed051d4aba1fe682ae7fbd4590b2408f8
@@@ -37,9 -37,13 +37,13 @@@ return 
              'provider' => 'external',
          ],
          'saml2' => [
 -            'driver' => 'saml2-session',
 +            'driver'   => 'saml2-session',
              'provider' => 'external',
          ],
+         'openid' => [
+             'driver' => 'openid-session',
+             'provider' => 'external',
+         ],
          'api' => [
              'driver' => 'api-token',
          ],
index 804a22bc09a3e35acbe26833940eb215e45a81d7,007564eb3ada1826e38e25a1ca62ff1a04e855ba..a2e7f1dc117c8325432b563313779d9c819e81df
@@@ -20,5 -20,6 +20,6 @@@ class VerifyCsrfToken extends Middlewar
       */
      protected $except = [
          'saml2/*',
 -        'openid/*'
++        'openid/*',
      ];
  }
index 37b0e83b9ac9b6a6e4b390aa4e30d5f0c7d906b5,653a292488995564036761e609b15a81f999a5c4..cd90cc849a895f5a2fa8d38c049eb8e45f993917
@@@ -6,10 -7,11 +6,12 @@@ 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\LoginService;
+ use BookStack\Auth\Access\OpenIdService;
  use BookStack\Auth\Access\RegistrationService;
 -use BookStack\Auth\UserRepo;
 +use Illuminate\Support\Facades\Auth;
  use Illuminate\Support\ServiceProvider;
  
  class AuthServiceProvider extends ServiceProvider
diff --cc composer.json
index 31ecbef84d54c2a3c9900daea88bf12a1bd28cbb,7b1a3d5928973b53dd7f675648972f77b3fc9f2c..288f559913efdb55b1d287f132ef0821978f8575
          "ext-gd": "*",
          "ext-json": "*",
          "ext-mbstring": "*",
 -        "ext-tidy": "*",
          "ext-xml": "*",
 -        "barryvdh/laravel-dompdf": "^0.8.6",
 -        "barryvdh/laravel-snappy": "^0.4.7",
 -        "doctrine/dbal": "^2.9",
 -        "facade/ignition": "^1.4",
 -        "fideloper/proxy": "^4.0",
 -        "gathercontent/htmldiff": "^0.2.1",
 -        "intervention/image": "^2.5",
 -        "laravel/framework": "^6.18",
 -        "laravel/socialite": "^4.3.2",
 -        "league/commonmark": "^1.4",
 -        "league/flysystem-aws-s3-v3": "^1.0",
 -        "nunomaduro/collision": "^3.0",
 -        "onelogin/php-saml": "^3.3",
 -        "predis/predis": "^1.1",
 -        "socialiteproviders/discord": "^2.0",
 -        "socialiteproviders/gitlab": "^3.0",
 -        "socialiteproviders/microsoft-azure": "^3.0",
 -        "socialiteproviders/okta": "^1.0",
 -        "socialiteproviders/slack": "^3.0",
 -        "socialiteproviders/twitch": "^5.0",
 +        "bacon/bacon-qr-code": "^2.0",
 +        "barryvdh/laravel-dompdf": "^0.9.0",
 +        "barryvdh/laravel-snappy": "^0.4.8",
 +        "doctrine/dbal": "^2.12.1",
 +        "facade/ignition": "^1.16.4",
 +        "fideloper/proxy": "^4.4.1",
 +        "intervention/image": "^2.5.1",
 +        "laravel/framework": "^6.20.33",
 +        "laravel/socialite": "^5.1",
 +        "league/commonmark": "^1.5",
 +        "league/flysystem-aws-s3-v3": "^1.0.29",
 +        "league/html-to-markdown": "^5.0.0",
 +        "nunomaduro/collision": "^3.1",
 +        "onelogin/php-saml": "^4.0",
 +        "pragmarx/google2fa": "^8.0",
 +        "predis/predis": "^1.1.6",
 +        "socialiteproviders/discord": "^4.1",
 +        "socialiteproviders/gitlab": "^4.1",
 +        "socialiteproviders/microsoft-azure": "^4.1",
 +        "socialiteproviders/okta": "^4.1",
 +        "socialiteproviders/slack": "^4.1",
 +        "socialiteproviders/twitch": "^5.3",
-         "ssddanbrown/htmldiff": "^v1.0.1"
++        "ssddanbrown/htmldiff": "^v1.0.1",
+         "steverhoades/oauth2-openid-connect-client": "^0.3.0"
      },
      "require-dev": {
 -        "barryvdh/laravel-debugbar": "^3.2.8",
 -        "barryvdh/laravel-ide-helper": "^2.6.4",
 -        "fzaninotto/faker": "^1.4",
 -        "laravel/browser-kit-testing": "^5.1",
 -        "mockery/mockery": "^1.0",
 -        "phpunit/phpunit": "^8.0",
 -        "squizlabs/php_codesniffer": "^3.4",
 -        "wnx/laravel-stats": "^2.0"
 +        "barryvdh/laravel-debugbar": "^3.5.1",
 +        "barryvdh/laravel-ide-helper": "^2.8.2",
 +        "fakerphp/faker": "^1.13.0",
 +        "mockery/mockery": "^1.3.3",
 +        "phpunit/phpunit": "^9.5.3",
 +        "symfony/dom-crawler": "^5.3"
      },
      "autoload": {
          "classmap": [
diff --cc composer.lock
index d267d13d65d6257988fdb0c4a6d51e28ad190299,0f5e29792e950d09396f9365a8f28cacc0bb5e40..a3cfe6e7e570d989d17d17e4e3aa47bceb0c22b6
                  "laravel",
                  "oauth"
              ],
 -            "time": "2020-02-04T15:30:01+00:00"
 +            "support": {
 +                "issues": "https://github.com/laravel/socialite/issues",
 +                "source": "https://github.com/laravel/socialite"
 +            },
 +            "time": "2021-08-31T15:16:26+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": "[email protected]",
+                     "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",
 +            "version": "1.6.6",
              "source": {
                  "type": "git",
                  "url": "https://github.com/thephpleague/commonmark.git",
                  "tumblr",
                  "twitter"
              ],
 -            "time": "2016-08-17T00:36:58+00:00"
 +            "support": {
 +                "issues": "https://github.com/thephpleague/oauth1-client/issues",
 +                "source": "https://github.com/thephpleague/oauth1-client/tree/v1.10.0"
 +            },
 +            "time": "2021-08-15T23:05:49+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": "[email protected]",
+                     "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",
 +            "version": "2.3.4",
              "source": {
                  "type": "git",
                  "url": "https://github.com/Seldaek/monolog.git",
              },
              "dist": {
                  "type": "zip",
 -                "url": "https://api.github.com/repos/steverhoades/oauth2-openid-connect-client/zipball/0159471487540a4620b8d0b693f5f215503a8d75",
 +                "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": "[email protected]",
++                    "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"
++            },
++            "funding": [
++                {
++                    "url": "https://github.com/ssddanbrown",
++                    "type": "github"
++                }
++            ],
++            "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": "[email protected]"
+                 }
+             ],
+             "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": "[email protected]",
 +                    "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"
 +            },
 +            "funding": [
 +                {
 +                    "url": "https://github.com/ssddanbrown",
 +                    "type": "github"
 +                }
 +            ],
 +            "time": "2021-01-24T18:51:30+00:00"
 +        },
 +        {
 +            "name": "swiftmailer/swiftmailer",
 +            "version": "v6.2.7",
              "source": {
                  "type": "git",
                  "url": "https://github.com/swiftmailer/swiftmailer.git",
Simple merge
index 0000000000000000000000000000000000000000,b1ef51d1363a7c44a122c7774163868a1818484a..ba975ebf4e914e7bd76324a0f62bb456b7730536
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,11 +1,11 @@@
 -</form>
+ <form action="{{ url('/openid/login') }}" method="POST" id="login-form" class="mt-l">
+     {!! csrf_field() !!}
+     <div>
+         <button id="saml-login" class="button outline block svg">
+             @icon('saml2')
+             <span>{{ trans('auth.log_in_with', ['socialDriver' => config('openid.name')]) }}</span>
+         </button>
+     </div>
++</form>
index 0000000000000000000000000000000000000000,b5aa96911e36e699e893fed8d9a3c81d0689d6fa..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
mode 000000,100644..100644
--- /dev/null
index 2f94398b5449846b89c57b3b7686bbe3369686ee,0000000000000000000000000000000000000000..3f7f8fd1fc982d63c5f0989f64f21e43e5fbb5f0
mode 100644,000000..100644
--- /dev/null
@@@ -1,267 -1,0 +1,267 @@@
-                 @if(config('auth.method') === 'ldap' || config('auth.method') === 'saml2')
 +{!! csrf_field() !!}
 +
 +<div class="card content-wrap">
 +    <h1 class="list-heading">{{ $title }}</h1>
 +
 +    <div class="setting-list">
 +
 +        <div class="grid half">
 +            <div>
 +                <label class="setting-list-label">{{ trans('settings.role_details') }}</label>
 +            </div>
 +            <div>
 +                <div class="form-group">
 +                    <label for="display_name">{{ trans('settings.role_name') }}</label>
 +                    @include('form.text', ['name' => 'display_name'])
 +                </div>
 +                <div class="form-group">
 +                    <label for="description">{{ trans('settings.role_desc') }}</label>
 +                    @include('form.text', ['name' => 'description'])
 +                </div>
 +                <div class="form-group">
 +                    @include('form.checkbox', ['name' => 'mfa_enforced', 'label' => trans('settings.role_mfa_enforced') ])
 +                </div>
 +
++                @if(in_array(config('auth.method'), ['ldap', 'saml2', 'openid']))
 +                    <div class="form-group">
 +                        <label for="name">{{ trans('settings.role_external_auth_id') }}</label>
 +                        @include('form.text', ['name' => 'external_auth_id'])
 +                    </div>
 +                @endif
 +            </div>
 +        </div>
 +
 +        <div permissions-table>
 +            <label class="setting-list-label">{{ trans('settings.role_system') }}</label>
 +            <a href="#" permissions-table-toggle-all class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
 +
 +            <div class="toggle-switch-list grid half mt-m">
 +                <div>
 +                    <div>@include('settings.roles.parts.checkbox', ['permission' => 'restrictions-manage-all', 'label' => trans('settings.role_manage_entity_permissions')])</div>
 +                    <div>@include('settings.roles.parts.checkbox', ['permission' => 'restrictions-manage-own', 'label' => trans('settings.role_manage_own_entity_permissions')])</div>
 +                    <div>@include('settings.roles.parts.checkbox', ['permission' => 'templates-manage', 'label' => trans('settings.role_manage_page_templates')])</div>
 +                    <div>@include('settings.roles.parts.checkbox', ['permission' => 'access-api', 'label' => trans('settings.role_access_api')])</div>
 +                    <div>@include('settings.roles.parts.checkbox', ['permission' => 'content-export', 'label' => trans('settings.role_export_content')])</div>
 +                </div>
 +                <div>
 +                    <div>@include('settings.roles.parts.checkbox', ['permission' => 'settings-manage', 'label' => trans('settings.role_manage_settings')])</div>
 +                    <div>@include('settings.roles.parts.checkbox', ['permission' => 'users-manage', 'label' => trans('settings.role_manage_users')])</div>
 +                    <div>@include('settings.roles.parts.checkbox', ['permission' => 'user-roles-manage', 'label' => trans('settings.role_manage_roles')])</div>
 +                    <p class="text-warn text-small mt-s mb-none">{{ trans('settings.roles_system_warning') }}</p>
 +                </div>
 +            </div>
 +        </div>
 +
 +        <div>
 +            <label class="setting-list-label">{{ trans('settings.role_asset') }}</label>
 +            <p>{{ trans('settings.role_asset_desc') }}</p>
 +
 +            @if (isset($role) && $role->system_name === 'admin')
 +                <p class="text-warn">{{ trans('settings.role_asset_admins') }}</p>
 +            @endif
 +
 +            <table permissions-table class="table toggle-switch-list compact permissions-table">
 +                <tr>
 +                    <th width="20%">
 +                        <a href="#" permissions-table-toggle-all class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
 +                    </th>
 +                    <th width="20%" permissions-table-toggle-all-in-column>{{ trans('common.create') }}</th>
 +                    <th width="20%" permissions-table-toggle-all-in-column>{{ trans('common.view') }}</th>
 +                    <th width="20%" permissions-table-toggle-all-in-column>{{ trans('common.edit') }}</th>
 +                    <th width="20%" permissions-table-toggle-all-in-column>{{ trans('common.delete') }}</th>
 +                </tr>
 +                <tr>
 +                    <td>
 +                        <div>{{ trans('entities.shelves_long') }}</div>
 +                        <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
 +                    </td>
 +                    <td>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-create-all', 'label' => trans('settings.role_all')])
 +                    </td>
 +                    <td>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-view-own', 'label' => trans('settings.role_own')])
 +                        <br>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-view-all', 'label' => trans('settings.role_all')])
 +                    </td>
 +                    <td>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-update-own', 'label' => trans('settings.role_own')])
 +                        <br>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-update-all', 'label' => trans('settings.role_all')])
 +                    </td>
 +                    <td>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-delete-own', 'label' => trans('settings.role_own')])
 +                        <br>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-delete-all', 'label' => trans('settings.role_all')])
 +                    </td>
 +                </tr>
 +                <tr>
 +                    <td>
 +                        <div>{{ trans('entities.books') }}</div>
 +                        <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
 +                    </td>
 +                    <td>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'book-create-all', 'label' => trans('settings.role_all')])
 +                    </td>
 +                    <td>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'book-view-own', 'label' => trans('settings.role_own')])
 +                        <br>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'book-view-all', 'label' => trans('settings.role_all')])
 +                    </td>
 +                    <td>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'book-update-own', 'label' => trans('settings.role_own')])
 +                        <br>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'book-update-all', 'label' => trans('settings.role_all')])
 +                    </td>
 +                    <td>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'book-delete-own', 'label' => trans('settings.role_own')])
 +                        <br>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'book-delete-all', 'label' => trans('settings.role_all')])
 +                    </td>
 +                </tr>
 +                <tr>
 +                    <td>
 +                        <div>{{ trans('entities.chapters') }}</div>
 +                        <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
 +                    </td>
 +                    <td>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'chapter-create-own', 'label' => trans('settings.role_own')])
 +                        <br>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'chapter-create-all', 'label' => trans('settings.role_all')])
 +                    </td>
 +                    <td>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'chapter-view-own', 'label' => trans('settings.role_own')])
 +                        <br>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'chapter-view-all', 'label' => trans('settings.role_all')])
 +                    </td>
 +                    <td>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'chapter-update-own', 'label' => trans('settings.role_own')])
 +                        <br>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'chapter-update-all', 'label' => trans('settings.role_all')])
 +                    </td>
 +                    <td>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'chapter-delete-own', 'label' => trans('settings.role_own')])
 +                        <br>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'chapter-delete-all', 'label' => trans('settings.role_all')])
 +                    </td>
 +                </tr>
 +                <tr>
 +                    <td>
 +                        <div>{{ trans('entities.pages') }}</div>
 +                        <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
 +                    </td>
 +                    <td>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'page-create-own', 'label' => trans('settings.role_own')])
 +                        <br>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'page-create-all', 'label' => trans('settings.role_all')])
 +                    </td>
 +                    <td>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'page-view-own', 'label' => trans('settings.role_own')])
 +                        <br>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'page-view-all', 'label' => trans('settings.role_all')])
 +                    </td>
 +                    <td>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'page-update-own', 'label' => trans('settings.role_own')])
 +                        <br>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'page-update-all', 'label' => trans('settings.role_all')])
 +                    </td>
 +                    <td>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'page-delete-own', 'label' => trans('settings.role_own')])
 +                        <br>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'page-delete-all', 'label' => trans('settings.role_all')])
 +                    </td>
 +                </tr>
 +                <tr>
 +                    <td>
 +                        <div>{{ trans('entities.images') }}</div>
 +                        <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
 +                    </td>
 +                    <td>@include('settings.roles.parts.checkbox', ['permission' => 'image-create-all', 'label' => ''])</td>
 +                    <td style="line-height:1.2;"><small class="faded">{{ trans('settings.role_controlled_by_asset') }}</small></td>
 +                    <td>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'image-update-own', 'label' => trans('settings.role_own')])
 +                        <br>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'image-update-all', 'label' => trans('settings.role_all')])
 +                    </td>
 +                    <td>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'image-delete-own', 'label' => trans('settings.role_own')])
 +                        <br>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'image-delete-all', 'label' => trans('settings.role_all')])
 +                    </td>
 +                </tr>
 +                <tr>
 +                    <td>
 +                        <div>{{ trans('entities.attachments') }}</div>
 +                        <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
 +                    </td>
 +                    <td>@include('settings.roles.parts.checkbox', ['permission' => 'attachment-create-all', 'label' => ''])</td>
 +                    <td style="line-height:1.2;"><small class="faded">{{ trans('settings.role_controlled_by_asset') }}</small></td>
 +                    <td>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'attachment-update-own', 'label' => trans('settings.role_own')])
 +                        <br>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'attachment-update-all', 'label' => trans('settings.role_all')])
 +                    </td>
 +                    <td>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'attachment-delete-own', 'label' => trans('settings.role_own')])
 +                        <br>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'attachment-delete-all', 'label' => trans('settings.role_all')])
 +                    </td>
 +                </tr>
 +                <tr>
 +                    <td>
 +                        <div>{{ trans('entities.comments') }}</div>
 +                        <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
 +                    </td>
 +                    <td>@include('settings.roles.parts.checkbox', ['permission' => 'comment-create-all', 'label' => ''])</td>
 +                    <td style="line-height:1.2;"><small class="faded">{{ trans('settings.role_controlled_by_asset') }}</small></td>
 +                    <td>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'comment-update-own', 'label' => trans('settings.role_own')])
 +                        <br>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'comment-update-all', 'label' => trans('settings.role_all')])
 +                    </td>
 +                    <td>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'comment-delete-own', 'label' => trans('settings.role_own')])
 +                        <br>
 +                        @include('settings.roles.parts.checkbox', ['permission' => 'comment-delete-all', 'label' => trans('settings.role_all')])
 +                    </td>
 +                </tr>
 +            </table>
 +        </div>
 +    </div>
 +
 +    <div class="form-group text-right">
 +        <a href="{{ url("/settings/roles") }}" class="button outline">{{ trans('common.cancel') }}</a>
 +        @if (isset($role) && $role->id)
 +            <a href="{{ url("/settings/roles/delete/{$role->id}") }}" class="button outline">{{ trans('settings.role_delete') }}</a>
 +        @endif
 +        <button type="submit" class="button">{{ trans('settings.role_save') }}</button>
 +    </div>
 +
 +</div>
 +
 +<div class="card content-wrap auto-height">
 +    <h2 class="list-heading">{{ trans('settings.role_users') }}</h2>
 +    @if(count($role->users ?? []) > 0)
 +        <div class="grid third">
 +            @foreach($role->users as $user)
 +                <div class="user-list-item">
 +                    <div>
 +                        <img class="avatar small" src="{{ $user->getAvatar(40) }}" alt="{{ $user->name }}">
 +                    </div>
 +                    <div>
 +                        @if(userCan('users-manage') || user()->id == $user->id)
 +                            <a href="{{ url("/settings/users/{$user->id}") }}">
 +                                @endif
 +                                {{ $user->name }}
 +                                @if(userCan('users-manage') || user()->id == $user->id)
 +                            </a>
 +                        @endif
 +                    </div>
 +                </div>
 +            @endforeach
 +        </div>
 +    @else
 +        <p class="text-muted">
 +            {{ trans('settings.role_users_none') }}
 +        </p>
 +    @endif
 +</div>
index 7105e2ff14458578f978249728879bb1667b6c8d,f3e8cedff7f4fb151a908669efab605a26226ca5..ef8c611ef0610ed6ff674a5021d82510a9bfbb5c
@@@ -25,7 -25,7 +25,7 @@@
      </div>
  </div>
  
- @if(($authMethod === 'ldap' || $authMethod === 'saml2') && userCan('users-manage'))
 -@if(($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') && userCan('users-manage'))
++@if(in_array($authMethod, ['ldap', 'saml2', 'openid']) && userCan('users-manage'))
      <div class="grid half gap-xl v-center">
          <div>
              <label class="setting-list-label">{{ trans('settings.users_external_auth_id') }}</label>
diff --cc routes/web.php
Simple merge
index d037b57011fada64a1c1755ab9418b9ea03af828,779d5e70fc228ba3c631c014160b8e01347c4ce5..1ffcc0815097bea1d78c1a5556292af5032f4d45
@@@ -330,39 -400,17 +331,40 @@@ class AuthTest extends TestCas
          $this->assertTrue(auth()->check());
          $this->assertFalse(auth('ldap')->check());
          $this->assertFalse(auth('saml2')->check());
+         $this->assertFalse(auth('openid')->check());
      }
  
 +    public function test_failed_logins_are_logged_when_message_configured()
 +    {
 +        $log = $this->withTestLogger();
 +        config()->set(['logging.failed_login.message' => 'Failed login for %u']);
 +
 +        $this->post('/login', ['email' => '[email protected]', 'password' => 'cattreedog']);
 +        $this->assertTrue($log->hasWarningThatContains('Failed login for [email protected]'));
 +
 +        $this->post('/login', ['email' => '[email protected]', 'password' => 'password']);
 +        $this->assertFalse($log->hasWarningThatContains('Failed login for [email protected]'));
 +    }
 +
 +    public function test_logged_in_user_with_unconfirmed_email_is_logged_out()
 +    {
 +        $this->setSettings(['registration-confirmation' => 'true']);
 +        $user = $this->getEditor();
 +        $user->email_confirmed = false;
 +        $user->save();
 +
 +        auth()->login($user);
 +        $this->assertTrue(auth()->check());
 +
 +        $this->get('/books')->assertRedirect('/');
 +        $this->assertFalse(auth()->check());
 +    }
 +
      /**
 -     * Perform a login
 +     * Perform a login.
       */
 -    protected function login(string $email, string $password): AuthTest
 +    protected function login(string $email, string $password): TestResponse
      {
 -        return $this->visit('/login')
 -            ->type($email, '#email')
 -            ->type($password, '#password')
 -            ->press('Log In');
 +        return $this->post('/login', compact('email', 'password'));
      }
  }