Feat: 2fa (#1783)
* preplan otp, better qrcode library * add 2fa as feature * add totp generation * working totp lifecycle * don't allow disabled user to log in not a security issue as permission handler would fail anyway * require 2fa on login if enabled * update packages * fix typo * remove console.logs
This commit is contained in:
65
src/server/api/me/totp.post.ts
Normal file
65
src/server/api/me/totp.post.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Secret, TOTP } from 'otpauth';
|
||||
import { UserUpdateTotpSchema } from '#db/repositories/user/types';
|
||||
|
||||
type Response =
|
||||
| {
|
||||
success: boolean;
|
||||
type: 'setup';
|
||||
key: string;
|
||||
uri: string;
|
||||
}
|
||||
| { success: boolean; type: 'created' }
|
||||
| { success: boolean; type: 'deleted' };
|
||||
|
||||
export default definePermissionEventHandler(
|
||||
'me',
|
||||
'update',
|
||||
async ({ event, user, checkPermissions }) => {
|
||||
const body = await readValidatedBody(
|
||||
event,
|
||||
validateZod(UserUpdateTotpSchema, event)
|
||||
);
|
||||
|
||||
checkPermissions(user);
|
||||
|
||||
if (body.type === 'setup') {
|
||||
const key = new Secret({ size: 20 });
|
||||
|
||||
const totp = new TOTP({
|
||||
issuer: 'wg-easy',
|
||||
label: user.username,
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: key,
|
||||
});
|
||||
|
||||
await Database.users.updateTotpKey(user.id, key.base32);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
type: 'setup',
|
||||
key: key.base32,
|
||||
uri: totp.toString(),
|
||||
} as Response;
|
||||
} else if (body.type === 'create') {
|
||||
await Database.users.verifyTotp(user.id, body.code);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
type: 'created',
|
||||
} as Response;
|
||||
} else if (body.type === 'delete') {
|
||||
await Database.users.deleteTotpKey(user.id, body.currentPassword);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
type: 'deleted',
|
||||
} as Response;
|
||||
}
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid request',
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -20,5 +20,6 @@ export default defineEventHandler(async (event) => {
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
totpVerified: user.totpVerified,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,29 +1,42 @@
|
||||
import { UserLoginSchema } from '#db/repositories/user/types';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { username, password, remember } = await readValidatedBody(
|
||||
const { username, password, remember, totpCode } = await readValidatedBody(
|
||||
event,
|
||||
validateZod(UserLoginSchema, event)
|
||||
);
|
||||
|
||||
// TODO: timing can be used to enumerate usernames
|
||||
const result = await Database.users.login(username, password, totpCode);
|
||||
|
||||
const user = await Database.users.getByUsername(username);
|
||||
if (!user)
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Incorrect credentials',
|
||||
});
|
||||
// TODO: add localization support
|
||||
|
||||
const userHashPassword = user.password;
|
||||
const passwordValid = await isPasswordValid(password, userHashPassword);
|
||||
if (!passwordValid) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Incorrect credentials',
|
||||
});
|
||||
if (!result.success) {
|
||||
switch (result.error) {
|
||||
case 'INCORRECT_CREDENTIALS':
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Invalid username or password',
|
||||
});
|
||||
case 'TOTP_REQUIRED':
|
||||
return { status: 'TOTP_REQUIRED' };
|
||||
case 'INVALID_TOTP_CODE':
|
||||
return { status: 'INVALID_TOTP_CODE' };
|
||||
case 'USER_DISABLED':
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'User disabled',
|
||||
});
|
||||
case 'UNEXPECTED_ERROR':
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Unexpected error',
|
||||
});
|
||||
}
|
||||
assertUnreachable(result.error);
|
||||
}
|
||||
|
||||
const user = result.user;
|
||||
|
||||
const session = await useWGSession(event, remember);
|
||||
|
||||
const data = await session.update({
|
||||
@@ -34,5 +47,5 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
SERVER_DEBUG(`New Session: ${data.id} for ${user.id} (${user.username})`);
|
||||
|
||||
return { success: true };
|
||||
return { status: 'success' };
|
||||
});
|
||||
|
||||
@@ -80,6 +80,8 @@ CREATE TABLE `users_table` (
|
||||
`email` text,
|
||||
`name` text NOT NULL,
|
||||
`role` integer NOT NULL,
|
||||
`totp_key` text,
|
||||
`totp_verified` integer NOT NULL,
|
||||
`enabled` integer NOT NULL,
|
||||
`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
|
||||
`updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "8c2af02b-c4bd-4880-a9ad-b38805636208",
|
||||
"id": "91f8ccee-7842-4fd3-bb84-f43e00466b20",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"clients_table": {
|
||||
@@ -558,6 +558,20 @@
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"totp_key": {
|
||||
"name": "totp_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"totp_verified": {
|
||||
"name": "totp_verified",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "a61263b1-9af1-4d2e-99e9-80d08127b545",
|
||||
"prevId": "8c2af02b-c4bd-4880-a9ad-b38805636208",
|
||||
"id": "0224c6a5-3456-402d-a40d-0821637015da",
|
||||
"prevId": "91f8ccee-7842-4fd3-bb84-f43e00466b20",
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"tables": {
|
||||
@@ -558,6 +558,20 @@
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"totp_key": {
|
||||
"name": "totp_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"totp_verified": {
|
||||
"name": "totp_verified",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1741355094140,
|
||||
"when": 1743490907551,
|
||||
"tag": "0000_short_skin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1741355098159,
|
||||
"when": 1743490912488,
|
||||
"tag": "0001_classy_the_stranger",
|
||||
"breakpoints": true
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ export const user = sqliteTable('users_table', {
|
||||
email: text(),
|
||||
name: text().notNull(),
|
||||
role: int().$type<Role>().notNull(),
|
||||
totpKey: text('totp_key'),
|
||||
totpVerified: int('totp_verified', { mode: 'boolean' }).notNull(),
|
||||
enabled: int({ mode: 'boolean' }).notNull(),
|
||||
createdAt: text('created_at')
|
||||
.notNull()
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { TOTP } from 'otpauth';
|
||||
import { user } from './schema';
|
||||
import type { UserType } from './types';
|
||||
import type { DBType } from '#db/sqlite';
|
||||
|
||||
type LoginResult =
|
||||
| {
|
||||
success: true;
|
||||
user: UserType;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
error:
|
||||
| 'INCORRECT_CREDENTIALS'
|
||||
| 'TOTP_REQUIRED'
|
||||
| 'USER_DISABLED'
|
||||
| 'INVALID_TOTP_CODE'
|
||||
| 'UNEXPECTED_ERROR';
|
||||
};
|
||||
|
||||
function createPreparedStatement(db: DBType) {
|
||||
return {
|
||||
findAll: db.query.user.findMany().prepare(),
|
||||
@@ -21,6 +38,14 @@ function createPreparedStatement(db: DBType) {
|
||||
})
|
||||
.where(eq(user.id, sql.placeholder('id')))
|
||||
.prepare(),
|
||||
updateKey: db
|
||||
.update(user)
|
||||
.set({
|
||||
totpKey: sql.placeholder('key') as never as string,
|
||||
totpVerified: false,
|
||||
})
|
||||
.where(eq(user.id, sql.placeholder('id')))
|
||||
.prepare(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,6 +92,7 @@ export class UserService {
|
||||
email: null,
|
||||
name: 'Administrator',
|
||||
role: userCount === 0 ? roles.ADMIN : roles.CLIENT,
|
||||
totpVerified: false,
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
@@ -105,4 +131,121 @@ export class UserService {
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
updateTotpKey(id: ID, key: string | null) {
|
||||
return this.#statements.updateKey.execute({ id, key });
|
||||
}
|
||||
|
||||
login(username: string, password: string, code: string | undefined) {
|
||||
return this.#db.transaction(async (tx): Promise<LoginResult> => {
|
||||
const txUser = await tx.query.user
|
||||
.findFirst({ where: eq(user.username, username) })
|
||||
.execute();
|
||||
|
||||
if (!txUser) {
|
||||
return { success: false, error: 'INCORRECT_CREDENTIALS' };
|
||||
}
|
||||
|
||||
const passwordValid = await isPasswordValid(password, txUser.password);
|
||||
|
||||
if (!passwordValid) {
|
||||
return { success: false, error: 'INCORRECT_CREDENTIALS' };
|
||||
}
|
||||
|
||||
if (txUser.totpVerified) {
|
||||
if (!code) {
|
||||
return { success: false, error: 'TOTP_REQUIRED' };
|
||||
} else {
|
||||
if (!txUser.totpKey) {
|
||||
return { success: false, error: 'UNEXPECTED_ERROR' };
|
||||
}
|
||||
|
||||
const totp = new TOTP({
|
||||
issuer: 'wg-easy',
|
||||
label: txUser.username,
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: txUser.totpKey,
|
||||
});
|
||||
|
||||
const valid = totp.validate({ token: code, window: 1 });
|
||||
|
||||
if (valid === null) {
|
||||
return { success: false, error: 'INVALID_TOTP_CODE' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!txUser.enabled) {
|
||||
return { success: false, error: 'USER_DISABLED' };
|
||||
}
|
||||
|
||||
return { success: true, user: txUser };
|
||||
});
|
||||
}
|
||||
|
||||
verifyTotp(id: ID, code: string) {
|
||||
return this.#db.transaction(async (tx) => {
|
||||
const txUser = await tx.query.user
|
||||
.findFirst({ where: eq(user.id, id) })
|
||||
.execute();
|
||||
|
||||
if (!txUser) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
if (!txUser.totpKey) {
|
||||
throw new Error('TOTP key is not set');
|
||||
}
|
||||
|
||||
const totp = new TOTP({
|
||||
issuer: 'wg-easy',
|
||||
label: txUser.username,
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: txUser.totpKey,
|
||||
});
|
||||
|
||||
const valid = totp.validate({ token: code, window: 1 });
|
||||
|
||||
if (valid === null) {
|
||||
throw new Error('Invalid TOTP code');
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(user)
|
||||
.set({ totpVerified: true })
|
||||
.where(eq(user.id, id))
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
deleteTotpKey(id: ID, currentPassword: string) {
|
||||
return this.#db.transaction(async (tx) => {
|
||||
const txUser = await tx.query.user
|
||||
.findFirst({ where: eq(user.id, id) })
|
||||
.execute();
|
||||
|
||||
if (!txUser) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const passwordValid = await isPasswordValid(
|
||||
currentPassword,
|
||||
txUser.password
|
||||
);
|
||||
|
||||
if (!passwordValid) {
|
||||
throw new Error('Invalid password');
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(user)
|
||||
.set({ totpKey: null, totpVerified: false })
|
||||
.where(eq(user.id, id))
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,16 @@ const password = z
|
||||
|
||||
const remember = z.boolean({ message: t('zod.user.remember') });
|
||||
|
||||
const totpCode = z
|
||||
.string({ message: t('zod.user.totpCode') })
|
||||
.min(6, t('zod.user.totpCode'))
|
||||
.pipe(safeStringRefine);
|
||||
|
||||
export const UserLoginSchema = z.object({
|
||||
username: username,
|
||||
password: password,
|
||||
remember: remember,
|
||||
totpCode: totpCode.optional(),
|
||||
});
|
||||
|
||||
export const UserSetupSchema = z
|
||||
@@ -58,3 +64,17 @@ export const UserUpdatePasswordSchema = z
|
||||
.refine((val) => val.newPassword === val.confirmPassword, {
|
||||
message: t('zod.user.passwordMatch'),
|
||||
});
|
||||
|
||||
export const UserUpdateTotpSchema = z.union([
|
||||
z.object({
|
||||
type: z.literal('setup'),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('create'),
|
||||
code: totpCode,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('delete'),
|
||||
currentPassword: password,
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import debug from 'debug';
|
||||
import QRCode from 'qrcode';
|
||||
import { encodeQR } from 'qr';
|
||||
import type { InterfaceType } from '#db/repositories/interface/types';
|
||||
|
||||
const WG_DEBUG = debug('WireGuard');
|
||||
@@ -128,9 +128,10 @@ class WireGuard {
|
||||
|
||||
async getClientQRCodeSVG({ clientId }: { clientId: ID }) {
|
||||
const config = await this.getClientConfiguration({ clientId });
|
||||
return QRCode.toString(config, {
|
||||
type: 'svg',
|
||||
width: 512,
|
||||
return encodeQR(config, 'svg', {
|
||||
ecc: 'high',
|
||||
scale: 4,
|
||||
encoding: 'byte',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -147,3 +147,10 @@ export function validateZod<T>(
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* exhaustive check
|
||||
*/
|
||||
export function assertUnreachable(_: never): never {
|
||||
throw new Error("Didn't expect to get here");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user