Add a setting to restrict authentication to SSO only - Closes #368, Closes #362

This commit is contained in:
Bubka 2024-09-20 10:50:25 +02:00
parent 4d56e74b6f
commit 091129ef06
16 changed files with 455 additions and 61 deletions

View File

@ -77,6 +77,7 @@ class Kernel extends HttpKernel
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'rejectIfDemoMode' => \App\Http\Middleware\RejectIfDemoMode::class, 'rejectIfDemoMode' => \App\Http\Middleware\RejectIfDemoMode::class,
'rejectIfReverseProxy' => \App\Http\Middleware\RejectIfReverseProxy::class, 'rejectIfReverseProxy' => \App\Http\Middleware\RejectIfReverseProxy::class,
'RejectIfSsoOnlyAndNotForAdmin' => \App\Http\Middleware\RejectIfSsoOnlyAndNotForAdmin::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
// 'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class, // 'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
// 'signed' => \App\Http\Middleware\ValidateSignature::class, // 'signed' => \App\Http\Middleware\ValidateSignature::class,

View File

@ -26,7 +26,8 @@ protected function authenticate($request, array $guards)
// Will retreive the default guard // Will retreive the default guard
$guards = [null]; $guards = [null];
} else { } else {
// We replace routes guard by the reverse proxy guard if necessary // If reverse proxy is defined as the default guard, we force the
// authentication against this only guard.
if (config('auth.defaults.guard') === $proxyGuard) { if (config('auth.defaults.guard') === $proxyGuard) {
$guards = [$proxyGuard]; $guards = [$proxyGuard];
} }

View File

@ -0,0 +1,41 @@
<?php
namespace App\Http\Middleware;
use App\Facades\Settings;
use App\Models\User;
use Closure;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
class RejectIfSsoOnlyAndNotForAdmin
{
/**
* Reject the request when it aims to modify or impact a user account in those 2 conditions:
* - The impacted account does not have the Administrator role
* - Authentication is restricted to SSO only
*
* @param \Illuminate\Http\Request $request
* @return mixed
*/
public function handle($request, Closure $next)
{
if (Settings::get('useSsoOnly')) {
if ($email = $request->input('email', null)) {
$user = User::whereEmail($email)->first();
}
else $user = Auth::user();
if ($user?->isAdministrator()) {
return $next($request);
}
Log::notice(sprintf('Request to %s rejected, only Admins can request it while authentication is restricted to SSO only', $request->getPathInfo()));
return response()->json(['message' => __('errors.unsupported_with_sso_only')], Response::HTTP_METHOD_NOT_ALLOWED);
}
return $next($request);
}
}

View File

@ -89,6 +89,7 @@
'latestRelease' => false, 'latestRelease' => false,
'disableRegistration' => false, 'disableRegistration' => false,
'enableSso' => true, 'enableSso' => true,
'useSsoOnly' => false,
'restrictRegistration' => false, 'restrictRegistration' => false,
'restrictList' => '', 'restrictList' => '',
'restrictRule' => '', 'restrictRule' => '',

View File

@ -0,0 +1,31 @@
<script setup>
const props = defineProps({
provider: {
type: String,
default: 'unknown'
}
})
const icons = {
unknown: {
collection: 'fa',
icon: 'globe',
},
github: {
collection: 'fab',
icon: 'github-alt',
},
openid: {
collection: 'fab',
icon: 'openid',
}
}
</script>
<template>
<a :id="'lnkSignWith' + props.provider" class="button is-link" :href="'socialite/redirect/' + props.provider">
{{ $t('auth.sso_providers.' + props.provider) }}
<FontAwesomeIcon class="ml-2" :icon="[icons[props.provider].collection, icons[props.provider].icon]" />
</a>
</template>

View File

@ -78,6 +78,8 @@
<h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('admin.single_sign_on') }}</h4> <h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('admin.single_sign_on') }}</h4>
<!-- enable SSO --> <!-- enable SSO -->
<FormCheckbox v-model="appSettings.enableSso" @update:model-value="val => useAppSettingsUpdater('enableSso', val)" fieldName="enableSso" label="admin.forms.enable_sso.label" help="admin.forms.enable_sso.help" /> <FormCheckbox v-model="appSettings.enableSso" @update:model-value="val => useAppSettingsUpdater('enableSso', val)" fieldName="enableSso" label="admin.forms.enable_sso.label" help="admin.forms.enable_sso.help" />
<!-- use SSO only -->
<FormCheckbox v-model="appSettings.useSsoOnly" @update:model-value="val => useAppSettingsUpdater('useSsoOnly', val)" fieldName="useSsoOnly" label="admin.forms.use_sso_only.label" help="admin.forms.use_sso_only.help" :isDisabled="!appSettings.enableSso" :isIndented="true" />
<h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('admin.registrations') }}</h4> <h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('admin.registrations') }}</h4>
<!-- restrict registration --> <!-- restrict registration -->
<FormCheckbox v-model="appSettings.restrictRegistration" @update:model-value="val => useAppSettingsUpdater('restrictRegistration', val)" fieldName="restrictRegistration" :isDisabled="appSettings.disableRegistration" label="admin.forms.restrict_registration.label" help="admin.forms.restrict_registration.help" /> <FormCheckbox v-model="appSettings.restrictRegistration" @update:model-value="val => useAppSettingsUpdater('restrictRegistration', val)" fieldName="restrictRegistration" :isDisabled="appSettings.disableRegistration" label="admin.forms.restrict_registration.label" help="admin.forms.restrict_registration.help" />

View File

@ -1,5 +1,6 @@
<script setup> <script setup>
import Form from '@/components/formElements/Form' import Form from '@/components/formElements/Form'
import SsoConnectLink from '@/components/SsoConnectLink.vue'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { useNotifyStore } from '@/stores/notify' import { useNotifyStore } from '@/stores/notify'
import { useAppSettingsStore } from '@/stores/appSettings' import { useAppSettingsStore } from '@/stores/appSettings'
@ -16,13 +17,29 @@
password: '' password: ''
})) }))
const isBusy = ref(false) const isBusy = ref(false)
const activeForm = ref()
onMounted(() => {
if (appSettings.enableSso == true && appSettings.useSsoOnly == true) {
activeForm.value = 'sso'
}
else if (showWebauthnForm.value == true) {
activeForm.value = 'webauthn'
}
else activeForm.value = 'legacy'
// showWebauthnForm && appSettings.useSsoOnly != true
})
/** /**
* Toggle the form between legacy and webauthn method * Toggle the form between legacy and webauthn method
*/ */
function toggleForm() { function switchToForm(formName) {
form.clear() form.clear()
showWebauthnForm.value = ! showWebauthnForm.value activeForm.value = formName
showWebauthnForm.value = activeForm.value == 'webauthn'
} }
/** /**
@ -101,7 +118,8 @@
<template> <template>
<!-- webauthn authentication --> <!-- webauthn authentication -->
<FormWrapper v-if="showWebauthnForm" title="auth.forms.webauthn_login" punchline="auth.welcome_to_2fauth"> <FormWrapper v-if="activeForm == 'webauthn'" title="auth.forms.webauthn_login" punchline="auth.welcome_to_2fauth">
<div v-if="appSettings.enableSso == true && appSettings.useSsoOnly == true" class="notification is-warning has-text-centered" v-html="$t('auth.forms.sso_only_form_restricted_to_admin')" />
<div class="field"> <div class="field">
{{ $t('auth.webauthn.use_security_device_to_sign_in') }} {{ $t('auth.webauthn.use_security_device_to_sign_in') }}
</div> </div>
@ -116,36 +134,64 @@
{{ $t('auth.webauthn.recover_your_account') }} {{ $t('auth.webauthn.recover_your_account') }}
</RouterLink> </RouterLink>
</p> </p>
<p v-if="!user.preferences.useWebauthnOnly">{{ $t('auth.sign_in_using') }}&nbsp; <p>{{ $t('auth.sign_in_using') }}&nbsp;
<a id="lnkSignWithLegacy" role="button" class="is-link" @keyup.enter="toggleForm" @click="toggleForm" tabindex="0"> <a id="lnkSignWithLegacy" role="button" class="is-link" @keyup.enter="switchToForm('legacy')" @click="switchToForm('legacy')" tabindex="0">
{{ $t('auth.login_and_password') }} {{ $t('auth.login_and_password') }}
</a> </a>
</p> </p>
<p v-if="appSettings.disableRegistration == false" class="mt-4"> <p v-if="appSettings.disableRegistration == false && appSettings.useSsoOnly == false" class="mt-4">
{{ $t('auth.forms.dont_have_account_yet') }}&nbsp; {{ $t('auth.forms.dont_have_account_yet') }}&nbsp;
<RouterLink id="lnkRegister" :to="{ name: 'register' }" class="is-link"> <RouterLink id="lnkRegister" :to="{ name: 'register' }" class="is-link">
{{ $t('auth.register') }} {{ $t('auth.register') }}
</RouterLink> </RouterLink>
</p> </p>
<div v-if="appSettings.enableSso && Object.values($2fauth.config.sso).includes(true)" class="columns mt-4 is-variable is-1"> <div v-if="appSettings.enableSso == true && Object.values($2fauth.config.sso).includes(true)" class="columns mt-4 is-variable is-1">
<div class="column is-narrow py-1"> <div class="column is-narrow py-1">
{{ $t('auth.or_continue_with') }} {{ $t('auth.or_continue_with') }}
</div> </div>
<div class="column py-1"> <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"> <div class="buttons">
OpenID<FontAwesomeIcon class="ml-2" :icon="['fab', 'openid']" /> <template v-for="(isEnabled, provider) in $2fauth.config.sso">
</a> <SsoConnectLink v-if="isEnabled" :class="'is-outlined is-small'" :provider="provider" />
<a v-if="$2fauth.config.sso.github" id="lnkSignWithGithub" class="button is-link is-outlined is-small ml-2" href="/socialite/redirect/github"> </template>
Github<FontAwesomeIcon class="ml-2" :icon="['fab', 'github-alt']" /> </div>
</a>
</div> </div>
</div> </div>
</div> </div>
</FormWrapper> </FormWrapper>
<!-- login/password legacy form --> <!-- SSO only links -->
<FormWrapper v-else title="auth.forms.login" punchline="auth.welcome_to_2fauth"> <FormWrapper v-else-if="activeForm == 'sso'" title="auth.forms.sso_login" punchline="auth.welcome_to_2fauth">
<div v-if="$2fauth.isDemoApp" class="notification is-info has-text-centered is-radiusless" v-html="$t('auth.forms.welcome_to_demo_app_use_those_credentials')" /> <div v-if="$2fauth.isDemoApp" class="notification is-info has-text-centered is-radiusless" v-html="$t('auth.forms.welcome_to_demo_app_use_those_credentials')" />
<div v-if="$2fauth.isTestingApp" class="notification is-warning has-text-centered is-radiusless" v-html="$t('auth.forms.welcome_to_testing_app_use_those_credentials')" /> <div v-if="$2fauth.isTestingApp" class="notification is-warning has-text-centered is-radiusless" v-html="$t('auth.forms.welcome_to_testing_app_use_those_credentials')" />
<div class="nav-links">
<p class="">{{ $t('auth.password_login_and_webauthn_are_disabled') }}</p>
<p class="">{{ $t('auth.sign_in_using_sso') }}</p>
</div>
<div v-if="Object.values($2fauth.config.sso).includes(true)" class="buttons mt-4">
<template v-for="(isEnabled, provider) in $2fauth.config.sso">
<SsoConnectLink v-if="isEnabled" :provider="provider" />
</template>
</div>
<p v-else class="is-italic">- {{ $t('auth.no_provider') }} -</p>
<div class="nav-links">
<p>
{{ $t('auth.no_sso_provider_or_provider_is_missing') }}&nbsp;
<a id="lnkSsoDocs" class="is-link" tabindex="0" :href="$2fauth.urls.ssoDocUrl" target="_blank">
{{ $t('auth.see_how_to_enable_sso') }}
</a>
</p>
<p >{{ $t('auth.if_administrator') }}&nbsp;
<a id="lnkSignWithLegacy" role="button" class="is-link" @keyup.enter="switchToForm('legacy')" @click="switchToForm('legacy')" tabindex="0">
{{ $t('auth.sign_in_here') }}
</a>
</p>
</div>
</FormWrapper>
<!-- login/password legacy form -->
<FormWrapper v-else-if="activeForm == 'legacy'" title="auth.forms.login" punchline="auth.welcome_to_2fauth">
<div v-if="$2fauth.isDemoApp" class="notification is-info has-text-centered is-radiusless" v-html="$t('auth.forms.welcome_to_demo_app_use_those_credentials')" />
<div v-if="$2fauth.isTestingApp" class="notification is-warning has-text-centered is-radiusless" v-html="$t('auth.forms.welcome_to_testing_app_use_those_credentials')" />
<div v-if="appSettings.enableSso == true && appSettings.useSsoOnly == true" class="notification is-warning has-text-centered" v-html="$t('auth.forms.sso_only_form_restricted_to_admin')" />
<form id="frmLegacyLogin" @submit.prevent="LegacysignIn" @keydown="form.onKeydown($event)"> <form id="frmLegacyLogin" @submit.prevent="LegacysignIn" @keydown="form.onKeydown($event)">
<FormField v-model="form.email" fieldName="email" :fieldError="form.errors.get('email')" inputType="email" label="auth.forms.email" autofocus /> <FormField v-model="form.email" fieldName="email" :fieldError="form.errors.get('email')" inputType="email" label="auth.forms.email" autofocus />
<FormPasswordField v-model="form.password" fieldName="password" :fieldError="form.errors.get('password')" label="auth.forms.password" /> <FormPasswordField v-model="form.password" fieldName="password" :fieldError="form.errors.get('password')" label="auth.forms.password" />
@ -158,11 +204,11 @@
</RouterLink> </RouterLink>
</p> </p>
<p >{{ $t('auth.sign_in_using') }}&nbsp; <p >{{ $t('auth.sign_in_using') }}&nbsp;
<a id="lnkSignWithWebauthn" role="button" class="is-link" @keyup.enter="toggleForm" @click="toggleForm" tabindex="0" :aria-label="$t('auth.sign_in_using_security_device')"> <a id="lnkSignWithWebauthn" role="button" class="is-link" @keyup.enter="switchToForm('webauthn')" @click="switchToForm('webauthn')" tabindex="0" :aria-label="$t('auth.sign_in_using_security_device')">
{{ $t('auth.webauthn.security_device') }} {{ $t('auth.webauthn.security_device') }}
</a> </a>
</p> </p>
<p v-if="appSettings.disableRegistration == false" class="mt-4"> <p v-if="appSettings.disableRegistration == false && appSettings.useSsoOnly == false" class="mt-4">
{{ $t('auth.forms.dont_have_account_yet') }}&nbsp; {{ $t('auth.forms.dont_have_account_yet') }}&nbsp;
<RouterLink id="lnkRegister" :to="{ name: 'register' }" class="is-link"> <RouterLink id="lnkRegister" :to="{ name: 'register' }" class="is-link">
{{ $t('auth.register') }} {{ $t('auth.register') }}
@ -173,12 +219,11 @@
{{ $t('auth.or_continue_with') }} {{ $t('auth.or_continue_with') }}
</div> </div>
<div class="column py-1"> <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"> <div class="buttons">
OpenID<FontAwesomeIcon class="ml-2" :icon="['fab', 'openid']" /> <template v-for="(isEnabled, provider) in $2fauth.config.sso">
</a> <SsoConnectLink v-if="isEnabled" :class="'is-outlined is-small'" :provider="provider" />
<a v-if="$2fauth.config.sso.github" id="lnkSignWithGithub" class="button is-link is-outlined is-small mr-2" href="/socialite/redirect/github"> </template>
Github<FontAwesomeIcon class="ml-2" :icon="['fab', 'github-alt']" /> </div>
</a>
</div> </div>
</div> </div>
</div> </div>

View File

@ -107,7 +107,7 @@
<div v-if="user.isAdmin" class="notification is-warning"> <div v-if="user.isAdmin" class="notification is-warning">
{{ $t('settings.you_are_administrator') }} {{ $t('settings.you_are_administrator') }}
</div> </div>
<div v-if="user.oauth_provider" class="notification is-info"> <div v-if="user.oauth_provider" class="notification is-info has-text-centered">
{{ $t('settings.account_linked_to_sso_x_provider', { provider: user.oauth_provider }) }} {{ $t('settings.account_linked_to_sso_x_provider', { provider: user.oauth_provider }) }}
</div> </div>
<form @submit.prevent="submitProfile" @keydown="formProfile.onKeydown($event)"> <form @submit.prevent="submitProfile" @keydown="formProfile.onKeydown($event)">

View File

@ -4,20 +4,25 @@
import SettingTabs from '@/layouts/SettingTabs.vue' import SettingTabs from '@/layouts/SettingTabs.vue'
import { useNotifyStore } from '@/stores/notify' import { useNotifyStore } from '@/stores/notify'
import { UseColorMode } from '@vueuse/components' import { UseColorMode } from '@vueuse/components'
import { useUserStore } from '@/stores/user'
import Spinner from '@/components/Spinner.vue' import Spinner from '@/components/Spinner.vue'
const $2fauth = inject('2fauth') const $2fauth = inject('2fauth')
const notify = useNotifyStore() const notify = useNotifyStore()
const user = useUserStore()
const returnTo = useStorage($2fauth.prefix + 'returnTo', 'accounts') const returnTo = useStorage($2fauth.prefix + 'returnTo', 'accounts')
const { copy } = useClipboard({ legacy: true }) const { copy } = useClipboard({ legacy: true })
const tokens = ref([]) const tokens = ref([])
const isFetching = ref(false) const isFetching = ref(false)
const isRemoteUser = ref(false)
const createPATModalIsVisible = ref(false) const createPATModalIsVisible = ref(false)
const visibleToken = ref(null) const visibleToken = ref(null)
const visibleTokenId = ref(null) const visibleTokenId = ref(null)
const isDisabled = computed(() => {
return (appSettings.enableSso && appSettings.useSsoOnly) || user.authenticated_by_proxy
})
onMounted(() => { onMounted(() => {
fetchTokens() fetchTokens()
}) })
@ -47,9 +52,9 @@
}) })
.catch(error => { .catch(error => {
if( error.response.status === 405 ) { if( error.response.status === 405 ) {
// The backend returns a 405 response for routes with the // The backend returns a 405 response if the user is authenticated by a reverse proxy
// rejectIfReverseProxy middleware // or if SSO only is enabled.
isRemoteUser.value = true // The form is already disabled (see isDisabled) so we do nothing more here
} }
else { else {
notify.error(error) notify.error(error)
@ -69,7 +74,7 @@
function showPATcreationForm() { function showPATcreationForm() {
clearTokenValues() clearTokenValues()
if (isRemoteUser.value) { if (isDisabled.value) {
notify.warn({ text: trans('errors.unsupported_with_reverseproxy') }) notify.warn({ text: trans('errors.unsupported_with_reverseproxy') })
} }
else createPATModalIsVisible.value = true else createPATModalIsVisible.value = true
@ -141,7 +146,10 @@
<SettingTabs :activeTab="'settings.oauth.tokens'" /> <SettingTabs :activeTab="'settings.oauth.tokens'" />
<div class="options-tabs"> <div class="options-tabs">
<FormWrapper> <FormWrapper>
<div v-if="isRemoteUser" class="notification is-warning has-text-centered" v-html="$t('auth.auth_handled_by_proxy')" /> <div v-if="isDisabled && user.oauth_provider" class="notification is-warning has-text-centered">
{{ $t('auth.sso_only_x_settings_are_disabled', { auth_method: 'OAuth' }) }}
</div>
<div v-if="isDisabled && user.authenticated_by_proxy" class="notification is-warning has-text-centered" v-html="$t('auth.auth_handled_by_proxy')" />
<h4 class="title is-4 has-text-grey-light">{{ $t('settings.personal_access_tokens') }}</h4> <h4 class="title is-4 has-text-grey-light">{{ $t('settings.personal_access_tokens') }}</h4>
<div class="is-size-7-mobile"> <div class="is-size-7-mobile">
{{ $t('settings.token_legend')}} {{ $t('settings.token_legend')}}

View File

@ -2,6 +2,7 @@
import SettingTabs from '@/layouts/SettingTabs.vue' import SettingTabs from '@/layouts/SettingTabs.vue'
import userService from '@/services/userService' import userService from '@/services/userService'
import { webauthnService } from '@/services/webauthn/webauthnService' import { webauthnService } from '@/services/webauthn/webauthnService'
import { useAppSettingsStore } from '@/stores/appSettings'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { useNotifyStore } from '@/stores/notify' import { useNotifyStore } from '@/stores/notify'
import { UseColorMode } from '@vueuse/components' import { UseColorMode } from '@vueuse/components'
@ -9,13 +10,17 @@
const $2fauth = inject('2fauth') const $2fauth = inject('2fauth')
const user = useUserStore() const user = useUserStore()
const appSettings = useAppSettingsStore()
const notify = useNotifyStore() const notify = useNotifyStore()
const router = useRouter() const router = useRouter()
const returnTo = useStorage($2fauth.prefix + 'returnTo', 'accounts') const returnTo = useStorage($2fauth.prefix + 'returnTo', 'accounts')
const credentials = ref([]) const credentials = ref([])
const isFetching = ref(false) const isFetching = ref(false)
const isRemoteUser = ref(false)
const isDisabled = computed(() => {
return (appSettings.enableSso && appSettings.useSsoOnly) || user.authenticated_by_proxy
})
onMounted(() => { onMounted(() => {
fetchCredentials() fetchCredentials()
@ -31,8 +36,7 @@
* Register a new security device * Register a new security device
*/ */
function register() { function register() {
if (isDisabled.value == true) {
if (isRemoteUser == true) {
notify.warn({text: trans('errors.unsupported_with_reverseproxy') }) notify.warn({text: trans('errors.unsupported_with_reverseproxy') })
return false return false
} }
@ -95,9 +99,9 @@
}) })
.catch(error => { .catch(error => {
if( error.response.status === 405 ) { if( error.response.status === 405 ) {
// The backend returns a 405 response for routes with the // The backend returns a 405 response if the user is authenticated by a reverse proxy
// rejectIfReverseProxy middleware // or if SSO only is enabled.
isRemoteUser.value = true // The form is already disabled (see isDisabled) so we do nothing more here
} }
else { else {
notify.error(error) notify.error(error)
@ -120,7 +124,10 @@
<SettingTabs :activeTab="'settings.webauthn.devices'" /> <SettingTabs :activeTab="'settings.webauthn.devices'" />
<div class="options-tabs"> <div class="options-tabs">
<FormWrapper> <FormWrapper>
<div v-if="isRemoteUser" class="notification is-warning has-text-centered" v-html="$t('auth.auth_handled_by_proxy')" /> <div v-if="isDisabled && user.oauth_provider" class="notification is-warning has-text-centered">
{{ $t('auth.sso_only_x_settings_are_disabled', { auth_method: 'WebAuthn' }) }}
</div>
<div v-if="isDisabled && user.authenticated_by_proxy" class="notification is-warning has-text-centered" v-html="$t('auth.auth_handled_by_proxy')" />
<h4 class="title is-4 has-text-grey-light">{{ $t('auth.webauthn.security_devices') }}</h4> <h4 class="title is-4 has-text-grey-light">{{ $t('auth.webauthn.security_devices') }}</h4>
<div class="is-size-7-mobile"> <div class="is-size-7-mobile">
{{ $t('auth.webauthn.security_devices_legend')}} {{ $t('auth.webauthn.security_devices_legend')}}
@ -161,7 +168,7 @@
fieldName="useWebauthnOnly" fieldName="useWebauthnOnly"
label="auth.webauthn.use_webauthn_only.label" label="auth.webauthn.use_webauthn_only.label"
help="auth.webauthn.use_webauthn_only.help" help="auth.webauthn.use_webauthn_only.help"
:disabled="isRemoteUser || credentials.length === 0" :isDisabled="isDisabled || credentials.length === 0"
/> />
</form> </form>
<!-- footer --> <!-- footer -->

View File

@ -110,6 +110,10 @@
'label' => 'Enable SSO', 'label' => 'Enable SSO',
'help' => 'Allow visitors to authenticate using an external ID via the Single Sign-On scheme', 'help' => 'Allow visitors to authenticate using an external ID via the Single Sign-On scheme',
], ],
'use_sso_only' => [
'label' => 'Use SSO only',
'help' => 'Make SSO the only available method to log in to 2FAuth. Password login and Webauthn are then disabled for regular users. Administrators are not affected by this restriction.',
],
'keep_sso_registration_enabled' => [ 'keep_sso_registration_enabled' => [
'label' => 'Keep SSO registration enabled', 'label' => 'Keep SSO registration enabled',
'help' => 'Allow new users to sign in for the first time via SSO whereas registration is disabled', 'help' => 'Allow new users to sign in for the first time via SSO whereas registration is disabled',

View File

@ -22,7 +22,14 @@
'sign_out' => 'Sign out', 'sign_out' => 'Sign out',
'sign_in' => 'Sign in', 'sign_in' => 'Sign in',
'sign_in_using' => 'Sign in using', 'sign_in_using' => 'Sign in using',
'if_administrator' => 'Administrator?',
'sign_in_here' => 'You can sign without SSO',
'or_continue_with' => 'You can also continue with:', 'or_continue_with' => 'You can also continue with:',
'password_login_and_webauthn_are_disabled' => 'Password login and WebAuthn are disabled.',
'sign_in_using_sso' => 'Pick an SSO provider to sign in with:',
'no_provider' => 'no provider',
'no_sso_provider_or_provider_is_missing' => 'Provider is missing?',
'see_how_to_enable_sso' => 'See how to enable a provider',
'sign_in_using_security_device' => 'Sign in using a security device', 'sign_in_using_security_device' => 'Sign in using a security device',
'login_and_password' => 'login & password', 'login_and_password' => 'login & password',
'register' => 'Register', 'register' => 'Register',
@ -34,6 +41,7 @@
'maybe_later' => 'Maybe later', 'maybe_later' => 'Maybe later',
'user_account_controlled_by_proxy' => 'User account made available by an authentication proxy.<br />Manage the account at proxy level.', 'user_account_controlled_by_proxy' => 'User account made available by an authentication proxy.<br />Manage the account at proxy level.',
'auth_handled_by_proxy' => 'Authentication handled by a reverse proxy, below settings are disabled.<br />Manage authentication at proxy level.', 'auth_handled_by_proxy' => 'Authentication handled by a reverse proxy, below settings are disabled.<br />Manage authentication at proxy level.',
'sso_only_x_settings_are_disabled' => 'Authentication is restricted to SSO only, :auth_method is disabled',
'confirm' => [ 'confirm' => [
'logout' => 'Are you sure you want to log out?', 'logout' => 'Are you sure you want to log out?',
'revoke_device' => 'Are you sure you want to revoke this device?', 'revoke_device' => 'Are you sure you want to revoke this device?',
@ -79,6 +87,7 @@
'name' => 'Name', 'name' => 'Name',
'login' => 'Login', 'login' => 'Login',
'webauthn_login' => 'WebAuthn login', 'webauthn_login' => 'WebAuthn login',
'sso_login' => 'SSO login',
'email' => 'Email', 'email' => 'Email',
'password' => 'Password', 'password' => 'Password',
'reveal_password' => 'Reveal password', 'reveal_password' => 'Reveal password',
@ -94,6 +103,7 @@
'reset_your_password' => 'Reset your password', 'reset_your_password' => 'Reset your password',
'reset_password' => 'Reset password', 'reset_password' => 'Reset password',
'disabled_in_demo' => 'Feature disabled in Demo mode', 'disabled_in_demo' => 'Feature disabled in Demo mode',
'sso_only_form_restricted_to_admin' => 'Regular users must sign in with SSO. Other methods are for administrators only.',
'new_password' => 'New password', 'new_password' => 'New password',
'current_password' => [ 'current_password' => [
'label' => 'Current password', 'label' => 'Current password',
@ -124,5 +134,9 @@
'optional_rules_you_should_follow' => 'Recommanded (highly)', 'optional_rules_you_should_follow' => 'Recommanded (highly)',
'caps_lock_is_on' => 'Caps lock is On', 'caps_lock_is_on' => 'Caps lock is On',
], ],
'sso_providers' => [
'unknown' => 'unknown',
'github' => 'Github',
'openid' => 'OpenID'
]
]; ];

View File

@ -43,7 +43,8 @@
'2fauth_has_not_a_valid_domain' => '2FAuth\'s domain is not a valid domain', '2fauth_has_not_a_valid_domain' => '2FAuth\'s domain is not a valid domain',
'user_id_not_between_1_64' => 'User ID was not between 1 and 64 chars', 'user_id_not_between_1_64' => 'User ID was not between 1 and 64 chars',
'no_entry_was_of_type_public_key' => 'No entry was of type "public-key"', 'no_entry_was_of_type_public_key' => 'No entry was of type "public-key"',
'unsupported_with_reverseproxy' => 'Not applicable when using an auth proxy', 'unsupported_with_reverseproxy' => 'Not applicable when using an auth proxy or SSO',
'unsupported_with_sso_only' => 'This authentication method is for administrators only. Users must log in with SSO.',
'user_deletion_failed' => 'User account deletion failed, no data have been deleted', 'user_deletion_failed' => 'User account deletion failed, no data have been deleted',
'auth_proxy_failed' => 'Proxy authentication failed', 'auth_proxy_failed' => 'Proxy authentication failed',
'auth_proxy_failed_legend' => '2Fauth is configured to run behind an authentication proxy but your proxy does not return the expected header. Check your configuration and try again.', 'auth_proxy_failed_legend' => '2Fauth is configured to run behind an authentication proxy but your proxy does not return the expected header. Check your configuration and try again.',

View File

@ -35,7 +35,7 @@
/** /**
* Routes that only work for unauthenticated user (return an error otherwise) * Routes that only work for unauthenticated user (return an error otherwise)
*/ */
Route::group(['middleware' => ['guest', 'rejectIfDemoMode']], function () { Route::group(['middleware' => ['guest', 'rejectIfDemoMode', 'RejectIfSsoOnlyAndNotForAdmin']], function () {
Route::post('user', [RegisterController::class, 'register'])->name('user.register'); Route::post('user', [RegisterController::class, 'register'])->name('user.register');
Route::post('user/password/lost', [ForgotPasswordController::class, 'sendResetLinkEmail'])->name('user.password.lost'); Route::post('user/password/lost', [ForgotPasswordController::class, 'sendResetLinkEmail'])->name('user.password.lost');
Route::post('user/password/reset', [ResetPasswordController::class, 'reset'])->name('password.reset'); Route::post('user/password/reset', [ResetPasswordController::class, 'reset'])->name('password.reset');
@ -46,7 +46,7 @@
/** /**
* Routes that can be requested max 10 times per minute by the same IP * Routes that can be requested max 10 times per minute by the same IP
*/ */
Route::group(['middleware' => ['rejectIfDemoMode', 'throttle:10,1']], function () { Route::group(['middleware' => ['rejectIfDemoMode', 'throttle:10,1', 'RejectIfSsoOnlyAndNotForAdmin']], function () {
Route::post('webauthn/recover', [WebAuthnRecoveryController::class, 'recover'])->name('webauthn.recover'); Route::post('webauthn/recover', [WebAuthnRecoveryController::class, 'recover'])->name('webauthn.recover');
}); });
@ -55,15 +55,16 @@
* that can be requested max 10 times per minute by the same IP * that can be requested max 10 times per minute by the same IP
*/ */
Route::group(['middleware' => ['guest', 'throttle:10,1']], function () { Route::group(['middleware' => ['guest', 'throttle:10,1']], function () {
Route::post('user/login', [LoginController::class, 'login'])->name('user.login'); Route::post('user/login', [LoginController::class, 'login'])->name('user.login')->middleware('RejectIfSsoOnlyAndNotForAdmin');;
Route::post('webauthn/login', [WebAuthnLoginController::class, 'login'])->name('webauthn.login'); Route::post('webauthn/login', [WebAuthnLoginController::class, 'login'])->name('webauthn.login')->middleware('RejectIfSsoOnlyAndNotForAdmin');
Route::get('/socialite/redirect/{driver}', [SocialiteController::class, 'redirect'])->name('socialite.redirect'); Route::get('/socialite/redirect/{driver}', [SocialiteController::class, 'redirect'])->name('socialite.redirect');
Route::get('/socialite/callback/{driver}', [SocialiteController::class, 'callback'])->name('socialite.callback'); Route::get('/socialite/callback/{driver}', [SocialiteController::class, 'callback'])->name('socialite.callback');
}); });
/** /**
* Routes protected by an authentication guard but rejected when reverse-proxy guard is enabled * Routes protected by an authentication guard but rejected when the reverse-proxy
* guard is enabled or SSO only is enabled
*/ */
Route::group(['middleware' => ['behind-auth', 'rejectIfReverseProxy']], function () { Route::group(['middleware' => ['behind-auth', 'rejectIfReverseProxy']], function () {
Route::put('user', [UserController::class, 'update'])->name('user.update'); Route::put('user', [UserController::class, 'update'])->name('user.update');
@ -71,15 +72,15 @@
Route::get('user/logout', [LoginController::class, 'logout'])->name('user.logout'); Route::get('user/logout', [LoginController::class, 'logout'])->name('user.logout');
Route::delete('user', [UserController::class, 'delete'])->name('user.delete')->middleware('rejectIfDemoMode'); Route::delete('user', [UserController::class, 'delete'])->name('user.delete')->middleware('rejectIfDemoMode');
Route::get('oauth/personal-access-tokens', [PersonalAccessTokenController::class, 'forUser'])->name('passport.personal.tokens.index'); Route::get('oauth/personal-access-tokens', [PersonalAccessTokenController::class, 'forUser'])->name('passport.personal.tokens.index')->middleware('RejectIfSsoOnlyAndNotForAdmin');
Route::post('oauth/personal-access-tokens', [PersonalAccessTokenController::class, 'store'])->name('passport.personal.tokens.store'); Route::post('oauth/personal-access-tokens', [PersonalAccessTokenController::class, 'store'])->name('passport.personal.tokens.store')->middleware('RejectIfSsoOnlyAndNotForAdmin');
Route::delete('oauth/personal-access-tokens/{token_id}', [PersonalAccessTokenController::class, 'destroy'])->name('passport.personal.tokens.destroy'); Route::delete('oauth/personal-access-tokens/{token_id}', [PersonalAccessTokenController::class, 'destroy'])->name('passport.personal.tokens.destroy')->middleware('RejectIfSsoOnlyAndNotForAdmin');
Route::post('webauthn/register/options', [WebAuthnRegisterController::class, 'options'])->name('webauthn.register.options'); Route::post('webauthn/register/options', [WebAuthnRegisterController::class, 'options'])->name('webauthn.register.options')->middleware('RejectIfSsoOnlyAndNotForAdmin');
Route::post('webauthn/register', [WebAuthnRegisterController::class, 'register'])->name('webauthn.register'); Route::post('webauthn/register', [WebAuthnRegisterController::class, 'register'])->name('webauthn.register')->middleware('RejectIfSsoOnlyAndNotForAdmin');
Route::get('webauthn/credentials', [WebAuthnManageController::class, 'index'])->name('webauthn.credentials.index'); Route::get('webauthn/credentials', [WebAuthnManageController::class, 'index'])->name('webauthn.credentials.index')->middleware('RejectIfSsoOnlyAndNotForAdmin');
Route::patch('webauthn/credentials/{credential}/name', [WebAuthnManageController::class, 'rename'])->name('webauthn.credentials.rename'); Route::patch('webauthn/credentials/{credential}/name', [WebAuthnManageController::class, 'rename'])->name('webauthn.credentials.rename')->middleware('RejectIfSsoOnlyAndNotForAdmin');
Route::delete('webauthn/credentials/{credential}', [WebAuthnManageController::class, 'delete'])->name('webauthn.credentials.delete'); Route::delete('webauthn/credentials/{credential}', [WebAuthnManageController::class, 'delete'])->name('webauthn.credentials.delete')->middleware('RejectIfSsoOnlyAndNotForAdmin');
}); });
/** /**

View File

@ -3,7 +3,9 @@
namespace Tests\Feature\Http\Auth; namespace Tests\Feature\Http\Auth;
use App\Extensions\WebauthnTwoFAuthUserProvider; use App\Extensions\WebauthnTwoFAuthUserProvider;
use App\Facades\Settings;
use App\Http\Controllers\Auth\WebAuthnLoginController; use App\Http\Controllers\Auth\WebAuthnLoginController;
use App\Http\Middleware\RejectIfSsoOnlyAndNotForAdmin;
use App\Listeners\Authentication\FailedLoginListener; use App\Listeners\Authentication\FailedLoginListener;
use App\Listeners\Authentication\LoginListener; use App\Listeners\Authentication\LoginListener;
use App\Models\User; use App\Models\User;
@ -26,6 +28,7 @@
#[CoversClass(WebauthnTwoFAuthUserProvider::class)] #[CoversClass(WebauthnTwoFAuthUserProvider::class)]
#[CoversClass(LoginListener::class)] #[CoversClass(LoginListener::class)]
#[CoversClass(FailedLoginListener::class)] #[CoversClass(FailedLoginListener::class)]
#[CoversMethod(RejectIfSsoOnlyAndNotForAdmin::class, 'handle')]
class WebAuthnLoginControllerTest extends FeatureTestCase class WebAuthnLoginControllerTest extends FeatureTestCase
{ {
/** /**
@ -125,6 +128,34 @@ public function test_webauthn_login_returns_success()
]); ]);
} }
#[Test]
public function test_webauthn_login_of_admin_returns_success_even_with_sso_only_enabled()
{
Settings::set('useSsoOnly', true);
$this->user->promoteToAdministrator(true);
$this->user->save();
$this->createWebauthnCredential(self::CREDENTIAL_ID_ALT, $this->user->id, self::USER_ID_ALT);
$this->addWebauthnChallengeToSession();
$this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE)
->assertOk()
->assertJsonFragment([
'message' => 'authenticated',
'id' => $this->user->id,
'name' => $this->user->name,
'email' => $this->user->email,
'is_admin' => true,
])
->assertJsonStructure([
'preferences',
]);
$this->user->promoteToAdministrator(false);
$this->user->save();
}
#[Test] #[Test]
public function test_webauthn_login_sends_new_device_notification_to_existing_user() public function test_webauthn_login_sends_new_device_notification_to_existing_user()
{ {

View File

@ -0,0 +1,206 @@
<?php
namespace Tests\Feature\Http\Middlewares;
use App\Facades\Settings;
use App\Http\Middleware\RejectIfSsoOnlyAndNotForAdmin;
use App\Models\User;
use Illuminate\Http\Response;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversNothing;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Tests\FeatureTestCase;
/**
* RejectIfSsoOnlyAndNotForAdminMiddlewareTest test class
*/
#[CoversClass(RejectIfSsoOnlyAndNotForAdmin::class)]
class RejectIfSsoOnlyAndNotForAdminMiddlewareTest extends FeatureTestCase
{
/**
* @var \App\Models\User|\Illuminate\Contracts\Auth\Authenticatable
*/
protected $user;
/**
* @var \App\Models\User|\Illuminate\Contracts\Auth\Authenticatable
*/
protected $admin;
private const PASSWORD = 'password';
public function setUp() : void
{
parent::setUp();
$this->user = User::factory()->create();
$this->admin = User::factory()->administrator()->create([
'password' => self::PASSWORD
]);
Settings::set('useSsoOnly', true);
}
#[Test]
public function test_admin_login_with_password_returns_success()
{
$this->json('POST', '/user/login', [
'email' => $this->admin->email,
'password' => self::PASSWORD,
])
->assertOk();
}
#[Test]
#[CoversNothing]
public function test_admin_login_with_webauthn_returns_success()
{
// See WebAuthnLoginControllerTest->test_webauthn_login_of_admin_returns_success_even_with_sso_only_enabled()
}
#[Test]
public function test_login_of_missing_account_returns_NOT_ALLOWED()
{
$this->json('POST', '/user/login', [
'email' => 'missing@user.com',
'password' => self::PASSWORD,
])
->assertMethodNotAllowed();
}
#[Test]
#[DataProvider('providePublicEndPoints')]
public function test_public_endpoint_does_not_return_NOT_ALLOWED_if_requested_for_an_admin(string $method, string $url)
{
$expectedResponseCodes = [
Response::HTTP_OK,
Response::HTTP_UNPROCESSABLE_ENTITY,
];
$response = $this->json($method, $url, [
'email' => $this->admin->email,
]);
$this->assertContains($response->getStatusCode(), $expectedResponseCodes);
}
#[Test]
#[DataProvider('providePublicEndPoints')]
public function test_public_endpoint_returns_NOT_ALLOWED_if_requested_for_regular_user(string $method, string $url)
{
$this->json($method, $url)
->assertMethodNotAllowed();
}
/**
* Provide Valid data for validation test
*/
public static function providePublicEndPoints() : array
{
return [
'PWD_REGISTER' => [
'method' => 'POST',
'url' => '/user',
],
'PWD_LOGIN' => [
'method' => 'POST',
'url' => '/user/login',
],
'PWD_LOST' => [
'method' => 'POST',
'url' => '/user/password/lost',
],
'PWD_RESET' => [
'method' => 'POST',
'url' => '/user/password/reset',
],
'WEBAUTHN_LOGIN' => [
'method' => 'POST',
'url' => '/webauthn/login',
],
'WEBAUTHN_LOGIN_OPTIONS' => [
'method' => 'POST',
'url' => '/webauthn/login/options',
],
'WEBAUTHN_LOST' => [
'method' => 'POST',
'url' => '/webauthn/lost',
],
'WEBAUTHN_RECOVER' => [
'method' => 'POST',
'url' => '/webauthn/recover',
],
];
}
#[Test]
#[DataProvider('provideProtectedEndPoints')]
public function test_protected_endpoint_are_allowed_if_requested_by_an_admin(string $method, string $url)
{
$expectedResponseCodes = [
Response::HTTP_OK,
Response::HTTP_UNPROCESSABLE_ENTITY,
Response::HTTP_NOT_FOUND,
Response::HTTP_CREATED,
Response::HTTP_NO_CONTENT,
];
$response = $this->actingAs($this->admin, 'web-guard')
->json($method, $url, [
'email' => $this->admin->email,
]);
$this->assertContains($response->getStatusCode(), $expectedResponseCodes);
}
#[Test]
#[DataProvider('provideProtectedEndPoints')]
public function test_protected_endpoint_returns_NOT_ALLOWED_if_requested_by_regular_user(string $method, string $url)
{
$this->actingAs($this->user, 'web-guard')
->json($method, $url)
->assertMethodNotAllowed();
}
/**
* Provide Valid data for validation test
*/
public static function provideProtectedEndPoints() : array
{
return [
'WEBAUTHN_REGISTER' => [
'method' => 'POST',
'url' => '/webauthn/register',
],
'WEBAUTHN_REGISTER_OPTIONS' => [
'method' => 'POST',
'url' => '/webauthn/register/options',
],
'WEBAUTHN_CREDENTIALS_ALL' => [
'method' => 'GET',
'url' => '/webauthn/credentials',
],
'WEBAUTHN_CREDENTIALS_PATCH' => [
'method' => 'PATCH',
'url' => '/webauthn/credentials/FAKE_CREDENTIAL_ID/name',
],
'WEBAUTHN_CREDENTIALS_DELETE' => [
'method' => 'DELETE',
'url' => '/webauthn/credentials/FAKE_CREDENTIAL_ID',
],
'OAUTH_PAT_ALL' => [
'method' => 'GET',
'url' => '/oauth/personal-access-tokens',
],
'OAUTH_PAT_STORE' => [
'method' => 'POST',
'url' => '/oauth/personal-access-tokens',
],
'OAUTH_PAT_DELETE' => [
'method' => 'DELETE',
'url' => '/oauth/personal-access-tokens/FAKE_TOKEN_ID',
],
];
}
}