Ver código fonte

Complete SSO (user model, error cases, tests, views) & Add github provider

Bubka 1 ano atrás
pai
commit
9ff35195f0

+ 7 - 1
.env.example

@@ -221,7 +221,10 @@ WEBAUTHN_ID=null
 
 WEBAUTHN_USER_VERIFICATION=preferred
 
-### OpenID settings ###
+
+#### SSO settings (for Socialite) ####
+
+# Uncomment lines for the OAuth providers you need.
 
 # OPENID_AUTHORIZE_URL=
 # OPENID_TOKEN_URL=
@@ -229,6 +232,9 @@ WEBAUTHN_USER_VERIFICATION=preferred
 # OPENID_CLIENT_ID=
 # OPENID_CLIENT_SECRET=
 
+# GITHUB_CLIENT_ID=
+# GITHUB_CLIENT_SECRET=
+
 
 # Use this setting to declare trusted proxied.
 # Supported:

+ 7 - 5
app/Api/v1/Resources/UserResource.php

@@ -8,6 +8,7 @@ use Illuminate\Http\Resources\Json\JsonResource;
  * @property mixed $id
  * @property string $name
  * @property string $email
+ * @property string $oauth_provider
  * @property \Illuminate\Support\Collection<array-key, mixed> $preferences
  * @property string $is_admin
  */
@@ -22,11 +23,12 @@ class UserResource extends JsonResource
     public function toArray($request)
     {
         return [
-            'id'          => $this->id,
-            'name'        => $this->name,
-            'email'       => $this->email,
-            'preferences' => $this->preferences,
-            'is_admin'    => $this->is_admin,
+            'id'             => $this->id,
+            'name'           => $this->name,
+            'email'          => $this->email,
+            'oauth_provider' => $this->oauth_provider,
+            'preferences'    => $this->preferences,
+            'is_admin'       => $this->is_admin,
         ];
     }
 }

+ 9 - 2
app/Http/Controllers/Auth/PasswordController.php

@@ -17,8 +17,15 @@ class PasswordController extends Controller
      */
     public function update(UserPatchPwdRequest $request)
     {
+        $user      = $request->user();
         $validated = $request->validated();
 
+        if (config('auth.defaults.guard') === 'reverse-proxy-guard' || $user->oauth_provider) {
+            Log::notice('Password update rejected: reverse-proxy-guard enabled or account from external sso provider');
+
+            return response()->json(['message' => __('errors.account_managed_by_external_provider')], 400);
+        }
+
         if (! Hash::check($validated['currentPassword'], Auth::user()->password)) {
             Log::notice('Password update failed: wrong password provided');
 
@@ -26,10 +33,10 @@ class PasswordController extends Controller
         }
 
         if (! config('2fauth.config.isDemoApp')) {
-            $request->user()->update([
+            $user->update([
                 'password' => bcrypt($validated['password']),
             ]);
-            Log::info(sprintf('Password of user ID #%s updated', $request->user()->id));
+            Log::info(sprintf('Password of user ID #%s updated', $user->id));
         }
 
         return response()->json(['message' => __('auth.forms.password_successfully_changed')]);

+ 39 - 13
app/Http/Controllers/Auth/SocialiteController.php

@@ -13,32 +13,58 @@ use Laravel\Socialite\Facades\Socialite;
 
 class SocialiteController extends Controller
 {
-    public function redirect(Request $request, $driver)
+    /**
+     * Redirect to the provider's authentication url
+     * 
+     * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Illuminate\Http\RedirectResponse
+     */
+    public function redirect(Request $request, string $driver)
     {
-        return Socialite::driver($driver)->redirect();
+        if (! config('services.' . $driver . '.client_id') || ! config('services.' . $driver . '.client_secret')) {
+            return redirect('/error?err=sso_bad_provider_setup');
+        }
+
+        return Settings::get('enableSso')
+            ? Socialite::driver($driver)->redirect()
+            : redirect('/error?err=sso_disabled');
     }
 
-    public function callback(Request $request, $driver)
+    /**
+     * Register (if needed) the user and authenticate him
+     * 
+     * @return \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse
+     */
+    public function callback(Request $request, string $driver)
     {
-        $socialiteUser = Socialite::driver($driver)->user();
+        try {
+            $socialiteUser = Socialite::driver($driver)->user();
+        } catch (\Exception $e) {
+            return redirect('/error?err=sso_failed');
+        }
 
-        /** @var User $user */
+        /** @var User|null $user */
         $user = User::firstOrNew([
-            'email' => $socialiteUser->getEmail(),
-        ], [
-            'name' => $socialiteUser->getName(),
-            'password' => bcrypt(Str::random()),
+            'oauth_id'       => $socialiteUser->getId(),
+            'oauth_provider' => $driver,
         ]);
 
-        if (!$user->exists && Settings::get('disableRegistrationSso')) {
-            return response(401);
+        if (! $user->exists) {
+            if (User::count() === 0) {
+                $user->is_admin = true;
+            }
+            else if (Settings::get('disableRegistration')) {
+                return redirect('/error?err=no_register');
+            }
+            $user->password = bcrypt(Str::random());
         }
 
+        $user->email        = $socialiteUser->getEmail() ?? $socialiteUser->getId() . '@' . $driver;
+        $user->name         = $socialiteUser->getNickname() ?? $socialiteUser->getName() ?? $driver . ' #' . $socialiteUser->getId();
         $user->last_seen_at = Carbon::now()->format('Y-m-d H:i:s');
         $user->save();
 
-        Auth::guard()->login($user, true);
+        Auth::guard()->login($user);
 
-        return redirect('/accounts?authenticated');
+        return redirect('/accounts');
     }
 }

+ 6 - 0
app/Http/Controllers/Auth/UserController.php

@@ -24,6 +24,12 @@ class UserController extends Controller
         $user      = $request->user();
         $validated = $request->validated();
 
+        if (config('auth.defaults.guard') === 'reverse-proxy-guard' || $user->oauth_provider) {
+            Log::notice('Account update rejected: reverse-proxy-guard enabled or account from external sso provider');
+
+            return response()->json(['message' => __('errors.account_managed_by_external_provider')], 400);
+        }
+
         if (! Hash::check($request->password, Auth::user()->password)) {
             Log::notice('Account update failed: wrong password provided');
 

+ 6 - 2
app/Http/Controllers/SinglePageController.php

@@ -27,7 +27,8 @@ class SinglePageController extends Controller
         $isTestingApp       = config('2fauth.config.isTestingApp') ? 'true' : 'false';
         $lang               = App::getLocale();
         $locales            = collect(config('2fauth.locales'))->toJson(); /** @phpstan-ignore-line */
-        $openidAuth      = config('services.openid.client_secret') ? true : false;
+        $openidAuth         = config('services.openid.client_secret') ? true : false;
+        $githubAuth         = config('services.github.client_secret') ? true : false;
 
         // if (Auth::user()->preferences)
 
@@ -36,7 +37,10 @@ class SinglePageController extends Controller
             'appConfig'   => collect([
                 'proxyAuth'      => $proxyAuth,
                 'proxyLogoutUrl' => $proxyLogoutUrl,
-                'openidAuth'     => $openidAuth,
+                'sso'            => [
+                    'openid' => $openidAuth,
+                    'github' => $githubAuth,
+                ],
                 'subdirectory'   => $subdir,
             ])->toJson(),
             'defaultPreferences' => $defaultPreferences,

+ 29 - 0
app/Listeners/RegisterOpenId.php

@@ -0,0 +1,29 @@
+<?php
+
+namespace App\Listeners;
+
+use App\Providers\Socialite\OpenId;
+use SocialiteProviders\Manager\SocialiteWasCalled;
+
+class RegisterOpenId
+{
+    /**
+     * Create the event listener.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        //
+    }
+
+    /**
+     * Handle the event.
+     *
+     * @return void
+     */
+    public function handle(SocialiteWasCalled $socialiteWasCalled)
+    {
+        $socialiteWasCalled->extendSocialite('openid', OpenId::class);
+    }
+}

+ 1 - 1
app/Models/User.php

@@ -49,7 +49,7 @@ class User extends Authenticatable implements WebAuthnAuthenticatable
      * @var string[]
      */
     protected $fillable = [
-        'name', 'email', 'password',
+        'name', 'email', 'password', 'oauth_id', 'oauth_provider'
     ];
 
     /**

+ 1 - 1
app/Providers/EventServiceProvider.php

@@ -10,7 +10,7 @@ use App\Listeners\CleanIconStorage;
 use App\Listeners\DissociateTwofaccountFromGroup;
 use App\Listeners\ReleaseRadar;
 use App\Listeners\ResetUsersPreference;
-use App\Providers\Socialite\RegisterOpenId;
+use App\Listeners\RegisterOpenId;
 use Illuminate\Auth\Events\Registered;
 use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
 use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;

+ 4 - 2
app/Providers/Socialite/OpenId.php

@@ -56,9 +56,11 @@ class OpenId extends AbstractProvider
     }
 
     /**
-     * {@inheritdoc}
+     * Get a refresh token
+     * 
+     * @return \Psr\Http\Message\ResponseInterface
      */
-    protected function refreshToken($refreshToken)
+    protected function refreshToken(string $refreshToken)
     {
         return $this->getHttpClient()->post($this->getTokenUrl(), [
             RequestOptions::FORM_PARAMS => [

+ 0 - 13
app/Providers/Socialite/RegisterOpenId.php

@@ -1,13 +0,0 @@
-<?php
-
-namespace App\Providers\Socialite;
-
-use SocialiteProviders\Manager\SocialiteWasCalled;
-
-class RegisterOpenId
-{
-    public function __invoke(SocialiteWasCalled $socialiteWasCalled)
-    {
-        $socialiteWasCalled->extendSocialite('openid', OpenId::class);
-    }
-}

+ 1 - 1
config/2fauth.php

@@ -71,7 +71,7 @@ return [
         'lastRadarScan' => 0,
         'latestRelease' => false,
         'disableRegistration' => false,
-        'disableRegistrationSso' => false,
+        'enableSso' => true,
     ],
 
     /*

+ 2 - 2
config/app.php

@@ -159,7 +159,7 @@ return [
         /*
          * Package Service Providers...
          */
-
+        \SocialiteProviders\Manager\ServiceProvider::class,
         /*
          * Application Service Providers...
          */
@@ -170,7 +170,7 @@ return [
         App\Providers\RouteServiceProvider::class,
         App\Providers\TwoFAuthServiceProvider::class,
         App\Providers\MigrationServiceProvider::class,
-        ])->toArray(),
+    ])->toArray(),
 
     /*
     |--------------------------------------------------------------------------

+ 21 - 0
config/services.php

@@ -21,6 +21,27 @@ return [
         'scheme' => 'https',
     ],
 
+    'openid' => [
+        'token_url' => env('OPENID_TOKEN_URL'),
+        'authorize_url' => env('OPENID_AUTHORIZE_URL'),
+        'userinfo_url' => env('OPENID_USERINFO_URL'),
+        'client_id' => env('OPENID_CLIENT_ID'),
+        'client_secret' => env('OPENID_CLIENT_SECRET'),
+        'redirect' => '/socialite/callback/openid',
+    ],
+
+    'github' => [
+        'client_id' => env('GITHUB_CLIENT_ID'),
+        'client_secret' => env('GITHUB_CLIENT_SECRET'),
+        'redirect' => '/socialite/callback/github',
+    ],
+
+    // 'google' => [    
+    //     'client_id' => env('GOOGLE_CLIENT_ID'),  
+    //     'client_secret' => env('GOOGLE_CLIENT_SECRET'),  
+    //     'redirect' => '/socialite/callback/google ',
+    // ],
+
     'postmark' => [
         'token' => env('POSTMARK_TOKEN'),
     ],

+ 0 - 11
config/services/openid.php

@@ -1,11 +0,0 @@
-<?php
-
-return [
-    'token_url' => env('OPENID_TOKEN_URL'),
-    'authorize_url' => env('OPENID_AUTHORIZE_URL'),
-    'userinfo_url' => env('OPENID_USERINFO_URL'),
-
-    'client_id' => env('OPENID_CLIENT_ID'),
-    'client_secret' => env('OPENID_CLIENT_SECRET'),
-    'redirect' => '/socialite/callback/openid',
-];

+ 34 - 0
database/migrations/2023_12_06_131842_add_oauth_columns_to_user.php

@@ -0,0 +1,34 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('users', function (Blueprint $table) {
+            $table->string('oauth_provider', 100)
+                ->after('id')
+                ->nullable();
+            $table->string('oauth_id', 200)
+                ->after('id')
+                ->nullable();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('users', function (Blueprint $table) {
+            $table->dropColumn('oauth_id');
+            $table->dropColumn('oauth_provider');
+        });
+    }
+};

+ 3 - 1
resources/js/icons.js

@@ -53,7 +53,8 @@ import {
 } from '@fortawesome/free-regular-svg-icons'
 
 import {
-    faGithubAlt
+    faGithubAlt,
+    faOpenid
 } from '@fortawesome/free-brands-svg-icons'
 
 library.add(
@@ -103,6 +104,7 @@ library.add(
     faVideoSlash,
     faStar,
     faChevronRight,
+    faOpenid,
 );
 
 export default FontAwesomeIcon

+ 1 - 0
resources/js/router/middlewares/authGuard.js

@@ -11,6 +11,7 @@ export default async function authGuard({ to, next, nextMiddleware, stores }) {
             await user.loginAs({
                 name: currentUser.name,
                 email: currentUser.email,
+                oauth_provider: currentUser.oauth_provider,
                 preferences: currentUser.preferences,
                 isAdmin: currentUser.is_admin,
             })

+ 1 - 1
resources/js/router/middlewares/noEmptyError.js

@@ -1,7 +1,7 @@
 export default function noEmptyError({ to, next, nextMiddleware, stores }) {
     const { notify } = stores
 
-    if (notify.err == null) {
+    if (notify.err == null && ! to.query.err) {
         // return to home if no err object is set to prevent an empty error message
         next({ name: 'accounts' });
     }

+ 1 - 0
resources/js/stores/user.js

@@ -13,6 +13,7 @@ export const useUserStore = defineStore({
         return {
             name: undefined,
             email: undefined,
+            oauth_provider: undefined,
             preferences: window.defaultPreferences,
             isAdmin: false,
         }

+ 7 - 1
resources/js/views/Error.vue

@@ -21,11 +21,17 @@
         }
     })
 
+    onMounted(() => {
+        if (route.query.err) {
+            errorHandler.message = trans('errors.' + route.query.err)
+        }
+    })
+
     /**
      * Exits the error view
      */
     function exit() {
-        window.history.length > 1 && route.name !== '404' && route.name !== 'notFound'
+        window.history.length > 1 && route.name !== '404' && route.name !== 'notFound' && !route.query.err
             ? router.go(-1)
             : router.push({ name: 'accounts' })
     }

+ 28 - 10
resources/js/views/auth/Login.vue

@@ -35,6 +35,7 @@
             await user.loginAs({
                 name: response.data.name,
                 email: response.data.email,
+                oauth_provider: response.data.oauth_provider,
                 preferences: response.data.preferences,
                 isAdmin: response.data.is_admin,
             })
@@ -63,6 +64,7 @@
             await user.loginAs({
                 name: response.data.name,
                 email: response.data.email,
+                oauth_provider: response.data.oauth_provider,
                 preferences: response.data.preferences,
                 isAdmin: response.data.is_admin,
             })
@@ -115,17 +117,25 @@
                     {{ $t('auth.login_and_password') }}
                 </a>
             </p>
-            <p v-if="appSettings.openidAuth">{{ $t('auth.sign_in_using') }}&nbsp;
-                <a id="lnkSignWithOpenID" class="is-link" href="/socialite/redirect/openid">
-                    OpenID
-                </a>
-            </p>
             <p v-if="appSettings.disableRegistration == false" class="mt-4">
                 {{ $t('auth.forms.dont_have_account_yet') }}&nbsp;
                 <RouterLink id="lnkRegister" :to="{ name: 'register' }" class="is-link">
                     {{ $t('auth.register') }}
                 </RouterLink>
             </p>
+            <div v-if="appSettings.enableSso" class="columns mt-4 is-variable is-1">
+                <div class="column is-narrow py-1">
+                    {{ $t('auth.or_continue_with') }}
+                </div>
+                <div class="column py-1">
+                    <a v-if="$2fauth.config.sso.openid" id="lnkSignWithOpenID" class="button is-link is-outlined is-small ml-2" href="/socialite/redirect/openid">
+                        OpenID<FontAwesomeIcon class="ml-2" :icon="['fab', 'openid']" />
+                    </a>
+                    <a v-if="$2fauth.config.sso.github" id="lnkSignWithGithub" class="button is-link is-outlined is-small ml-2" href="/socialite/redirect/github">
+                        Github<FontAwesomeIcon class="ml-2" :icon="['fab', 'github-alt']" />
+                    </a>
+                </div>
+            </div>
         </div>
     </FormWrapper>
     <!-- login/password legacy form -->
@@ -148,17 +158,25 @@
                     {{ $t('auth.webauthn.security_device') }}
                 </a>
             </p>
-            <p v-if="appSettings.openidAuth">{{ $t('auth.sign_in_using') }}&nbsp;
-                <a id="lnkSignWithOpenID" class="is-link" href="/socialite/redirect/openid">
-                    OpenID
-                </a>
-            </p>
             <p v-if="appSettings.disableRegistration == false" class="mt-4">
                 {{ $t('auth.forms.dont_have_account_yet') }}&nbsp;
                 <RouterLink id="lnkRegister" :to="{ name: 'register' }" class="is-link">
                     {{ $t('auth.register') }}
                 </RouterLink>
             </p>
+            <div v-if="appSettings.enableSso" class="columns mt-4 is-variable is-1">
+                <div class="column is-narrow py-1">
+                    {{ $t('auth.or_continue_with') }}
+                </div>
+                <div class="column py-1">
+                    <a v-if="$2fauth.config.sso.openid" id="lnkSignWithOpenID" class="button is-link is-outlined is-small mr-2" href="/socialite/redirect/openid">
+                        OpenID<FontAwesomeIcon class="ml-2" :icon="['fab', 'openid']" />
+                    </a>
+                    <a v-if="$2fauth.config.sso.github" id="lnkSignWithGithub" class="button is-link is-outlined is-small mr-2" href="/socialite/redirect/github">
+                        Github<FontAwesomeIcon class="ml-2" :icon="['fab', 'github-alt']" />
+                    </a>
+                </div>
+            </div>
         </div>
     </FormWrapper>
     <!-- footer -->

+ 8 - 3
resources/js/views/settings/Account.vue

@@ -107,10 +107,13 @@
                 <div v-if="user.isAdmin" class="notification is-warning">
                     {{ $t('settings.you_are_administrator') }}
                 </div>
+                <div v-if="user.oauth_provider" class="notification is-info">
+                    {{ $t('settings.account_linked_to_sso_x_provider', { provider: user.oauth_provider }) }}
+                </div>
                 <form @submit.prevent="submitProfile" @keydown="formProfile.onKeydown($event)">
                     <div v-if="$2fauth.config.proxyAuth" class="notification is-warning has-text-centered" v-html="$t('auth.user_account_controlled_by_proxy')" />
                     <h4 class="title is-4 has-text-grey-light">{{ $t('settings.profile') }}</h4>
-                    <fieldset :disabled="$2fauth.config.proxyAuth">
+                    <fieldset :disabled="$2fauth.config.proxyAuth || user.oauth_provider">
                         <FormField v-model="formProfile.name" fieldName="name" :fieldError="formProfile.errors.get('name')" label="auth.forms.name" :maxLength="255" autofocus />
                         <FormField v-model="formProfile.email" fieldName="email" :fieldError="formProfile.errors.get('email')" inputType="email" label="auth.forms.email" :maxLength="255" autofocus />
                         <FormField v-model="formProfile.password" fieldName="password" :fieldError="formProfile.errors.get('password')" inputType="password" label="auth.forms.current_password.label" help="auth.forms.current_password.help" />
@@ -119,7 +122,7 @@
                 </form>
                 <form @submit.prevent="submitPassword" @keydown="formPassword.onKeydown($event)">
                     <h4 class="title is-4 pt-6 has-text-grey-light">{{ $t('settings.change_password') }}</h4>
-                    <fieldset :disabled="$2fauth.config.proxyAuth">
+                    <fieldset :disabled="$2fauth.config.proxyAuth || user.oauth_provider">
                         <FormPasswordField v-model="formPassword.password" fieldName="password" :fieldError="formPassword.errors.get('password')" :autocomplete="'new-password'" :showRules="true" label="auth.forms.new_password" />
                         <FormPasswordField v-model="formPassword.password_confirmation" :showRules="false" fieldName="password_confirmation" :fieldError="formPassword.errors.get('password_confirmation')" inputType="password" :autocomplete="'new-password'" label="auth.forms.confirm_new_password" />
                         <FormField v-model="formPassword.currentPassword" fieldName="currentPassword" :fieldError="formPassword.errors.get('currentPassword')" inputType="password" label="auth.forms.current_password.label" help="auth.forms.current_password.help" />
@@ -129,7 +132,9 @@
                 <form id="frmDeleteAccount" @submit.prevent="submitDelete" @keydown="formDelete.onKeydown($event)">
                     <h4 class="title is-4 pt-6 has-text-danger">{{ $t('auth.forms.delete_account') }}</h4>
                     <div class="field is-size-7-mobile">
-                        {{ $t('auth.forms.delete_your_account_and_reset_all_data')}}
+                        <p class="block">{{ $t('auth.forms.delete_your_account_and_reset_all_data')}}</p>
+                        <p>{{ $t('auth.forms.reset_your_password_to_delete_your_account') }}</p>
+                        <p>{{ $t('auth.forms.deleting_2fauth_account_does_not_impact_provider') }}</p>
                     </div>
                     <fieldset :disabled="$2fauth.config.proxyAuth">
                         <FormField v-model="formDelete.password" fieldName="password" :fieldError="formDelete.errors.get('password')" inputType="password" autocomplete="new-password" label="auth.forms.current_password.label" help="auth.forms.current_password.help" />

+ 2 - 0
resources/js/views/settings/Options.vue

@@ -189,6 +189,8 @@
                         <FormCheckbox :model-value="appSettings.useEncryption" @update:model-value="val => saveSetting('useEncryption', val)" fieldName="useEncryption" label="settings.forms.use_encryption.label" help="settings.forms.use_encryption.help" />
                         <!-- disable registration -->
                         <FormCheckbox :model-value="appSettings.disableRegistration" @update:model-value="val => saveSetting('disableRegistration', val)" fieldName="disableRegistration" label="settings.forms.disable_registration.label" help="settings.forms.disable_registration.help" />
+                        <!-- disable SSO registration -->
+                        <FormCheckbox :model-value="appSettings.enableSso" @update:model-value="val => saveSetting('enableSso', val)" fieldName="enableSso" label="settings.forms.enable_sso.label" help="settings.forms.enable_sso.help" />
                     </div>
                 </form>
             </FormWrapper>

+ 4 - 1
resources/lang/en/auth.php

@@ -22,6 +22,7 @@ return [
     'sign_out' => 'Sign out',
     'sign_in' => 'Sign in',
     'sign_in_using' => 'Sign in using',
+    'or_continue_with' => 'You an also continue with:',
     'sign_in_using_security_device' => 'Sign in using a security device',
     'login_and_password' => 'login & password',
     'register' => 'Register',
@@ -109,7 +110,9 @@ return [
         'name_this_device' => 'Name this device',
         'delete_account' => 'Delete account',
         'delete_your_account' => 'Delete your account',
-        'delete_your_account_and_reset_all_data' => 'This will reset 2FAuth. Your user account will be deleted as well as all 2FA data. There is no going back.',
+        'delete_your_account_and_reset_all_data' => 'Your user account will be deleted as well as all your 2FA data. There is no going back.',
+        'reset_your_password_to_delete_your_account' => 'If you always used SSO to sign in, sign out then use the reset password feature to get a password so you can fill this form.',
+        'deleting_2fauth_account_does_not_impact_provider' => 'Deleting your 2FAuth account has no impact on your external SSO account.',
         'user_account_successfully_deleted' => 'User account successfully deleted',
         'has_lower_case' => 'Has lower case',
         'has_upper_case' => 'Has upper case',

+ 5 - 0
resources/lang/en/errors.php

@@ -59,4 +59,9 @@ return [
     'cannot_delete_the_only_admin' => 'Cannot delete the only admin account',
     'error_during_data_fetching' => '💀 Something went wrong during data fetching',
     'check_failed_try_later' => 'Check failed, please retry later',
+    'sso_disabled' => 'SSO is disabled',
+    'sso_bad_provider_setup' => 'This SSO provider is not fully setup in your .env file',
+    'sso_failed' => 'Authentication via SSO rejected',
+    'no_register' => 'Registrations are disabled',
+    'account_managed_by_external_provider' => 'Account managed by an external provider',
 ];

+ 6 - 1
resources/lang/en/settings.php

@@ -29,6 +29,7 @@ return [
     'administration_legend' => 'While previous settings are user settings (every user can set its own preferences), following settings are global and apply to all users.',
     'only_an_admin_can_edit_them' => 'Only an administrator can view and edit them.',
     'you_are_administrator' => 'You are an administrator',
+    'account_linked_to_sso_x_provider' => 'You signed-in via SSO using your :provider account. Your information cannot be changed here but on :provider.',
     'general' => 'General',
     'security' => 'Security',
     'profile' => 'Profile',
@@ -131,7 +132,11 @@ return [
         ],
         'disable_registration' => [
             'label' => 'Disable registration',
-            'help' => 'Prevent new user registration',
+            'help' => 'Prevent new user registration. This affects SSO as well, so new SSO users won\'t be able to sign on',
+        ],
+        'enable_sso' => [
+            'label' => 'Enable Single Sign-On (SSO)',
+            'help' => 'Allow visitors to authenticate using an external ID via the Single Sign-On scheme',
         ],
         'otp_generation' => [
             'label' => 'Show Password',

+ 248 - 0
tests/Feature/Http/Auth/SocialiteControllerTest.php

@@ -0,0 +1,248 @@
+<?php
+
+namespace Tests\Feature\Http\Auth;
+
+use App\Facades\Settings;
+use App\Http\Controllers\Auth\SocialiteController;
+use App\Models\User;
+use Illuminate\Support\Facades\DB;
+use Laravel\Socialite\Facades\Socialite;
+use PHPUnit\Framework\Attributes\CoversClass;
+use Tests\FeatureTestCase;
+
+/**
+ * SocialiteControllerTest test class
+ */
+#[CoversClass(SocialiteController::class)]
+class SocialiteControllerTest extends FeatureTestCase
+{
+    /**
+     * @var \App\Models\User|\Illuminate\Contracts\Auth\Authenticatable
+     */
+    protected $user;
+
+    /**
+     * @var \Laravel\Socialite\Two\User
+     */
+    protected $socialiteUser;
+
+    private const USER_OAUTH_ID = '12345';
+
+    private const USER_OAUTH_PROVIDER = 'github';
+
+    private const USER_NAME = 'John';
+
+    private const USER_NICKNAME = 'Jo';
+
+    private const USER_EMAIL = 'john@provider.com';
+
+    /**
+     * @test
+     */
+    public function setUp() : void
+    {
+        parent::setUp();
+
+        DB::table('users')->delete();
+        $this->user = User::factory()->create([
+            'name'           => self::USER_NAME,
+            'email'          => self::USER_EMAIL,
+            'password'       => 'password',
+            'is_admin'       => 1,
+            'oauth_id'       => self::USER_OAUTH_ID,
+            'oauth_provider' => self::USER_OAUTH_PROVIDER,
+        ]);
+
+        
+        $this->socialiteUser = new \Laravel\Socialite\Two\User;
+        $this->socialiteUser->id = self::USER_OAUTH_ID;
+        $this->socialiteUser->name = self::USER_NAME;
+        $this->socialiteUser->email = self::USER_EMAIL;
+        $this->socialiteUser->nickname = self::USER_NICKNAME;
+    }
+    
+    /**
+     * @test
+     */
+    public function test_redirect_redirects_to_provider_url()
+    {
+        Settings::set('enableSso', true);
+
+        $response = $this->get('/socialite/redirect/github');
+
+        $response->assertRedirectContains('https://github.com/login/oauth/authorize');
+    }
+
+    /**
+     * @test
+     */
+    public function test_redirect_returns_error_when_registrations_are_disabled()
+    {
+        Settings::set('enableSso', false);
+
+        $response = $this->get('/socialite/redirect/github');
+
+        $response->assertRedirect('/error?err=sso_disabled');
+    }
+
+    /**
+     * @test
+     */
+    public function test_callback_authenticates_the_user()
+    {
+        Socialite::shouldReceive('driver->user')
+            ->andReturn($this->socialiteUser);
+
+        $response = $this->get('/socialite/callback/github', ['driver' => 'github']);
+
+        $this->assertAuthenticatedAs($this->user, 'web-guard');
+    }
+
+    /**
+     * @test
+     */
+    public function test_callback_redirects_authenticated_user_to_accounts()
+    {
+        Socialite::shouldReceive('driver->user')
+            ->andReturn($this->socialiteUser);
+
+        $response = $this->get('/socialite/callback/github', ['driver' => 'github']);
+
+        $response->assertRedirect('/accounts');
+    }
+
+    /**
+     * @test
+     */
+    public function test_callback_updates_user_informations()
+    {
+        $socialiteUpdatedUser = new \Laravel\Socialite\Two\User;
+        $socialiteUpdatedUser->id = self::USER_OAUTH_ID;
+        $socialiteUpdatedUser->email = 'new_email';
+        $socialiteUpdatedUser->nickname = 'new_nickname';
+
+        Socialite::shouldReceive('driver->user')
+            ->andReturn($socialiteUpdatedUser);
+
+        $response = $this->get('/socialite/callback/github', ['driver' => 'github']);
+
+        $this->assertDatabaseHas('users', [
+            'oauth_id'       => self::USER_OAUTH_ID,
+            'oauth_provider' => self::USER_OAUTH_PROVIDER,
+            'name'           => 'new_nickname',
+            'email'          => 'new_email',
+        ]);
+    }
+
+    /**
+     * @test
+     */
+    public function test_callback_updates_username_with_fallback_value()
+    {
+        $socialiteUpdatedUser = new \Laravel\Socialite\Two\User;
+        $socialiteUpdatedUser->id = self::USER_OAUTH_ID;
+        $socialiteUpdatedUser->name = 'new_name';
+        $socialiteUpdatedUser->email = 'new_email';
+
+        Socialite::shouldReceive('driver->user')
+            ->andReturn($socialiteUpdatedUser);
+
+        $response = $this->get('/socialite/callback/github', ['driver' => 'github']);
+
+        $this->assertDatabaseHas('users', [
+            'oauth_id'       => self::USER_OAUTH_ID,
+            'oauth_provider' => self::USER_OAUTH_PROVIDER,
+            'name'           => 'new_name',
+            'email'          => 'new_email',
+        ]);
+    }
+
+    /**
+     * @test
+     */
+    public function test_callback_registers_new_user()
+    {
+        $newSocialiteUser = new \Laravel\Socialite\Two\User;
+        $newSocialiteUser->id = 'new_id';
+        $newSocialiteUser->name = 'jane';
+        $newSocialiteUser->email = 'jane@provider.com';
+
+        Socialite::shouldReceive('driver->user')
+            ->andReturn($newSocialiteUser);
+
+        $response = $this->get('/socialite/callback/github', ['driver' => 'github']);
+
+        $this->assertDatabaseHas('users', [
+            'oauth_id'       => 'new_id',
+            'oauth_provider' => self::USER_OAUTH_PROVIDER,
+            'name'           => 'jane',
+            'email'          => 'jane@provider.com',
+            'is_admin'       => 0,
+        ]);
+    }
+
+    /**
+     * @test
+     */
+    public function test_callback_always_registers_first_user_as_admin()
+    {
+        DB::table('users')->delete();
+        Settings::set('disableRegistration', true);
+        Settings::set('enableSso', false);
+
+        Socialite::shouldReceive('driver->user')
+            ->andReturn($this->socialiteUser);
+
+        $response = $this->get('/socialite/callback/github', ['driver' => 'github']);
+
+        $this->assertDatabaseHas('users', [
+            'oauth_id'       => self::USER_OAUTH_ID,
+            'oauth_provider' => self::USER_OAUTH_PROVIDER,
+            'is_admin'       => 1,
+        ]);
+    }
+
+    /**
+     * @test
+     */
+    public function test_callback_returns_error_when_registrations_are_closed()
+    {
+        Settings::set('disableRegistration', true);
+
+        $newSocialiteUser = new \Laravel\Socialite\Two\User;
+        $newSocialiteUser->id = 'rejected_id';
+        $newSocialiteUser->name = 'jane';
+        $newSocialiteUser->email = 'jane@provider.com';
+
+        Socialite::shouldReceive('driver->user')
+            ->andReturn($newSocialiteUser);
+
+        $response = $this->get('/socialite/callback/github', ['driver' => 'github']);
+
+        $response->assertRedirect('/error?err=no_register');
+    }
+
+    /**
+     * @test
+     */
+    public function test_callback_skips_registration_when_registrations_are_closed()
+    {
+        Settings::set('disableRegistration', true);
+
+        $newSocialiteUser = new \Laravel\Socialite\Two\User;
+        $newSocialiteUser->id = 'rejected_id';
+        $newSocialiteUser->name = 'jane';
+        $newSocialiteUser->email = 'jane@provider.com';
+
+        Socialite::shouldReceive('driver->user')
+            ->andReturn($newSocialiteUser);
+
+        $response = $this->get('/socialite/callback/github', ['driver' => 'github']);
+
+        $this->assertDatabaseMissing('users', [
+            'oauth_id'       => 'rejected_id',
+            'oauth_provider' => self::USER_OAUTH_PROVIDER,
+        ]);
+    }
+
+}