Browse Source

move 2fa to its own page

dev-oauth
Bernd Storath 2 days ago
parent
commit
d1a9c15f06
  1. 4
      src/app/layouts/default.vue
  2. 6
      src/app/middleware/auth.global.ts
  3. 144
      src/app/pages/login.vue
  4. 106
      src/app/pages/login/2fa.vue
  5. 130
      src/app/pages/login/index.vue
  6. 9
      src/app/pages/me.vue
  7. 36
      src/app/stores/login.ts

4
src/app/layouts/default.vue

@ -29,5 +29,7 @@
<script setup lang="ts">
const route = useRoute();
const loggedIn = computed(() => route.path !== '/login');
const loggedIn = computed(
() => route.path !== '/login' && route.path !== '/login/2fa'
);
</script>

6
src/app/middleware/auth.global.ts

@ -9,8 +9,10 @@ export default defineNuxtRouteMiddleware(async (to) => {
const authStore = useAuthStore();
authStore.userData = await authStore.getSession(event);
// skip login if already logged in
if (to.path === '/login') {
const isLoginRoute = to.path === '/login' || to.path === '/login/2fa';
// skip login pages if already logged in
if (isLoginRoute) {
if (authStore.userData?.username) {
return navigateTo('/', { redirectCode: 302 });
}

144
src/app/pages/login.vue

@ -12,149 +12,7 @@
<IconsAvatar class="m-5 h-10 w-10 text-white dark:text-white" />
</div>
<div
v-if="authMethods && authMethods.oauthEnabled"
class="flex flex-col gap-5"
>
<UiLoginOauthButton
v-for="(info, provider) in authMethods.providers"
:key="provider"
:provider="provider"
:info="info"
/>
<!-- Divider -->
<div
v-if="authMethods.oauthEnabled && !authMethods.passwordDisabled"
class="flex items-center gap-2"
>
<div class="h-px flex-1 bg-gray-300 dark:bg-neutral-600"></div>
<span class="text-xs text-gray-500 dark:text-neutral-400">
{{ $t('login.or') }}
</span>
<div class="h-px flex-1 bg-gray-300 dark:bg-neutral-600"></div>
</div>
</div>
<!-- Classic Login Form -->
<form
v-if="!authMethods?.passwordDisabled"
class="flex flex-col gap-5"
@submit.prevent="submit"
>
<BaseInput
v-model="username"
type="text"
:placeholder="$t('general.username')"
autocomplete="username"
autofocus
name="username"
/>
<BaseInput
v-model="password"
type="password"
name="password"
:placeholder="$t('general.password')"
autocomplete="current-password"
/>
<BaseInput
v-if="totpRequired"
v-model="totp"
type="text"
name="totp"
:placeholder="$t('general.2faCode')"
autocomplete="one-time-code"
inputmode="numeric"
maxlength="6"
pattern="\d{6}"
/>
<label
class="flex gap-2 whitespace-nowrap"
:title="$t('login.rememberMeDesc')"
>
<BaseSwitch v-model="remember" />
<span class="text-sm">{{ $t('login.rememberMe') }}</span>
</label>
<button
class="rounded bg-red-800 py-2 text-sm text-white shadow transition hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-gray-200 dark:bg-red-800 dark:text-white dark:hover:bg-red-700 disabled:dark:bg-neutral-800"
:disabled="!password || !username"
>
<IconsLoading
v-if="authenticating"
class="mx-auto w-5 animate-spin"
/>
<span v-else>{{ $t('login.signIn') }}</span>
</button>
</form>
<NuxtPage />
</div>
</main>
</template>
<script setup lang="ts">
const toast = useToast();
const { t } = useI18n();
const authenticating = ref(false);
const remember = ref(false);
const username = ref<string>('');
const password = ref<string>('');
const totpRequired = ref(false);
const totp = ref<string>('');
const { data: authMethods } = await useFetch('/api/auth/methods');
const _submit = useSubmit(
(data) =>
$fetch('/api/auth/password', {
method: 'post',
body: data,
}),
{
revert: async (success, data) => {
if (success) {
if (data?.status === 'success') {
await navigateTo('/');
} else if (data?.status === 'TOTP_REQUIRED') {
authenticating.value = false;
totpRequired.value = true;
toast.showToast({
title: t('general.2fa'),
message: t('login.2faRequired'),
type: 'error',
});
return;
} else if (data?.status === 'INVALID_TOTP_CODE') {
authenticating.value = false;
totp.value = '';
toast.showToast({
title: t('general.2fa'),
message: t('login.2faWrong'),
type: 'error',
});
return;
}
}
authenticating.value = false;
password.value = '';
},
noSuccessToast: true,
}
);
async function submit() {
if (!username.value || !password.value || authenticating.value) return;
authenticating.value = true;
return _submit({
username: username.value,
password: password.value,
remember: remember.value,
totpCode: totpRequired.value ? totp.value : undefined,
});
}
</script>

106
src/app/pages/login/2fa.vue

@ -0,0 +1,106 @@
<template>
<div>
<form class="flex flex-col gap-5" @submit.prevent="submit">
<BaseInput
v-model="totp"
type="text"
name="totp"
:placeholder="$t('general.2faCode')"
autocomplete="one-time-code"
inputmode="numeric"
maxlength="6"
pattern="\d{6}"
autofocus
/>
<button
class="rounded bg-red-800 py-2 text-sm text-white shadow transition hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-gray-200 dark:bg-red-800 dark:text-white dark:hover:bg-red-700 disabled:dark:bg-neutral-800"
:disabled="!totp || authenticating"
>
<IconsLoading v-if="authenticating" class="mx-auto w-5 animate-spin" />
<span v-else>{{ $t('general.continue') }}</span>
</button>
<button
type="button"
class="rounded border-2 border-gray-100 py-2 text-sm text-gray-700 transition hover:border-red-800 hover:bg-red-800 hover:text-white dark:border-neutral-600 dark:text-neutral-200"
@click="cancel"
>
{{ $t('dialog.cancel') }}
</button>
</form>
</div>
</template>
<script setup lang="ts">
const toast = useToast();
const { t } = useI18n();
const loginStore = useLoginStore();
const authenticating = ref(false);
const totp = ref<string>('');
if (!loginStore.hasPendingLogin) {
await navigateTo('/login');
}
const _submit = useSubmit(
(data) =>
$fetch('/api/auth/password', {
method: 'post',
body: data,
}),
{
revert: async (success, data) => {
if (success) {
if (data?.status === 'success') {
loginStore.clearPendingLogin();
await navigateTo('/');
} else if (data?.status === 'INVALID_TOTP_CODE') {
authenticating.value = false;
totp.value = '';
toast.showToast({
title: t('general.2fa'),
message: t('login.2faWrong'),
type: 'error',
});
return;
} else if (data?.status === 'TOTP_REQUIRED') {
authenticating.value = false;
totp.value = '';
toast.showToast({
title: t('general.2fa'),
message: t('login.2faRequired'),
type: 'error',
});
return;
}
}
authenticating.value = false;
},
noSuccessToast: true,
}
);
async function submit() {
const challenge = loginStore.pendingChallenge;
if (!challenge || !totp.value || authenticating.value) return;
authenticating.value = true;
if (challenge.type === 'password') {
return _submit({
username: challenge.username,
password: challenge.password,
remember: challenge.remember,
totpCode: totp.value,
});
}
}
async function cancel() {
loginStore.clearPendingLogin();
await navigateTo('/login');
}
</script>

130
src/app/pages/login/index.vue

@ -0,0 +1,130 @@
<template>
<div>
<div
v-if="authMethods && authMethods.oauthEnabled"
class="flex flex-col gap-5"
>
<UiLoginOauthButton
v-for="(info, provider) in authMethods.providers"
:key="provider"
:provider="provider"
:info="info"
/>
<!-- Divider -->
<div
v-if="authMethods.oauthEnabled && !authMethods.passwordDisabled"
class="flex items-center gap-2"
>
<div class="h-px flex-1 bg-gray-300 dark:bg-neutral-600"></div>
<span class="text-xs text-gray-500 dark:text-neutral-400">
{{ $t('login.or') }}
</span>
<div class="h-px flex-1 bg-gray-300 dark:bg-neutral-600"></div>
</div>
</div>
<!-- Classic Login Form -->
<form
v-if="!authMethods?.passwordDisabled"
class="flex flex-col gap-5"
@submit.prevent="submit"
>
<BaseInput
v-model="username"
type="text"
:placeholder="$t('general.username')"
autocomplete="username"
autofocus
name="username"
/>
<BaseInput
v-model="password"
type="password"
name="password"
:placeholder="$t('general.password')"
autocomplete="current-password"
/>
<label
class="flex gap-2 whitespace-nowrap"
:title="$t('login.rememberMeDesc')"
>
<BaseSwitch v-model="remember" />
<span class="text-sm">{{ $t('login.rememberMe') }}</span>
</label>
<button
class="rounded bg-red-800 py-2 text-sm text-white shadow transition hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-gray-200 dark:bg-red-800 dark:text-white dark:hover:bg-red-700 disabled:dark:bg-neutral-800"
:disabled="!password || !username"
>
<IconsLoading v-if="authenticating" class="mx-auto w-5 animate-spin" />
<span v-else>{{ $t('login.signIn') }}</span>
</button>
</form>
</div>
</template>
<script setup lang="ts">
const toast = useToast();
const { t } = useI18n();
const loginStore = useLoginStore();
const authenticating = ref(false);
const remember = ref(false);
const username = ref<string>('');
const password = ref<string>('');
const { data: authMethods } = await useFetch('/api/auth/methods');
const _submit = useSubmit(
(data) =>
$fetch('/api/auth/password', {
method: 'post',
body: data,
}),
{
revert: async (success, data) => {
if (success) {
if (data?.status === 'success') {
loginStore.clearPendingLogin();
await navigateTo('/');
} else if (data?.status === 'TOTP_REQUIRED') {
authenticating.value = false;
loginStore.setPendingPasswordLogin(
username.value,
password.value,
remember.value
);
await navigateTo('/login/2fa');
return;
} else if (data?.status === 'INVALID_TOTP_CODE') {
authenticating.value = false;
toast.showToast({
title: t('general.2fa'),
message: t('login.2faWrong'),
type: 'error',
});
return;
}
}
authenticating.value = false;
password.value = '';
},
noSuccessToast: true,
}
);
async function submit() {
if (!username.value || !password.value || authenticating.value) return;
authenticating.value = true;
return _submit({
username: username.value,
password: password.value,
remember: remember.value,
});
}
</script>

9
src/app/pages/me.vue

@ -317,10 +317,11 @@ async function disable2fa() {
}
const _unlinkOauth = useSubmit(
`/api/auth/unlink`,
{
method: 'post',
},
(data) =>
$fetch(`/api/auth/unlink`, {
method: 'post',
body: data,
}),
{
revert: async () => {
return authStore.update();

36
src/app/stores/login.ts

@ -0,0 +1,36 @@
type PendingLoginChallenge = {
type: 'password';
username: string;
password: string;
remember: boolean;
};
export const useLoginStore = defineStore('Login', () => {
const pendingChallenge = ref<PendingLoginChallenge | null>(null);
const hasPendingLogin = computed(() => pendingChallenge.value !== null);
function setPendingPasswordLogin(
username: string,
password: string,
remember: boolean
) {
pendingChallenge.value = {
type: 'password',
username,
password,
remember,
};
}
function clearPendingLogin() {
pendingChallenge.value = null;
}
return {
pendingChallenge,
hasPendingLogin,
setPendingPasswordLogin,
clearPendingLogin,
};
});
Loading…
Cancel
Save