- added api UserController.ts for 2FA
Some checks failed
CI Pipeline / japa-tests (push) Failing after 56s

- added PersonalTotpSettings.vue vor enablin/disabling 2FA
- changed User.ts: added attributes: state, twoFactorSecret and twoFactorRecoveryCodes
- added resources/js/utils/toast.ts for notifications
- modified start/routes/api.ts
- npm updates
This commit is contained in:
Kaimbacher 2024-01-19 15:33:46 +01:00
parent 18635f77b3
commit ebc62d9117
18 changed files with 1151 additions and 315 deletions

View file

@ -0,0 +1,82 @@
import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
// import TotpSecret from 'App/Models/TotpSecret';
import User from 'App/Models/User';
import TwoFactorAuthProvider from 'App/Services/TwoFactorAuthProvider';
import { StatusCodes } from 'http-status-codes';
import { InvalidArgumentException } from 'node-exceptions';
import { TotpState } from 'Contracts/enums';
// Here we are generating secret and recovery codes for the user thats enabling 2FA and storing them to our database.
export default class UserController {
public async enable({ auth, response, request }: HttpContextContract) {
const user = (await User.find(auth.user?.id)) as User;
// await user.load('totp_secret');
// if (!user.totp_secret) {
// let totpSecret = new TotpSecret();
// user.related('totp_secret').save(totpSecret);
// await user.load('totp_secret');
// }
if (!user) {
throw new Error('user not available');
}
const state: number = request.input('state');
try {
switch (state) {
case TotpState.STATE_DISABLED:
// user.twoFactorSecret = null;
// user.twoFactorRecoveryCodes = null;
user.twoFactorSecret = "";
user.twoFactorRecoveryCodes = [""];
await user.save();
user.state = TotpState.STATE_DISABLED;
await user.save();
return response.status(StatusCodes.OK).json({
state: TotpState.STATE_DISABLED,
});
case TotpState.STATE_CREATED:
user.twoFactorSecret = TwoFactorAuthProvider.generateSecret(user);
user.state = TotpState.STATE_CREATED;
await user.save();
let qrcode = await TwoFactorAuthProvider.generateQrCode(user);
// throw new InvalidArgumentException('code is missing');
return response.status(StatusCodes.OK).json({
state: user.state,
secret: user.twoFactorSecret,
url: qrcode.url,
svg: qrcode.svg,
});
case TotpState.STATE_ENABLED:
let code: string = request.input('code');
if (!code) {
throw new InvalidArgumentException('code is missing');
}
const success = await TwoFactorAuthProvider.enable(user, code)
return response.status(StatusCodes.OK).json({
state: success ? TotpState.STATE_ENABLED : TotpState.STATE_CREATED,
});
default:
throw new InvalidArgumentException('Invalid TOTP state');
}
} catch (error) {
return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({
message: 'Invalid TOTP state',
});
}
}
// public async fetchRecoveryCodes({ auth, view }) {
// const user = auth?.user;
// return view.render('pages/settings', {
// twoFactorEnabled: user.isTwoFactorEnabled,
// recoveryCodes: user.twoFactorRecoveryCodes,
// });
// }
}

View file

@ -22,7 +22,7 @@ export default class UserController {
return inertia.render('Auth/AccountInfo', {
user: user,
twoFactorEnabled: user.isTwoFactorEnabled,
code: await TwoFactorAuthProvider.generateQrCode(user),
// code: await TwoFactorAuthProvider.generateQrCode(user),
});
}

View file

@ -5,7 +5,7 @@ import type { ModelQueryBuilderContract } from '@ioc:Adonis/Lucid/Orm';
import Field from 'App/Library/Field';
import BaseModel from 'App/Models/BaseModel';
import { DateTime } from 'luxon';
import { schema, CustomMessages, rules } from '@ioc:Adonis/Core/Validator';
import { schema, rules } from '@ioc:Adonis/Core/Validator';
export default class DatasetsController {
public async index({ auth, request, inertia }: HttpContextContract) {

63
app/Models/TotpSecret.ts Normal file
View file

@ -0,0 +1,63 @@
import { column, BaseModel, SnakeCaseNamingStrategy, belongsTo, BelongsTo } from '@ioc:Adonis/Lucid/Orm';
import User from './User';
import { DateTime } from 'luxon';
import dayjs from 'dayjs';
import Encryption from '@ioc:Adonis/Core/Encryption';
export default class TotpSecret extends BaseModel {
public static namingStrategy = new SnakeCaseNamingStrategy();
public static table = 'totp_secrets';
// public static fillable: string[] = ['value', 'label', 'type', 'relation'];
@column({
isPrimary: true,
})
public id: number;
@column({})
public user_id: number;
// @column()
// public twoFactorSecret: string;
@column({
serializeAs: null,
consume: (value: string) => (value ? JSON.parse(Encryption.decrypt(value) ?? '{}') : null),
prepare: (value: string) => Encryption.encrypt(JSON.stringify(value)),
})
public twoFactorSecret?: string | null;
// serializeAs: null removes the model properties from the serialized output.
@column({
serializeAs: null,
consume: (value: string) => (value ? JSON.parse(Encryption.decrypt(value) ?? '[]') : []),
prepare: (value: string[]) => Encryption.encrypt(JSON.stringify(value)),
})
public twoFactorRecoveryCodes?: string[] | null;
@column({})
public state: number;
@column.dateTime({
serialize: (value: Date | null) => {
// return value ? moment(value).format('MMMM Do YYYY, HH:mm:ss') : value;
return value ? dayjs(value).format('MMMM D YYYY HH:mm a') : value;
},
autoCreate: true,
})
public created_at: DateTime;
@column.dateTime({
serialize: (value: Date | null) => {
return value ? dayjs(value).format('MMMM D YYYY HH:mm a') : value;
},
autoCreate: true,
autoUpdate: true,
})
public updated_at: DateTime;
@belongsTo(() => User, {
foreignKey: 'user_id',
})
public user: BelongsTo<typeof User>;
}

View file

@ -7,6 +7,8 @@ import Config from '@ioc:Adonis/Core/Config';
import Dataset from './Dataset';
import BaseModel from './BaseModel';
import Encryption from '@ioc:Adonis/Core/Encryption';
import { TotpState } from 'Contracts/enums';
// import TotpSecret from './TotpSecret';
// export default interface IUser {
// id: number;
@ -51,7 +53,7 @@ export default class User extends BaseModel {
consume: (value: string) => (value ? JSON.parse(Encryption.decrypt(value) ?? '{}') : null),
prepare: (value: string) => Encryption.encrypt(JSON.stringify(value)),
})
public twoFactorSecret?: string;
public twoFactorSecret?: string | null;
// serializeAs: null removes the model properties from the serialized output.
@column({
@ -59,7 +61,15 @@ export default class User extends BaseModel {
consume: (value: string) => (value ? JSON.parse(Encryption.decrypt(value) ?? '[]') : []),
prepare: (value: string[]) => Encryption.encrypt(JSON.stringify(value)),
})
public twoFactorRecoveryCodes?: string[];
public twoFactorRecoveryCodes?: string[] | null;
@column({})
public state: number;
// @hasOne(() => TotpSecret, {
// foreignKey: 'user_id',
// })
// public totp_secret: HasOne<typeof TotpSecret>;
@beforeSave()
public static async hashPassword(user) {
@ -68,8 +78,9 @@ export default class User extends BaseModel {
}
}
public get isTwoFactorEnabled() {
return Boolean(this?.twoFactorSecret);
public get isTwoFactorEnabled(): boolean {
return Boolean(this?.twoFactorSecret && this.state == TotpState.STATE_ENABLED);
// return Boolean(this.totp_secret?.twoFactorSecret);
}
@manyToMany(() => Role, {

View file

@ -1,9 +1,10 @@
import Config from '@ioc:Adonis/Core/Config';
import User from 'App/Models/User';
import { generateSecret } from 'node-2fa/dist/index';
import { generateSecret, verifyToken } from 'node-2fa/dist/index';
// import cryptoRandomString from 'crypto-random-string';
import QRCode from 'qrcode';
import crypto from 'crypto';
import { TotpState } from 'Contracts/enums';
// npm install node-2fa --save
// npm install crypto-random-string --save
@ -57,7 +58,7 @@ class TwoFactorAuthProvider {
private generateRandomString(length: number, type: 'hex' | 'base64' | 'numeric' = 'hex'): string {
const byteLength = Math.ceil(length * 0.5); // For hex encoding, each byte generates 2 characters
const randomBytes = crypto.randomBytes(byteLength);
switch (type) {
case 'hex':
return randomBytes.toString('hex').slice(0, length);
@ -73,16 +74,44 @@ class TwoFactorAuthProvider {
}
}
public async generateQrCode(user: User) : Promise<{svg: string; url: string; }> {
// public async generateQrCode(user: User) : Promise<{svg: string; url: string; secret: string; }> {
// const issuer = encodeURIComponent(this.issuer); // 'TethysCloud'
// // const userName = encodeURIComponent(user.email); // 'rrqx9472%40tethys.at'
// const label = `${this.issuer}:${user.email}`;
// const algorithm = encodeURIComponent("SHA256");
// const query = `?secret=${user.twoFactorSecret}&issuer=${issuer}&algorithm=${algorithm}&digits=6`; // '?secret=FEYCLOSO627CB7SMLX6QQ7BP75L7SJ54&issuer=TethysCloud'
// const url = `otpauth://totp/${label}${query}`; // 'otpauth://totp/rrqx9472%40tethys.at?secret=FEYCLOSO627CB7SMLX6QQ7BP75L7SJ54&issuer=TethysCloud'
// const svg = await QRCode.toDataURL(url);
// const secret = user.twoFactorSecret as string;
// return { svg, url, secret };
// }
public async generateQrCode(user: User, twoFactorSecret?: string): Promise<{ svg: string; url: string; secret: string }> {
const issuer = encodeURIComponent(this.issuer); // 'TethysCloud'
// const userName = encodeURIComponent(user.email); // 'rrqx9472%40tethys.at'
const label = `${this.issuer}:${user.email}`;
const algorithm = encodeURIComponent("SHA256");
const query = `?secret=${user.twoFactorSecret}&issuer=${issuer}&algorithm=${algorithm}&digits=6`; // '?secret=FEYCLOSO627CB7SMLX6QQ7BP75L7SJ54&issuer=TethysCloud'
// const algorithm = encodeURIComponent('SHA256');
const secret = twoFactorSecret ? twoFactorSecret : (user.twoFactorSecret as string);
const query = `?secret=${secret}&issuer=${issuer}&digits=6`; // '?secret=FEYCLOSO627CB7SMLX6QQ7BP75L7SJ54&issuer=TethysCloud'
const url = `otpauth://totp/${label}${query}`; // 'otpauth://totp/rrqx9472%40tethys.at?secret=FEYCLOSO627CB7SMLX6QQ7BP75L7SJ54&issuer=TethysCloud'
const svg = await QRCode.toDataURL(url);
return { svg, url };
return { svg, url, secret };
}
public async enable(user: User, token: string): Promise<boolean> {
const isValid = verifyToken(user.twoFactorSecret as string, token, 1);
if (!isValid) {
return false;
}
user.state = TotpState.STATE_ENABLED;
if (await user.save()) {
return true;
}
return false;
}
}