* 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:
Bernd Storath
2025-04-01 14:43:48 +02:00
committed by GitHub
parent 1c7f64ebd5
commit 32b73b850a
24 changed files with 804 additions and 438 deletions
+65
View 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',
});
}
);
+1
View File
@@ -20,5 +20,6 @@ export default defineEventHandler(async (event) => {
username: user.username,
name: user.name,
email: user.email,
totpVerified: user.totpVerified,
};
});
+29 -16
View File
@@ -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' };
});