- added api UserController.ts for 2FA
Some checks failed
CI Pipeline / japa-tests (push) Failing after 56s
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:
parent
18635f77b3
commit
ebc62d9117
18 changed files with 1151 additions and 315 deletions
82
app/Controllers/Http/Api/UserController.ts
Normal file
82
app/Controllers/Http/Api/UserController.ts
Normal 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 that’s 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,
|
||||
// });
|
||||
// }
|
||||
}
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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
63
app/Models/TotpSecret.ts
Normal 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>;
|
||||
|
||||
}
|
|
@ -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, {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
editor.link_modal.header
Reference in a new issue