- added 2fa authentication during login. see resources/js/Pages/Auth/login.vue
All checks were successful
CI Pipeline / japa-tests (push) Successful in 1m2s

- added validate() method inside app/Srvices/TwoFactorProvider.ts
- added twoFactorChallenge() method inside app/Controllers/Http/Auth/AuthController.ts for logging in via 2fa-code
This commit is contained in:
Kaimbacher 2024-02-16 15:32:47 +01:00
parent b2dce0259a
commit f828ca4491
7 changed files with 233 additions and 84 deletions

View File

@ -1,9 +1,13 @@
import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
// import User from 'App/Models/User'; import User from 'App/Models/User';
// import Hash from '@ioc:Adonis/Core/Hash'; // import Hash from '@ioc:Adonis/Core/Hash';
// import InvalidCredentialException from 'App/Exceptions/InvalidCredentialException'; // import InvalidCredentialException from 'App/Exceptions/InvalidCredentialException';
import AuthValidator from 'App/Validators/AuthValidator'; import AuthValidator from 'App/Validators/AuthValidator';
import TwoFactorAuthProvider from 'App/Services/TwoFactorAuthProvider';
// import { LoginState } from 'Contracts/enums';
// import { StatusCodes } from 'http-status-codes';
export default class AuthController { export default class AuthController {
// login function // login function
public async login({ request, response, auth, session }: HttpContextContract) { public async login({ request, response, auth, session }: HttpContextContract) {
@ -12,24 +16,30 @@ export default class AuthController {
// }); // });
await request.validate(AuthValidator); await request.validate(AuthValidator);
const plainPassword = await request.input('password'); // const plainPassword = await request.input('password');
const email = await request.input('email'); // const email = await request.input('email');
// grab uid and password values off request body // grab uid and password values off request body
// const { email, password } = request.only(['email', 'password']) const { email, password } = request.only(['email', 'password']);
try { try {
// attempt to verify credential and login user // // attempt to verify credential and login user
await auth.use('web').attempt(email, plainPassword); // await auth.use('web').attempt(email, plainPassword);
// const user = await auth.use('web').verifyCredentials(email, plainPassword); const user = await auth.use('web').verifyCredentials(email, password);
// if (user.isTwoFactorEnabled) { if (user.isTwoFactorEnabled) {
// // session.put("login.id", user.id); // session.put("login.id", user.id);
// // return view.render("pages/two-factor-challenge"); // return view.render("pages/two-factor-challenge");
// }
// session.forget('login.id'); session.flash('user_id', user.id);
// session.regenerate(); return response.redirect().back();
// await auth.login(user);
// let state = LoginState.STATE_VALIDATED;
// return response.status(StatusCodes.OK).json({
// state: state,
// new_user_id: user.id,
// });
}
await auth.login(user);
} catch (error) { } catch (error) {
// if login fails, return vague form message and redirect back // if login fails, return vague form message and redirect back
session.flash('message', 'Your username, email, or password is incorrect'); session.flash('message', 'Your username, email, or password is incorrect');
@ -40,6 +50,32 @@ export default class AuthController {
response.redirect('/apps/dashboard'); response.redirect('/apps/dashboard');
} }
public async twoFactorChallenge({ request, session, auth, response }) {
const { code, recoveryCode, login_id } = request.only(['code', 'recoveryCode', 'login_id']);
// const user = await User.query().where('id', session.get('login.id')).firstOrFail();
const user = await User.query().where('id', login_id).firstOrFail();
if (code) {
const isValid = await TwoFactorAuthProvider.validate(user, code);
if (isValid) {
// login user and redirect to dashboard
await auth.login(user);
response.redirect('/apps/dashboard');
} else {
session.flash('message', 'Your tow factor code is incorrect');
return response.redirect().back();
}
} else if (recoveryCode) {
const codes = user?.twoFactorRecoveryCodes ?? [];
if (codes.includes(recoveryCode)) {
user.twoFactorRecoveryCodes = codes.filter((c) => c !== recoveryCode);
await user.save();
await auth.login(user);
response.redirect('/apps/dashboard');
}
}
}
// logout function // logout function
public async logout({ auth, response }: HttpContextContract) { public async logout({ auth, response }: HttpContextContract) {
// await auth.logout(); // await auth.logout();

View File

@ -105,7 +105,7 @@ class TwoFactorAuthProvider {
public async enable(user: User, token: string): Promise<boolean> { public async enable(user: User, token: string): Promise<boolean> {
const isValid = verifyToken(user.twoFactorSecret as string, token, 1); const isValid = verifyToken(user.twoFactorSecret as string, token, 1);
if (!isValid) { if (!isValid) {
return false; return false;
} }
user.state = TotpState.STATE_ENABLED; user.state = TotpState.STATE_ENABLED;
if (await user.save()) { if (await user.save()) {
@ -113,6 +113,14 @@ class TwoFactorAuthProvider {
} }
return false; return false;
} }
public async validate(user: User, token: string): Promise<boolean> {
const isValid = verifyToken(user.twoFactorSecret as string, token, 1);
if (isValid) {
return true;
}
return false;
}
} }
export default new TwoFactorAuthProvider(); export default new TwoFactorAuthProvider();

View File

@ -186,3 +186,9 @@ export interface Identifier {
updated_at: string; //'2023-03-09T09:48:28.000Z' updated_at: string; //'2023-03-09T09:48:28.000Z'
value: string; //'10.24341/tethys.209' value: string; //'10.24341/tethys.209'
} }
// export enum LoginState {
// STATE_DISABLED = 0,
// STATE_VALIDATED = 1,
// STATE_2FA_AUTHENTICATED = 1,
// }

View File

@ -2,7 +2,7 @@
// import { Head, Link, useForm, usePage } from '@inertiajs/inertia-vue3'; // import { Head, Link, useForm, usePage } from '@inertiajs/inertia-vue3';
import { Head, Link, useForm, usePage } from '@inertiajs/vue3'; import { Head, Link, useForm, usePage } from '@inertiajs/vue3';
import { ComputedRef } from 'vue'; import { ComputedRef } from 'vue';
import { mdiAccountKey, mdiPlus, mdiSquareEditOutline, mdiTrashCan, mdiAlertBoxOutline } from '@mdi/js'; import { mdiAccountKey, mdiPlus, mdiSquareEditOutline, mdiAlertBoxOutline } from '@mdi/js';
import { computed } from 'vue'; import { computed } from 'vue';
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue'; import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
import SectionMain from '@/Components/SectionMain.vue'; import SectionMain from '@/Components/SectionMain.vue';

View File

@ -1,37 +1,7 @@
<!-- <template>
<div>
<Link href="/app">Home</Link>
<br />
<h1>Login</h1>
<Head>
<title>About - My app</title>
<meta
head-key="description"
name="description"
content="This is a page specific description"
/>
</Head>
</div>
</template>
<script>
import AuthLayout from '@/Layouts/Auth.vue';
import LayoutGuest from '@/Layouts/LayoutGuest.vue';
export default {
layout: AuthLayout,
};
</script>
<script setup>
// import { Head, Link } from '@inertiajs/vue3';
import { Head, Link } from '@inertiajs/inertia-vue3'
import FormField from '@/Components/FormField.vue';
import FormControl from '@/Components/FormControl.vue';
</script> -->
<template> <template>
<LayoutGuest> <LayoutGuest>
<Head title="Login" /> <Head title="Login" />
<!-- <SectionFullScreen v-slot="{ cardClass }" :bg="'greenBlue'"> --> <!-- <SectionFullScreen v-slot="{ cardClass }" :bg="'greenBlue'"> -->
@ -40,8 +10,9 @@ import FormControl from '@/Components/FormControl.vue';
<img src="/logo.svg" class="h-10 mr-4" alt="Windster Logo" /> <img src="/logo.svg" class="h-10 mr-4" alt="Windster Logo" />
<!-- <span class="self-center text-2xl font-bold whitespace-nowrap">Tethys</span> --> <!-- <span class="self-center text-2xl font-bold whitespace-nowrap">Tethys</span> -->
</a> </a>
<!-- Card -->
<CardBox :class="cardClass" form @submit.prevent="submit"> <CardBox v-if="isTwoFactorAuthNeeded == false" :class="cardClass" form
@submit.prevent="submit">
<FormValidationErrors v-bind:errors="errors" /> <FormValidationErrors v-bind:errors="errors" />
<NotificationBarInCard v-if="status" color="info"> <NotificationBarInCard v-if="status" color="info">
@ -49,18 +20,13 @@ import FormControl from '@/Components/FormControl.vue';
</NotificationBarInCard> </NotificationBarInCard>
<FormField label="Email" label-for="email" help="Please enter your email"> <FormField label="Email" label-for="email" help="Please enter your email">
<FormControl v-model="form.email" :icon="mdiAccount" id="email" autocomplete="email" type="email" required /> <FormControl v-model="form.email" :icon="mdiAccount" id="email" autocomplete="email" type="email"
required />
</FormField> </FormField>
<FormField label="Password" label-for="password" help="Please enter your password"> <FormField label="Password" label-for="password" help="Please enter your password">
<FormControl <FormControl v-model="form.password" :icon="mdiAsterisk" type="password" id="password"
v-model="form.password" autocomplete="current-password" required />
:icon="mdiAsterisk"
type="password"
id="password"
autocomplete="current-password"
required
/>
</FormField> </FormField>
<FormCheckRadioGroup v-model="form.remember" name="remember" :options="{ remember: 'Remember' }" /> <FormCheckRadioGroup v-model="form.remember" name="remember" :options="{ remember: 'Remember' }" />
@ -79,36 +45,62 @@ import FormControl from '@/Components/FormControl.vue';
<BaseDivider /> <BaseDivider />
<!-- buttons --> <!-- buttons -->
<BaseLevel>
<BaseButtons> <BaseButtons>
<!-- <BaseButton type="submit" color="info" label="Login" :class="{ 'opacity-25': form.processing }" <BaseButton type="submit" color="info" label=" Login to your account" :class="{ 'opacity-25': form.processing }"
v-bind:disabled="form.processing" /> --> v-bind:disabled="form.processing" />
<button <!-- <button type="submit" v-bind:disabled="form.processing" :class="{ 'opacity-25': form.processing }"
type="submit" class="text-white bg-cyan-600 hover:bg-cyan-700 focus:ring-4 focus:ring-cyan-200 font-medium rounded-lg text-base px-5 py-3 w-full sm:w-auto text-center">
v-bind:disabled="form.processing" Login to your account
:class="{ 'opacity-25': form.processing }" </button> -->
class="text-white bg-cyan-600 hover:bg-cyan-700 focus:ring-4 focus:ring-cyan-200 font-medium rounded-lg text-base px-5 py-3 w-full sm:w-auto text-center" <!-- <BaseButton v-if="canResetPassword" :route-name="route('password.request')" color="info" outline
>
Login to your account
</button>
<!-- <BaseButton v-if="canResetPassword" :route-name="route('password.request')" color="info" outline
label="Remind" /> --> label="Remind" /> -->
</BaseButtons> </BaseButtons>
<!-- <Link :href="stardust.route('app.register.show')"> Register </Link> --> <!-- <Link :href="stardust.route('app.register.show')"> Register </Link> -->
</BaseLevel>
<div class="text-sm font-medium text-gray-500"> <!-- <div class="text-sm font-medium text-gray-500">
Not registered? <a href="" class="text-teal-500 hover:underline">Create account</a> Not registered? <a href="" class="text-teal-500 hover:underline">Create account</a>
</div> </div> -->
</CardBox> </CardBox>
<CardBox v-else-if="isTwoFactorAuthNeeded" :icon="mdiTwoFactorAuthentication" :class="cardClass" form
@submit.prevent="submitFa2Form">
<FormField label="2FA Code" label-for="code" help="Please enter 2factor code">
<FormControl v-model="fa2Form.code" :icon="mdiAccount" id="code" type="tel" required />
</FormField>
<div v-if="flash && flash.message" class="flex flex-col mt-6 animate-fade-in">
<div class="bg-yellow-500 border-l-4 border-orange-400 text-white p-4" role="alert">
<p class="font-bold">Be Warned</p>
<p>{{ flash.message }}</p>
</div>
</div>
<BaseDivider />
<BaseButtons>
<!-- <button type="submit" :icon="mdiContentSaveCheck" :disabled="fa2Form.processing"
:class="{ 'opacity-25': fa2Form.processing }"
class="text-white bg-cyan-600 hover:bg-cyan-700 focus:ring-4 focus:ring-cyan-200 font-medium rounded-lg text-base px-5 py-3 w-full sm:w-auto text-center">
Verify
</button> -->
<BaseButton type="submit" :icon="mdiContentSaveCheck" color="info" label=" Login to your account" :class="{ 'opacity-25': fa2Form.processing }"
v-bind:disabled="fa2Form.processing" />
</BaseButtons>
<!-- <Link :href="stardust.route('app.register.show')"> Register </Link> -->
</CardBox>
</SectionFullScreen> </SectionFullScreen>
</LayoutGuest> </LayoutGuest>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useForm, Head } from '@inertiajs/vue3'; import { useForm, Head } from '@inertiajs/vue3';
import { Ref } from 'vue'; import { Ref, ref } from 'vue';
// import { Head, Link, useForm } from '@inertiajs/inertia-vue3'; // import { Head, Link, useForm } from '@inertiajs/inertia-vue3';
import { mdiAccount, mdiAsterisk } from '@mdi/js'; import { mdiAccount, mdiAsterisk, mdiTwoFactorAuthentication, mdiContentSaveCheck, mdiLock } from '@mdi/js';
import LayoutGuest from '@/Layouts/LayoutGuest.vue'; import LayoutGuest from '@/Layouts/LayoutGuest.vue';
import SectionFullScreen from '@/Components/SectionFullScreen.vue'; import SectionFullScreen from '@/Components/SectionFullScreen.vue';
import CardBox from '@/Components/CardBox.vue'; import CardBox from '@/Components/CardBox.vue';
@ -116,22 +108,55 @@ import FormCheckRadioGroup from '@/Components/FormCheckRadioGroup.vue';
import FormField from '@/Components/FormField.vue'; import FormField from '@/Components/FormField.vue';
import FormControl from '@/Components/FormControl.vue'; import FormControl from '@/Components/FormControl.vue';
import BaseDivider from '@/Components/BaseDivider.vue'; import BaseDivider from '@/Components/BaseDivider.vue';
// import BaseButton from '@/Components/BaseButton.vue'; import BaseButton from '@/Components/BaseButton.vue';
import BaseButtons from '@/Components/BaseButtons.vue'; import BaseButtons from '@/Components/BaseButtons.vue';
import FormValidationErrors from '@/Components/FormValidationErrors.vue'; import FormValidationErrors from '@/Components/FormValidationErrors.vue';
import NotificationBarInCard from '@/Components/NotificationBarInCard.vue'; import NotificationBarInCard from '@/Components/NotificationBarInCard.vue';
import BaseLevel from '@/Components/BaseLevel.vue';
import { stardust } from '@eidellev/adonis-stardust/client'; import { stardust } from '@eidellev/adonis-stardust/client';
// import NotificationBar from '@/Components/NotificationBar.vue'; // import NotificationBar from '@/Components/NotificationBar.vue';
import { computed } from 'vue'; import { computed, watch } from 'vue';
import { usePage } from '@inertiajs/vue3'; import { usePage } from '@inertiajs/vue3';
// import axios from 'axios';
// import { LoginState } from '@/Dataset';
// interface IErrorMessage { // interface IErrorMessage {
// [key: string]: Array<string>; // [key: string]: Array<string>;
// } // }
const flash: Ref<any> = computed(() => { const flash: Ref<any> = computed(() => {
return usePage().props.flash; let test = usePage().props;
return test.flash;
});
const user_id: Ref<any> = computed(() => {
let test = usePage().props;
return test.user_id;
});
const isTwoFactorAuthNeeded = ref(false);
// const user_id: ComputedRef<number> = computed(() => {
// return usePage().props.flash.user_id as number;
// });
// const isTwoFactorAuthNeeded = computed(() => {
// if (flash.new_user_id) {
// return true;
// } else {
// return false;
// }
// });
watch(user_id, () => {
if (user_id.value) {
isTwoFactorAuthNeeded.value = true;
} else {
isTwoFactorAuthNeeded.value = false;
}
}); });
defineProps({ defineProps({
@ -146,11 +171,57 @@ defineProps({
}, },
}); });
const form = useForm({ // const { version } = usePage();
const form = useForm(() => ({
email: '', email: '',
password: '', password: '',
remember: [], remember: [],
}); }));
// const login = async (data): Promise<any | null> => {
// try {
// let resp = await axios.post(stardust.route('login.store'), data, {
// headers: {
// 'X-Inertia': true,
// 'X-Inertia-Partial-Component': 'Users',
// 'X-Inertia-Partial-Data': 'users',
// 'X-Inertia-Version': version,
// }
// });
// // Check if the response contains a redirect status
// if (resp.data.component && resp.data.url) {
// // Use Inertia.js to visit the specified component
// router.visit(resp.data.url, resp.data.props);
// } else {
// // If it's not a redirect, reset the password field in the form
// form.reset('password');
// return resp.data;
// }
// } catch (error) {
// // Handle errors if any
// console.error('Error during login:', error);
// throw error;
// }
// };
// const submit = async () => {
// // Transform the formData object
// const formData = form.data();
// const transformedData = {
// ...formData,
// remember: formData.remember && formData.remember.length ? 'on' : '',
// };
// const resp = await login(transformedData);
// if (resp) {
// if (resp.state == LoginState.STATE_VALIDATED) {
// isTwoFactorAuthNeeded.value = true
// }
// fa2Form.login_id = resp.new_user_id;
// }
// };
const submit = async () => { const submit = async () => {
await form await form
@ -164,4 +235,26 @@ const submit = async () => {
}, },
}); });
}; };
const fa2Form = useForm(() => ({
code: '',
remember: [],
login_id: ''
}));
const submitFa2Form = async () => {
await fa2Form
.transform((data) => ({
...data,
remember: fa2Form.remember && fa2Form.remember.length ? 'on' : '',
login_id: user_id.value
}))
.post(stardust.route('login.twoFactorChallenge'), {
onFinish: () => {
fa2Form.reset('code');
fa2Form.reset('login_id');
// form.reset('password');
},
});
};
</script> </script>

View File

@ -16,6 +16,10 @@ Inertia.share({
return ctx.session.flashMessages.get('errors'); return ctx.session.flashMessages.get('errors');
}, },
user_id: (ctx) => {
return ctx.session.flashMessages.get('user_id');
},
flash: (ctx) => { flash: (ctx) => {
return { return {
message: ctx.session.flashMessages.get('message'), message: ctx.session.flashMessages.get('message'),

View File

@ -98,6 +98,8 @@ Route.get('/app/login', async ({ inertia }) => {
// Route.post("/login", "Users/AuthController.login"); // Route.post("/login", "Users/AuthController.login");
Route.post('/app/login', 'Auth/AuthController.login').as('login.store'); Route.post('/app/login', 'Auth/AuthController.login').as('login.store');
Route.post('/app/twoFactorChallenge', 'Auth/AuthController.twoFactorChallenge').as('login.twoFactorChallenge');
// Route.on("/signup").render("signup"); // Route.on("/signup").render("signup");
// Route.post("/signup", "AuthController.signup"); // Route.post("/signup", "AuthController.signup");
Route.post('/signout', 'Auth/AuthController.logout').as('logout'); Route.post('/signout', 'Auth/AuthController.logout').as('logout');