mirror of
https://github.com/Bubka/2FAuth.git
synced 2024-11-25 09:44:04 +01:00
This commit is contained in:
parent
4d56e74b6f
commit
091129ef06
@ -71,13 +71,14 @@ class Kernel extends HttpKernel
|
||||
* @var array<string, class-string|string>
|
||||
*/
|
||||
protected $middlewareAliases = [
|
||||
'auth' => \App\Http\Middleware\Authenticate::class,
|
||||
'admin' => \App\Http\Middleware\AdminOnly::class,
|
||||
'guest' => \App\Http\Middleware\RejectIfAuthenticated::class,
|
||||
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
||||
'rejectIfDemoMode' => \App\Http\Middleware\RejectIfDemoMode::class,
|
||||
'rejectIfReverseProxy' => \App\Http\Middleware\RejectIfReverseProxy::class,
|
||||
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
|
||||
'auth' => \App\Http\Middleware\Authenticate::class,
|
||||
'admin' => \App\Http\Middleware\AdminOnly::class,
|
||||
'guest' => \App\Http\Middleware\RejectIfAuthenticated::class,
|
||||
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
||||
'rejectIfDemoMode' => \App\Http\Middleware\RejectIfDemoMode::class,
|
||||
'rejectIfReverseProxy' => \App\Http\Middleware\RejectIfReverseProxy::class,
|
||||
'RejectIfSsoOnlyAndNotForAdmin' => \App\Http\Middleware\RejectIfSsoOnlyAndNotForAdmin::class,
|
||||
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
|
||||
// 'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
|
||||
// 'signed' => \App\Http\Middleware\ValidateSignature::class,
|
||||
];
|
||||
|
@ -26,7 +26,8 @@ protected function authenticate($request, array $guards)
|
||||
// Will retreive the default guard
|
||||
$guards = [null];
|
||||
} 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) {
|
||||
$guards = [$proxyGuard];
|
||||
}
|
||||
|
41
app/Http/Middleware/RejectIfSsoOnlyAndNotForAdmin.php
Normal file
41
app/Http/Middleware/RejectIfSsoOnlyAndNotForAdmin.php
Normal 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);
|
||||
}
|
||||
}
|
@ -89,6 +89,7 @@
|
||||
'latestRelease' => false,
|
||||
'disableRegistration' => false,
|
||||
'enableSso' => true,
|
||||
'useSsoOnly' => false,
|
||||
'restrictRegistration' => false,
|
||||
'restrictList' => '',
|
||||
'restrictRule' => '',
|
||||
|
31
resources/js/components/SsoConnectLink.vue
Normal file
31
resources/js/components/SsoConnectLink.vue
Normal 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>
|
||||
|
@ -78,6 +78,8 @@
|
||||
<h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('admin.single_sign_on') }}</h4>
|
||||
<!-- 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" />
|
||||
<!-- 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>
|
||||
<!-- 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" />
|
||||
|
@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import Form from '@/components/formElements/Form'
|
||||
import SsoConnectLink from '@/components/SsoConnectLink.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useNotifyStore } from '@/stores/notify'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
@ -16,13 +17,29 @@
|
||||
password: ''
|
||||
}))
|
||||
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
|
||||
*/
|
||||
function toggleForm() {
|
||||
function switchToForm(formName) {
|
||||
form.clear()
|
||||
showWebauthnForm.value = ! showWebauthnForm.value
|
||||
activeForm.value = formName
|
||||
showWebauthnForm.value = activeForm.value == 'webauthn'
|
||||
}
|
||||
|
||||
/**
|
||||
@ -101,7 +118,8 @@
|
||||
|
||||
<template>
|
||||
<!-- 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">
|
||||
{{ $t('auth.webauthn.use_security_device_to_sign_in') }}
|
||||
</div>
|
||||
@ -116,36 +134,64 @@
|
||||
{{ $t('auth.webauthn.recover_your_account') }}
|
||||
</RouterLink>
|
||||
</p>
|
||||
<p v-if="!user.preferences.useWebauthnOnly">{{ $t('auth.sign_in_using') }}
|
||||
<a id="lnkSignWithLegacy" role="button" class="is-link" @keyup.enter="toggleForm" @click="toggleForm" tabindex="0">
|
||||
<p>{{ $t('auth.sign_in_using') }}
|
||||
<a id="lnkSignWithLegacy" role="button" class="is-link" @keyup.enter="switchToForm('legacy')" @click="switchToForm('legacy')" tabindex="0">
|
||||
{{ $t('auth.login_and_password') }}
|
||||
</a>
|
||||
</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') }}
|
||||
<RouterLink id="lnkRegister" :to="{ name: 'register' }" class="is-link">
|
||||
{{ $t('auth.register') }}
|
||||
</RouterLink>
|
||||
</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">
|
||||
{{ $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 class="buttons">
|
||||
<template v-for="(isEnabled, provider) in $2fauth.config.sso">
|
||||
<SsoConnectLink v-if="isEnabled" :class="'is-outlined is-small'" :provider="provider" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormWrapper>
|
||||
<!-- login/password legacy form -->
|
||||
<FormWrapper v-else title="auth.forms.login" punchline="auth.welcome_to_2fauth">
|
||||
<!-- SSO only links -->
|
||||
<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.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') }}
|
||||
<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') }}
|
||||
<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)">
|
||||
<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" />
|
||||
@ -158,11 +204,11 @@
|
||||
</RouterLink>
|
||||
</p>
|
||||
<p >{{ $t('auth.sign_in_using') }}
|
||||
<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') }}
|
||||
</a>
|
||||
</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') }}
|
||||
<RouterLink id="lnkRegister" :to="{ name: 'register' }" class="is-link">
|
||||
{{ $t('auth.register') }}
|
||||
@ -173,12 +219,11 @@
|
||||
{{ $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 class="buttons">
|
||||
<template v-for="(isEnabled, provider) in $2fauth.config.sso">
|
||||
<SsoConnectLink v-if="isEnabled" :class="'is-outlined is-small'" :provider="provider" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -107,7 +107,7 @@
|
||||
<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">
|
||||
<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 }) }}
|
||||
</div>
|
||||
<form @submit.prevent="submitProfile" @keydown="formProfile.onKeydown($event)">
|
||||
|
@ -4,20 +4,25 @@
|
||||
import SettingTabs from '@/layouts/SettingTabs.vue'
|
||||
import { useNotifyStore } from '@/stores/notify'
|
||||
import { UseColorMode } from '@vueuse/components'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import Spinner from '@/components/Spinner.vue'
|
||||
|
||||
const $2fauth = inject('2fauth')
|
||||
const notify = useNotifyStore()
|
||||
const user = useUserStore()
|
||||
const returnTo = useStorage($2fauth.prefix + 'returnTo', 'accounts')
|
||||
const { copy } = useClipboard({ legacy: true })
|
||||
|
||||
const tokens = ref([])
|
||||
const isFetching = ref(false)
|
||||
const isRemoteUser = ref(false)
|
||||
const createPATModalIsVisible = ref(false)
|
||||
const visibleToken = ref(null)
|
||||
const visibleTokenId = ref(null)
|
||||
|
||||
const isDisabled = computed(() => {
|
||||
return (appSettings.enableSso && appSettings.useSsoOnly) || user.authenticated_by_proxy
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchTokens()
|
||||
})
|
||||
@ -47,9 +52,9 @@
|
||||
})
|
||||
.catch(error => {
|
||||
if( error.response.status === 405 ) {
|
||||
// The backend returns a 405 response for routes with the
|
||||
// rejectIfReverseProxy middleware
|
||||
isRemoteUser.value = true
|
||||
// The backend returns a 405 response if the user is authenticated by a reverse proxy
|
||||
// or if SSO only is enabled.
|
||||
// The form is already disabled (see isDisabled) so we do nothing more here
|
||||
}
|
||||
else {
|
||||
notify.error(error)
|
||||
@ -69,7 +74,7 @@
|
||||
function showPATcreationForm() {
|
||||
clearTokenValues()
|
||||
|
||||
if (isRemoteUser.value) {
|
||||
if (isDisabled.value) {
|
||||
notify.warn({ text: trans('errors.unsupported_with_reverseproxy') })
|
||||
}
|
||||
else createPATModalIsVisible.value = true
|
||||
@ -141,7 +146,10 @@
|
||||
<SettingTabs :activeTab="'settings.oauth.tokens'" />
|
||||
<div class="options-tabs">
|
||||
<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>
|
||||
<div class="is-size-7-mobile">
|
||||
{{ $t('settings.token_legend')}}
|
||||
|
@ -2,6 +2,7 @@
|
||||
import SettingTabs from '@/layouts/SettingTabs.vue'
|
||||
import userService from '@/services/userService'
|
||||
import { webauthnService } from '@/services/webauthn/webauthnService'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useNotifyStore } from '@/stores/notify'
|
||||
import { UseColorMode } from '@vueuse/components'
|
||||
@ -9,13 +10,17 @@
|
||||
|
||||
const $2fauth = inject('2fauth')
|
||||
const user = useUserStore()
|
||||
const appSettings = useAppSettingsStore()
|
||||
const notify = useNotifyStore()
|
||||
const router = useRouter()
|
||||
const returnTo = useStorage($2fauth.prefix + 'returnTo', 'accounts')
|
||||
|
||||
const credentials = ref([])
|
||||
const isFetching = ref(false)
|
||||
const isRemoteUser = ref(false)
|
||||
|
||||
const isDisabled = computed(() => {
|
||||
return (appSettings.enableSso && appSettings.useSsoOnly) || user.authenticated_by_proxy
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchCredentials()
|
||||
@ -31,8 +36,7 @@
|
||||
* Register a new security device
|
||||
*/
|
||||
function register() {
|
||||
|
||||
if (isRemoteUser == true) {
|
||||
if (isDisabled.value == true) {
|
||||
notify.warn({text: trans('errors.unsupported_with_reverseproxy') })
|
||||
return false
|
||||
}
|
||||
@ -95,9 +99,9 @@
|
||||
})
|
||||
.catch(error => {
|
||||
if( error.response.status === 405 ) {
|
||||
// The backend returns a 405 response for routes with the
|
||||
// rejectIfReverseProxy middleware
|
||||
isRemoteUser.value = true
|
||||
// The backend returns a 405 response if the user is authenticated by a reverse proxy
|
||||
// or if SSO only is enabled.
|
||||
// The form is already disabled (see isDisabled) so we do nothing more here
|
||||
}
|
||||
else {
|
||||
notify.error(error)
|
||||
@ -120,7 +124,10 @@
|
||||
<SettingTabs :activeTab="'settings.webauthn.devices'" />
|
||||
<div class="options-tabs">
|
||||
<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>
|
||||
<div class="is-size-7-mobile">
|
||||
{{ $t('auth.webauthn.security_devices_legend')}}
|
||||
@ -161,7 +168,7 @@
|
||||
fieldName="useWebauthnOnly"
|
||||
label="auth.webauthn.use_webauthn_only.label"
|
||||
help="auth.webauthn.use_webauthn_only.help"
|
||||
:disabled="isRemoteUser || credentials.length === 0"
|
||||
:isDisabled="isDisabled || credentials.length === 0"
|
||||
/>
|
||||
</form>
|
||||
<!-- footer -->
|
||||
|
@ -110,6 +110,10 @@
|
||||
'label' => 'Enable SSO',
|
||||
'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' => [
|
||||
'label' => 'Keep SSO registration enabled',
|
||||
'help' => 'Allow new users to sign in for the first time via SSO whereas registration is disabled',
|
||||
|
@ -22,7 +22,14 @@
|
||||
'sign_out' => 'Sign out',
|
||||
'sign_in' => 'Sign in',
|
||||
'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:',
|
||||
'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',
|
||||
'login_and_password' => 'login & password',
|
||||
'register' => 'Register',
|
||||
@ -34,6 +41,7 @@
|
||||
'maybe_later' => 'Maybe later',
|
||||
'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.',
|
||||
'sso_only_x_settings_are_disabled' => 'Authentication is restricted to SSO only, :auth_method is disabled',
|
||||
'confirm' => [
|
||||
'logout' => 'Are you sure you want to log out?',
|
||||
'revoke_device' => 'Are you sure you want to revoke this device?',
|
||||
@ -79,6 +87,7 @@
|
||||
'name' => 'Name',
|
||||
'login' => 'Login',
|
||||
'webauthn_login' => 'WebAuthn login',
|
||||
'sso_login' => 'SSO login',
|
||||
'email' => 'Email',
|
||||
'password' => 'Password',
|
||||
'reveal_password' => 'Reveal password',
|
||||
@ -94,6 +103,7 @@
|
||||
'reset_your_password' => 'Reset your password',
|
||||
'reset_password' => 'Reset password',
|
||||
'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',
|
||||
'current_password' => [
|
||||
'label' => 'Current password',
|
||||
@ -124,5 +134,9 @@
|
||||
'optional_rules_you_should_follow' => 'Recommanded (highly)',
|
||||
'caps_lock_is_on' => 'Caps lock is On',
|
||||
],
|
||||
|
||||
'sso_providers' => [
|
||||
'unknown' => 'unknown',
|
||||
'github' => 'Github',
|
||||
'openid' => 'OpenID'
|
||||
]
|
||||
];
|
||||
|
@ -43,7 +43,8 @@
|
||||
'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',
|
||||
'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',
|
||||
'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.',
|
||||
|
@ -35,7 +35,7 @@
|
||||
/**
|
||||
* 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/password/lost', [ForgotPasswordController::class, 'sendResetLinkEmail'])->name('user.password.lost');
|
||||
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
|
||||
*/
|
||||
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');
|
||||
});
|
||||
|
||||
@ -55,15 +55,16 @@
|
||||
* that can be requested max 10 times per minute by the same IP
|
||||
*/
|
||||
Route::group(['middleware' => ['guest', 'throttle:10,1']], function () {
|
||||
Route::post('user/login', [LoginController::class, 'login'])->name('user.login');
|
||||
Route::post('webauthn/login', [WebAuthnLoginController::class, 'login'])->name('webauthn.login');
|
||||
Route::post('user/login', [LoginController::class, 'login'])->name('user.login')->middleware('RejectIfSsoOnlyAndNotForAdmin');;
|
||||
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/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::put('user', [UserController::class, 'update'])->name('user.update');
|
||||
@ -71,15 +72,15 @@
|
||||
Route::get('user/logout', [LoginController::class, 'logout'])->name('user.logout');
|
||||
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::post('oauth/personal-access-tokens', [PersonalAccessTokenController::class, 'store'])->name('passport.personal.tokens.store');
|
||||
Route::delete('oauth/personal-access-tokens/{token_id}', [PersonalAccessTokenController::class, 'destroy'])->name('passport.personal.tokens.destroy');
|
||||
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')->middleware('RejectIfSsoOnlyAndNotForAdmin');
|
||||
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', [WebAuthnRegisterController::class, 'register'])->name('webauthn.register');
|
||||
Route::get('webauthn/credentials', [WebAuthnManageController::class, 'index'])->name('webauthn.credentials.index');
|
||||
Route::patch('webauthn/credentials/{credential}/name', [WebAuthnManageController::class, 'rename'])->name('webauthn.credentials.rename');
|
||||
Route::delete('webauthn/credentials/{credential}', [WebAuthnManageController::class, 'delete'])->name('webauthn.credentials.delete');
|
||||
Route::post('webauthn/register/options', [WebAuthnRegisterController::class, 'options'])->name('webauthn.register.options')->middleware('RejectIfSsoOnlyAndNotForAdmin');
|
||||
Route::post('webauthn/register', [WebAuthnRegisterController::class, 'register'])->name('webauthn.register')->middleware('RejectIfSsoOnlyAndNotForAdmin');
|
||||
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')->middleware('RejectIfSsoOnlyAndNotForAdmin');
|
||||
Route::delete('webauthn/credentials/{credential}', [WebAuthnManageController::class, 'delete'])->name('webauthn.credentials.delete')->middleware('RejectIfSsoOnlyAndNotForAdmin');
|
||||
});
|
||||
|
||||
/**
|
||||
|
@ -3,7 +3,9 @@
|
||||
namespace Tests\Feature\Http\Auth;
|
||||
|
||||
use App\Extensions\WebauthnTwoFAuthUserProvider;
|
||||
use App\Facades\Settings;
|
||||
use App\Http\Controllers\Auth\WebAuthnLoginController;
|
||||
use App\Http\Middleware\RejectIfSsoOnlyAndNotForAdmin;
|
||||
use App\Listeners\Authentication\FailedLoginListener;
|
||||
use App\Listeners\Authentication\LoginListener;
|
||||
use App\Models\User;
|
||||
@ -26,6 +28,7 @@
|
||||
#[CoversClass(WebauthnTwoFAuthUserProvider::class)]
|
||||
#[CoversClass(LoginListener::class)]
|
||||
#[CoversClass(FailedLoginListener::class)]
|
||||
#[CoversMethod(RejectIfSsoOnlyAndNotForAdmin::class, 'handle')]
|
||||
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]
|
||||
public function test_webauthn_login_sends_new_device_notification_to_existing_user()
|
||||
{
|
||||
|
@ -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',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user