- added backup codes for 2 factor authentication
Some checks failed
CI Pipeline / japa-tests (push) Failing after 58s

- npm updates
- coverage validation: elevation ust be positive, depth must be negative
- vinejs-provider.js: get enabled extensions from database, not via validOptions.extnames
- vue components for backup codes: e.g.: PersonalSettings.vue
- validate spaital coverage in leaflet map: draw.component.vue, map.component.vue
- add backup code authentication into Login.vue
- preset to use no preferred reviewer: Release.vue
- 2 new vinejs validation rules: file_scan.ts and file-length.ts
This commit is contained in:
Kaimbacher 2024-07-08 13:52:20 +02:00
parent ac473b1e72
commit 005df2e454
32 changed files with 1416 additions and 526 deletions

View file

@ -1,11 +1,11 @@
import type { HttpContext } from '@adonisjs/core/http';
// import TotpSecret from 'App/Models/TotpSecret';
import User from '#models/user';
import TwoFactorAuthProvider from '#app/services/TwoFactorAuthProvider';
import { StatusCodes } from 'http-status-codes';
import { InvalidArgumentException } from 'node-exceptions';
import { TotpState } from '#contracts/enums';
import BackupCodeStorage, { SecureRandom } from '#services/backup_code_storage';
import BackupCode from '#models/backup_code';
// Here we are generating secret and recovery codes for the user thats enabling 2FA and storing them to our database.
export default class UserController {
@ -28,15 +28,20 @@ export default class UserController {
case TotpState.STATE_DISABLED:
// user.twoFactorSecret = null;
// user.twoFactorRecoveryCodes = null;
user.twoFactorSecret = "";
user.twoFactorRecoveryCodes = [""];
await BackupCode.deleteCodes(user);
user.twoFactorSecret = '';
// user.twoFactorRecoveryCodes = [''];
await user.save();
user.state = TotpState.STATE_DISABLED;
await user.save();
let storage = new BackupCodeStorage(new SecureRandom());
let backupState = await storage.getBackupCodesState(user);
return response.status(StatusCodes.OK).json({
state: TotpState.STATE_DISABLED,
backupState: backupState,
});
case TotpState.STATE_CREATED:
user.twoFactorSecret = TwoFactorAuthProvider.generateSecret(user);
@ -56,8 +61,8 @@ export default class UserController {
if (!code) {
throw new InvalidArgumentException('code is missing');
}
const success = await TwoFactorAuthProvider.enable(user, code)
const success = await TwoFactorAuthProvider.enable(user, code);
return response.status(StatusCodes.OK).json({
state: success ? TotpState.STATE_ENABLED : TotpState.STATE_CREATED,
});
@ -79,4 +84,31 @@ export default class UserController {
// recoveryCodes: user.twoFactorRecoveryCodes,
// });
// }
/**
* @NoAdminRequired
* @PasswordConfirmationRequired
*
* @return JSONResponse
*/
public async createCodes({ auth, response }: HttpContext) {
// $user = $this->userSession->getUser();
const user = (await User.find(auth.user?.id)) as User;
// let codes = TwoFactorAuthProvider.generateRecoveryCodes();
let storage = new BackupCodeStorage(new SecureRandom());
// $codes = $this->storage->createCodes($user);
const codes = await storage.createCodes(user);
let backupState = await storage.getBackupCodesState(user);
// return new JSONResponse([
// 'codes' => $codes,
// 'state' => $this->storage->getBackupCodesState($user),
// ]);
return response.status(StatusCodes.OK).json({
codes: codes,
// state: success ? TotpState.STATE_ENABLED : TotpState.STATE_CREATED,
backupState: backupState, //storage.getBackupCodesState(user),
});
}
}

View file

@ -1,8 +1,10 @@
import type { HttpContext } from '@adonisjs/core/http';
import User from '#models/user';
import BackupCode from '#models/backup_code';
// import Hash from '@ioc:Adonis/Core/Hash';
// import InvalidCredentialException from 'App/Exceptions/InvalidCredentialException';
import { authValidator } from '#validators/auth';
import hash from '@adonisjs/core/services/hash';
import TwoFactorAuthProvider from '#app/services/TwoFactorAuthProvider';
// import { Authenticator } from '@adonisjs/auth';
@ -31,22 +33,22 @@ export default class AuthController {
// await auth.use('web').attempt(email, plainPassword);
// const user = await auth.use('web').verifyCredentials(email, password);
const user = await User.verifyCredentials(email, password)
const user = await User.verifyCredentials(email, password);
if (user.isTwoFactorEnabled) {
// session.put("login.id", user.id);
// return view.render("pages/two-factor-challenge");
session.flash('user_id', user.id);
return response.redirect().back();
// let state = LoginState.STATE_VALIDATED;
// return response.status(StatusCodes.OK).json({
// state: state,
// new_user_id: user.id,
// });
}
}
await auth.use('web').login(user);
} catch (error) {
// if login fails, return vague form message and redirect back
@ -59,10 +61,9 @@ export default class AuthController {
}
public async twoFactorChallenge({ request, session, auth, response }: HttpContext) {
const { code, recoveryCode, login_id } = request.only(['code', 'recoveryCode', 'login_id']);
// const user = await User.query().where('id', session.get('login.id')).firstOrFail();
const { code, backup_code, login_id } = request.only(['code', 'backup_code', 'login_id']);
const user = await User.query().where('id', login_id).firstOrFail();
if (code) {
const isValid = await TwoFactorAuthProvider.validate(user, code);
if (isValid) {
@ -70,17 +71,46 @@ export default class AuthController {
await auth.use('web').login(user);
response.redirect('/apps/dashboard');
} else {
session.flash('message', 'Your tow factor code is incorrect');
session.flash('message', 'Your two-factor code is incorrect');
return response.redirect().back();
}
} else if (backup_code) {
const codes: BackupCode[] = await user.getBackupCodes();
// const verifiedBackupCodes = await Promise.all(
// codes.map(async (backupCode) => {
// let isVerified = await hash.verify(backupCode.code, backup_code);
// if (isVerified) {
// return backupCode;
// }
// }),
// );
// const backupCodeToDelete = verifiedBackupCodes.find(Boolean);
let backupCodeToDelete = null;
for (const backupCode of codes) {
const isVerified = await hash.verify(backupCode.code, backup_code);
if (isVerified) {
backupCodeToDelete = backupCode;
break;
}
}
if (backupCodeToDelete) {
if (backupCodeToDelete.used === false) {
backupCodeToDelete.used = true;
await backupCodeToDelete.save();
console.log(`BackupCode with id ${backupCodeToDelete.id} has been marked as used.`);
await auth.use('web').login(user);
response.redirect('/apps/dashboard');
} else {
session.flash('message', 'BackupCode already used');
return response.redirect().back();
}
} else {
session.flash('message', 'BackupCode not found');
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.use('web').login(user);
response.redirect('/apps/dashboard');
}
}
}
}

View file

@ -5,6 +5,7 @@ import TwoFactorAuthProvider from '#app/services/TwoFactorAuthProvider';
import hash from '@adonisjs/core/services/hash';
// import { schema, rules } from '@adonisjs/validator';
import vine from '@vinejs/vine';
import BackupCodeStorage, { SecureRandom } from '#services/backup_code_storage';
// Here we are generating secret and recovery codes for the user thats enabling 2FA and storing them to our database.
export default class UserController {
@ -19,10 +20,15 @@ export default class UserController {
// const id = request.param('id');
// const user = await User.query().where('id', id).firstOrFail();
let storage = new BackupCodeStorage(new SecureRandom());
// const codes= user.isTwoFactorEnabled? (await user.getBackupCodes()).map((role) => role.code) : [];
let backupState = await storage.getBackupCodesState(user);
return inertia.render('Auth/AccountInfo', {
user: user,
twoFactorEnabled: user.isTwoFactorEnabled,
// code: await TwoFactorAuthProvider.generateQrCode(user),
backupState: backupState,
});
}

View file

@ -327,13 +327,13 @@ export default class DatasetController {
x_max: vine.number(),
y_min: vine.number(),
y_max: vine.number(),
elevation_absolut: vine.number().optional(),
elevation_min: vine.number().optional().requiredIfExists('elevation_max'),
elevation_max: vine.number().optional().requiredIfExists('elevation_min'),
elevation_absolut: vine.number().positive().optional(),
elevation_min: vine.number().positive().optional().requiredIfExists('elevation_max'),
elevation_max: vine.number().positive().optional().requiredIfExists('elevation_min'),
// type: vine.enum(Object.values(DescriptionTypes)),
depth_absolut: vine.number().optional(),
depth_min: vine.number().optional().requiredIfExists('depth_max'),
depth_max: vine.number().optional().requiredIfExists('depth_min'),
depth_absolut: vine.number().negative().optional(),
depth_min: vine.number().negative().optional().requiredIfExists('depth_max'),
depth_max: vine.number().negative().optional().requiredIfExists('depth_min'),
}),
references: vine
.array(

51
app/models/backup_code.ts Normal file
View file

@ -0,0 +1,51 @@
import BaseModel from './base_model.js';
import { column, SnakeCaseNamingStrategy, belongsTo } from '@adonisjs/lucid/orm';
import User from './user.js';
import type { BelongsTo } from '@adonisjs/lucid/types/relations';
import db from '@adonisjs/lucid/services/db';
import hash from '@adonisjs/core/services/hash';
export default class BackupCode extends BaseModel {
public static table = 'backupcodes';
public static namingStrategy = new SnakeCaseNamingStrategy();
@column({
isPrimary: true,
})
public id: number;
@column({})
public user_id: number;
@column({
// serializeAs: null,
// consume: (value: string) => (value ? JSON.parse(encryption.decrypt(value) ?? '{}') : null),
// prepare: (value: string) => encryption.encrypt(JSON.stringify(value)),
})
public code: string;
@column({})
public used: boolean;
@belongsTo(() => User, {
foreignKey: 'user_id',
})
public user: BelongsTo<typeof User>;
// public static async getBackupCodes(user: User): Promise<BackupCode[]> {
// return await db.from(this.table).select('id', 'user_id', 'code', 'used').where('user_id', user.id);
// }
public static async deleteCodes(user: User): Promise<void> {
await db.from(this.table).where('user_id', user.id).delete();
}
public static async deleteCodesByUserId(uid: string): Promise<void> {
await db.from(this.table).where('user_id', uid).delete();
}
// Method to verify password
public async verifyCode(plainCode: string) {
return await hash.verify(this.code, plainCode);
}
}

View file

@ -1,5 +1,5 @@
import { DateTime } from 'luxon';
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid';
import { column, manyToMany, hasMany } from '@adonisjs/lucid/orm';
import hash from '@adonisjs/core/services/hash';
import Role from './role.js';
@ -13,6 +13,7 @@ import { TotpState } from '#contracts/enums';
import type { ManyToMany } from '@adonisjs/lucid/types/relations';
import type { HasMany } from '@adonisjs/lucid/types/relations';
import { compose } from '@adonisjs/core/helpers';
import BackupCode from './backup_code.js';
const AuthFinder = withAuthFinder(() => hash.use('laravel'), {
uids: ['email'],
@ -107,6 +108,19 @@ export default class User extends compose(BaseModel, AuthFinder) {
})
public datasets: HasMany<typeof Dataset>;
@hasMany(() => BackupCode, {
foreignKey: 'user_id',
})
public backupcodes: HasMany<typeof BackupCode>;
public async getBackupCodes(this: User): Promise<BackupCode[]> {
const test = await this.related('backupcodes').query();
// return test.map((role) => role.code);
return test;
}
// https://github.com/adonisjs/core/discussions/1872#discussioncomment-132289
public async getRoles(this: User): Promise<string[]> {
const test = await this.related('roles').query();

View file

@ -1,5 +1,6 @@
// import Config from '@ioc:Adonis/Core/Config';
import config from '@adonisjs/core/services/config'
// import config from '@adonisjs/core/services/config'
import env from '#start/env';
import User from '#models/user';
import { generateSecret, verifyToken } from 'node-2fa/dist/index.js';
// import cryptoRandomString from 'crypto-random-string';
@ -14,7 +15,7 @@ import { TotpState } from '#contracts/enums';
// npm i --save-dev @types/qrcode
class TwoFactorAuthProvider {
private issuer: string = config.get('twoFactorAuthConfig.app.name') || 'TethysCloud';
private issuer: string = env.get('APP_NAME') || 'TethysCloud';
/**
* generateSecret will generate a user-specific 32-character secret.
@ -41,7 +42,7 @@ class TwoFactorAuthProvider {
* Return recovery codes
* @return {string[]}
*/
public generateRecoveryCodes() {
public generateRecoveryCodes(): string[] {
const recoveryCodeLimit: number = 8;
const codes: string[] = [];
for (let i = 0; i < recoveryCodeLimit; i++) {

View file

@ -0,0 +1,136 @@
import User from '#models/user';
import BackupCode from '#models/backup_code';
import hash from '@adonisjs/core/services/hash';
export interface ISecureRandom {
CHAR_UPPER: string;
CHAR_LOWER: string;
CHAR_DIGITS: string;
CHAR_SYMBOLS: string;
CHAR_ALPHANUMERIC: string;
CHAR_HUMAN_READABLE: string;
/**
* Generate a random string of specified length.
* @param int $length The length of the generated string
* @param string $characters An optional list of characters to use if no character list is
* specified all valid base64 characters are used.
* @return string
* @since 8.0.0
*/
generate(length: number, characters?: string): string;
}
export class SecureRandom implements ISecureRandom {
CHAR_UPPER: string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
CHAR_LOWER: string = 'abcdefghijklmnopqrstuvwxyz';
CHAR_DIGITS: string = '0123456789';
CHAR_SYMBOLS: string = '!"#$%&\\\'()*+,-./:;<=>?@[]^_`{|}~';
CHAR_ALPHANUMERIC: string = this.CHAR_UPPER + this.CHAR_LOWER + this.CHAR_DIGITS;
CHAR_HUMAN_READABLE: string = 'abcdefgijkmnopqrstwxyzABCDEFGHJKLMNPQRSTWXYZ23456789';
public generate(length: number, characters: string = this.CHAR_ALPHANUMERIC): string {
if (length <= 0) {
throw new Error('Invalid length specified: ' + length + ' must be bigger than 0');
}
const maxCharIndex: number = characters.length - 1;
let randomString: string = '';
while (length > 0) {
const randomNumber: number = Math.floor(Math.random() * (maxCharIndex + 1));
randomString += characters[randomNumber];
length--;
}
return randomString;
}
}
class BackupCodeStorage {
private static CODE_LENGTH: number = 16;
// private mapper: BackupCodeMapper;
// private hasher: IHasher;
private random: ISecureRandom;
// private eventDispatcher: IEventDispatcher;
// constructor(mapper: BackupCodeMapper, random: ISecureRandom, hasher: IHasher, eventDispatcher: IEventDispatcher) {
// this.mapper = mapper;
// this.hasher = hasher;
// this.random = random;
// this.eventDispatcher = eventDispatcher;
// }
constructor(random: ISecureRandom) {
// this.mapper = mapper;
// this.hasher = hasher;
this.random = random;
// this.eventDispatcher = eventDispatcher;
}
public async createCodes(user: User, number: number = 10): Promise<string[]> {
let results: string[] = [];
// this.mapper.deleteCodes(user);
await BackupCode.deleteCodes(user);
// user.twoFactorRecoveryCodes = [""];
// const uid = user.getUID();
for (let i = 1; i <= Math.min(number, 20); i++) {
const code = this.random.generate(BackupCodeStorage.CODE_LENGTH, this.random.CHAR_HUMAN_READABLE);
// const code = crypto
// .randomBytes(Math.ceil(BackupCodeStorage.CODE_LENGTH / 2))
// .toString('hex')
// .slice(0, BackupCodeStorage.CODE_LENGTH);
const dbCode = new BackupCode();
// dbCode.setUserId(uid);
// dbCode.setCode(this.hasher.hash(code));
dbCode.code = await hash.make(code);
// dbCode.setUsed(0);
dbCode.used = false;
// this.mapper.insert(dbCode);
// await dbCode.save();
await dbCode.related('user').associate(user); // speichert schon ab
results.push(code);
}
// this.eventDispatcher.dispatchTyped(new CodesGenerated(user));
return results;
}
public async hasBackupCodes(user: User): Promise<boolean> {
const codes = await user.getBackupCodes();
return codes.length > 0;
}
public async getBackupCodesState(user: User) {
// const codes = this.mapper.getBackupCodes(user);
// const codes = await user.related('backupcodes').query().exec();
const codes: BackupCode[] = await user.getBackupCodes();
const total = codes.length;
let used: number = 0;
codes.forEach((code) => {
if (code.used === true) {
used++;
}
});
return {
enabled: total > 0,
total: total,
used: used,
};
}
// public validateCode(user: User, code: string): boolean {
// const dbCodes = await user.getBackupCodes();
// for (const dbCode of dbCodes) {
// if (parseInt(dbCode.getUsed()) === 0 && this.hasher.verify(code, dbCode.getCode())) {
// dbCode.setUsed(1);
// this.mapper.update(dbCode);
// return true;
// }
// }
// return false;
// }
// public deleteCodes(user: User): void {
// this.mapper.deleteCodes(user);
// }
}
export default BackupCodeStorage;

View file

@ -4,9 +4,11 @@ import dayjs from 'dayjs';
import MimeType from '#models/mime_type';
const enabledExtensions = await MimeType.query().select('file_extension').where('enabled', true).exec();
const extensions = enabledExtensions.map((extension)=> {
return extension.file_extension.split('|')
}).flat();
const extensions = enabledExtensions
.map((extension) => {
return extension.file_extension.split('|');
})
.flat();
/**
* Validates the dataset's creation action
@ -55,7 +57,13 @@ export const createDatasetValidator = vine.compile(
authors: vine
.array(
vine.object({
email: vine.string().trim().maxLength(255).email().normalizeEmail().isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
email: vine
.string()
.trim()
.maxLength(255)
.email()
.normalizeEmail()
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
first_name: vine.string().trim().minLength(3).maxLength(255),
last_name: vine.string().trim().minLength(3).maxLength(255),
}),
@ -65,14 +73,20 @@ export const createDatasetValidator = vine.compile(
contributors: vine
.array(
vine.object({
email: vine.string().trim().maxLength(255).email().normalizeEmail().isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
email: vine
.string()
.trim()
.maxLength(255)
.email()
.normalizeEmail()
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
first_name: vine.string().trim().minLength(3).maxLength(255),
last_name: vine.string().trim().minLength(3).maxLength(255),
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
}),
)
.distinct('email')
.optional(),
.optional(),
// third step
project_id: vine.number().optional(),
// embargo_date: schema.date.optional({ format: 'yyyy-MM-dd' }, [rules.after(10, 'days')]),
@ -89,13 +103,13 @@ export const createDatasetValidator = vine.compile(
x_max: vine.number(),
y_min: vine.number(),
y_max: vine.number(),
elevation_absolut: vine.number().optional(),
elevation_min: vine.number().optional().requiredIfExists('elevation_max'),
elevation_max: vine.number().optional().requiredIfExists('elevation_min'),
elevation_absolut: vine.number().positive().optional(),
elevation_min: vine.number().positive().optional().requiredIfExists('elevation_max'),
elevation_max: vine.number().positive().optional().requiredIfExists('elevation_min'),
// type: vine.enum(Object.values(DescriptionTypes)),
depth_absolut: vine.number().optional(),
depth_min: vine.number().optional().requiredIfExists('depth_max'),
depth_max: vine.number().optional().requiredIfExists('depth_min'),
depth_absolut: vine.number().negative().optional(),
depth_min: vine.number().negative().optional().requiredIfExists('depth_max'),
depth_max: vine.number().negative().optional().requiredIfExists('depth_min'),
}),
references: vine
.array(
@ -120,10 +134,13 @@ export const createDatasetValidator = vine.compile(
// last step
files: vine
.array(
vine.myfile({
size: '512mb',
extnames: extensions,
}).filenameLength({ clientNameSizeLimit : 100 }),
vine
.myfile({
size: '512mb',
extnames: extensions,
})
.filenameLength({ clientNameSizeLimit: 100 })
.fileScan({ removeInfected: true }),
)
.minLength(1),
}),
@ -175,7 +192,13 @@ export const updateDatasetValidator = vine.compile(
authors: vine
.array(
vine.object({
email: vine.string().trim().maxLength(255).email().normalizeEmail().isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
email: vine
.string()
.trim()
.maxLength(255)
.email()
.normalizeEmail()
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
first_name: vine.string().trim().minLength(3).maxLength(255),
last_name: vine.string().trim().minLength(3).maxLength(255),
}),
@ -185,7 +208,13 @@ export const updateDatasetValidator = vine.compile(
contributors: vine
.array(
vine.object({
email: vine.string().trim().maxLength(255).email().normalizeEmail().isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
email: vine
.string()
.trim()
.maxLength(255)
.email()
.normalizeEmail()
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
first_name: vine.string().trim().minLength(3).maxLength(255),
last_name: vine.string().trim().minLength(3).maxLength(255),
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
@ -209,13 +238,13 @@ export const updateDatasetValidator = vine.compile(
x_max: vine.number(),
y_min: vine.number(),
y_max: vine.number(),
elevation_absolut: vine.number().optional(),
elevation_min: vine.number().optional().requiredIfExists('elevation_max'),
elevation_max: vine.number().optional().requiredIfExists('elevation_min'),
elevation_absolut: vine.number().positive().optional(),
elevation_min: vine.number().positive().optional().requiredIfExists('elevation_max'),
elevation_max: vine.number().positive().optional().requiredIfExists('elevation_min'),
// type: vine.enum(Object.values(DescriptionTypes)),
depth_absolut: vine.number().optional(),
depth_min: vine.number().optional().requiredIfExists('depth_max'),
depth_max: vine.number().optional().requiredIfExists('depth_min'),
depth_absolut: vine.number().negative().optional(),
depth_min: vine.number().negative().optional().requiredIfExists('depth_max'),
depth_max: vine.number().negative().optional().requiredIfExists('depth_min'),
}),
references: vine
.array(
@ -238,14 +267,13 @@ export const updateDatasetValidator = vine.compile(
.minLength(3)
.distinct('value'),
// last step
files: vine
.array(
vine.myfile({
size: '512mb',
extnames: extensions,
}),
),
// .minLength(1),
files: vine.array(
vine.myfile({
size: '512mb',
extnames: extensions,
}),
),
// .minLength(1),
}),
);