* 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

View File

@@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
We're super excited to announce v15! We're super excited to announce v15!
This update is an entire rewrite to make it even easier to set up your own VPN. This update is an entire rewrite to make it even easier to set up your own VPN.
## Breaking Changes
As the whole setup has changed, we recommend to start from scratch. And import your existing configs.
## Major Changes ## Major Changes
- Almost all Environment variables removed - Almost all Environment variables removed
@@ -26,6 +30,8 @@ This update is an entire rewrite to make it even easier to set up your own VPN.
- Removed ARMv6 and ARMv7 support - Removed ARMv6 and ARMv7 support
- Connections over HTTP require setting the `INSECURE` env var - Connections over HTTP require setting the `INSECURE` env var
- Changed license from CC BY-NC-SA 4.0 to AGPL-3.0-only - Changed license from CC BY-NC-SA 4.0 to AGPL-3.0-only
- Added 2FA using TOTP
- Improved mobile support
## [14.0.0] - 2024-09-04 ## [14.0.0] - 2024-09-04

View File

@@ -38,6 +38,7 @@ You have found the easiest way to install & manage WireGuard on any Linux host!
- Prometheus metrics support - Prometheus metrics support
- IPv6 support - IPv6 support
- CIDR support - CIDR support
- 2FA support
> [!NOTE] > [!NOTE]
> To better manage documentation for this project, it has its own site here: [https://wg-easy.github.io/wg-easy/latest](https://wg-easy.github.io/wg-easy/latest) > To better manage documentation for this project, it has its own site here: [https://wg-easy.github.io/wg-easy/latest](https://wg-easy.github.io/wg-easy/latest)

View File

@@ -4,7 +4,9 @@
<slot /> <slot />
</template> </template>
<template #description> <template #description>
<img :src="qrCode" /> <div class="bg-white">
<img :src="qrCode" />
</div>
</template> </template>
<template #actions> <template #actions>
<DialogClose> <DialogClose>

View File

@@ -12,7 +12,7 @@
v-model.trim="data" v-model.trim="data"
:name="id" :name="id"
type="text" type="text"
:autcomplete="autocomplete" :autocomplete="autocomplete"
:placeholder="placeholder" :placeholder="placeholder"
/> />
</template> </template>

View File

@@ -12,7 +12,8 @@
v-model.trim="data" v-model.trim="data"
:name="id" :name="id"
type="text" type="text"
:autcomplete="autocomplete" :autocomplete="autocomplete"
:disabled="disabled"
/> />
</template> </template>
@@ -22,6 +23,7 @@ defineProps<{
label: string; label: string;
description?: string; description?: string;
autocomplete?: string; autocomplete?: string;
disabled?: boolean;
}>(); }>();
const data = defineModel<string>(); const data = defineModel<string>();

View File

@@ -1,21 +1,42 @@
import type { NitroFetchRequest, NitroFetchOptions } from 'nitropack/types'; import type {
NitroFetchRequest,
NitroFetchOptions,
TypedInternalResponse,
ExtractedRouteMethod,
} from 'nitropack/types';
import { FetchError } from 'ofetch'; import { FetchError } from 'ofetch';
type RevertFn = (success: boolean) => Promise<void>; type RevertFn<
R extends NitroFetchRequest,
T = unknown,
O extends NitroFetchOptions<R> = NitroFetchOptions<R>,
> = (
success: boolean,
data:
| TypedInternalResponse<
R,
T,
NitroFetchOptions<R> extends O ? 'get' : ExtractedRouteMethod<R, O>
>
| undefined
) => Promise<void>;
type SubmitOpts = { type SubmitOpts<
revert: RevertFn; R extends NitroFetchRequest,
T = unknown,
O extends NitroFetchOptions<R> = NitroFetchOptions<R>,
> = {
revert: RevertFn<R, T, O>;
successMsg?: string; successMsg?: string;
errorMsg?: string;
noSuccessToast?: boolean; noSuccessToast?: boolean;
}; };
export function useSubmit< export function useSubmit<
R extends NitroFetchRequest, R extends NitroFetchRequest,
O extends NitroFetchOptions<R> & { body?: never }, O extends NitroFetchOptions<R> & { body?: never },
>(url: R, options: O, opts: SubmitOpts) { T = unknown,
>(url: R, options: O, opts: SubmitOpts<R, T, O>) {
const toast = useToast(); const toast = useToast();
const { t: $t } = useI18n();
return async (data: unknown) => { return async (data: unknown) => {
try { try {
@@ -24,11 +45,6 @@ export function useSubmit<
body: data, body: data,
}); });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!(res as any).success) {
throw new Error(opts.errorMsg || $t('toast.errored'));
}
if (!opts.noSuccessToast) { if (!opts.noSuccessToast) {
toast.showToast({ toast.showToast({
type: 'success', type: 'success',
@@ -36,7 +52,8 @@ export function useSubmit<
}); });
} }
await opts.revert(true); // eslint-disable-next-line @typescript-eslint/no-explicit-any
await opts.revert(true, res as any);
} catch (e) { } catch (e) {
if (e instanceof FetchError) { if (e instanceof FetchError) {
toast.showToast({ toast.showToast({
@@ -51,7 +68,7 @@ export function useSubmit<
} else { } else {
console.error(e); console.error(e);
} }
await opts.revert(false); await opts.revert(false, undefined);
} }
}; };
} }

View File

@@ -86,7 +86,6 @@ const _changeCidr = useSubmit(
{ {
revert, revert,
successMsg: t('admin.interface.cidrSuccess'), successMsg: t('admin.interface.cidrSuccess'),
errorMsg: t('admin.interface.cidrError'),
} }
); );
@@ -102,7 +101,6 @@ const _restartInterface = useSubmit(
{ {
revert, revert,
successMsg: t('admin.interface.restartSuccess'), successMsg: t('admin.interface.restartSuccess'),
errorMsg: t('admin.interface.restartError'),
} }
); );

View File

@@ -30,6 +30,18 @@
autocomplete="current-password" autocomplete="current-password"
/> />
<BaseInput
v-if="totpRequired"
v-model="totp"
type="text"
name="totp"
:placeholder="$t('general.2faCode')"
autocomplete="one-time-code"
inputmode="numeric"
maxlength="6"
pattern="\d{6}"
/>
<label <label
class="flex gap-2 whitespace-nowrap" class="flex gap-2 whitespace-nowrap"
:title="$t('login.rememberMeDesc')" :title="$t('login.rememberMeDesc')"
@@ -58,10 +70,15 @@
const authStore = useAuthStore(); const authStore = useAuthStore();
authStore.update(); authStore.update();
const toast = useToast();
const { t } = useI18n();
const authenticating = ref(false); const authenticating = ref(false);
const remember = ref(false); const remember = ref(false);
const username = ref<null | string>(null); const username = ref<string>('');
const password = ref<null | string>(null); const password = ref<string>('');
const totpRequired = ref(false);
const totp = ref<string>('');
const _submit = useSubmit( const _submit = useSubmit(
'/api/session', '/api/session',
@@ -69,13 +86,32 @@ const _submit = useSubmit(
method: 'post', method: 'post',
}, },
{ {
revert: async (success) => { revert: async (success, data) => {
authenticating.value = false;
password.value = null;
if (success) { if (success) {
await navigateTo('/'); if (data?.status === 'success') {
await navigateTo('/');
} else if (data?.status === 'TOTP_REQUIRED') {
authenticating.value = false;
totpRequired.value = true;
toast.showToast({
title: t('general.2fa'),
message: t('login.2faRequired'),
type: 'error',
});
return;
} else if (data?.status === 'INVALID_TOTP_CODE') {
authenticating.value = false;
totp.value = '';
toast.showToast({
title: t('general.2fa'),
message: t('login.2faWrong'),
type: 'error',
});
return;
}
} }
authenticating.value = false;
password.value = '';
}, },
noSuccessToast: true, noSuccessToast: true,
} }
@@ -90,6 +126,7 @@ async function submit() {
username: username.value, username: username.value,
password: password.value, password: password.value,
remember: remember.value, remember: remember.value,
totpCode: totpRequired.value ? totp.value : undefined,
}); });
} }
</script> </script>

View File

@@ -48,12 +48,74 @@
/> />
</FormGroup> </FormGroup>
</FormElement> </FormElement>
<FormElement @submit.prevent>
<FormGroup>
<FormHeading>{{ $t('general.2fa') }}</FormHeading>
<div
v-if="!authStore.userData?.totpVerified && !twofa"
class="col-span-2 flex flex-col"
>
<FormActionField :label="$t('me.enable2fa')" @click="setup2fa" />
</div>
<div
v-if="!authStore.userData?.totpVerified && twofa"
class="col-span-2"
>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ $t('me.enable2faDesc') }}
</p>
<div class="mt-2 flex flex-col gap-2">
<img :src="twofa.qrcode" size="128" class="bg-white" />
<FormTextField
id="2fakey"
:model-value="twofa.key"
:on-update:model-value="() => {}"
:label="$t('me.2faKey')"
:disabled="true"
/>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ $t('me.2faCodeDesc') }}
</p>
<FormTextField
id="2facode"
v-model="code"
:label="$t('general.2faCode')"
/>
<FormActionField
:label="$t('me.enable2fa')"
@click="enable2fa"
/>
</div>
</div>
<div
v-if="authStore.userData?.totpVerified"
class="col-span-2 flex flex-col gap-2"
>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ $t('me.disable2faDesc') }}
</p>
<FormPasswordField
id="2fapassword"
v-model="disable2faPassword"
:label="$t('me.currentPassword')"
type="password"
autocomplete="current-password"
/>
<FormActionField
:label="$t('me.disable2fa')"
@click="disable2fa"
/>
</div>
</FormGroup>
</FormElement>
</PanelBody> </PanelBody>
</Panel> </Panel>
</main> </main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { encodeQR } from 'qr';
const authStore = useAuthStore(); const authStore = useAuthStore();
authStore.update(); authStore.update();
@@ -101,4 +163,81 @@ function updatePassword() {
confirmPassword: confirmPassword.value, confirmPassword: confirmPassword.value,
}); });
} }
const twofa = ref<{ key: string; qrcode: string } | null>(null);
const _setup2fa = useSubmit(
`/api/me/totp`,
{
method: 'post',
},
{
revert: async (success, data) => {
if (success && data?.type === 'setup') {
const qrcode = encodeQR(data.uri, 'svg', {
ecc: 'high',
scale: 4,
encoding: 'byte',
});
const svg = new Blob([qrcode], { type: 'image/svg+xml' });
twofa.value = { key: data.key, qrcode: URL.createObjectURL(svg) };
}
},
}
);
async function setup2fa() {
return _setup2fa({
type: 'setup',
});
}
const code = ref<string>('');
const _enable2fa = useSubmit(
`/api/me/totp`,
{
method: 'post',
},
{
revert: async (success, data) => {
if (success && data?.type === 'created') {
authStore.update();
twofa.value = null;
code.value = '';
}
},
}
);
async function enable2fa() {
return _enable2fa({
type: 'create',
code: code.value,
});
}
const disable2faPassword = ref('');
const _disable2fa = useSubmit(
`/api/me/totp`,
{
method: 'post',
},
{
revert: async (success, data) => {
if (success && data?.type === 'deleted') {
authStore.update();
disable2faPassword.value = '';
}
},
}
);
async function disable2fa() {
return _disable2fa({
type: 'delete',
currentPassword: disable2faPassword.value,
});
}
</script> </script>

View File

@@ -14,7 +14,13 @@
"email": "E-Mail" "email": "E-Mail"
}, },
"me": { "me": {
"currentPassword": "Current Password" "currentPassword": "Current Password",
"enable2fa": "Enable Two Factor Authentication",
"enable2faDesc": "Scan the QR code with your authenticator app or enter the key manually.",
"2faKey": "TOTP Key",
"2faCodeDesc": "Enter the code from your authenticator app.",
"disable2fa": "Disable Two Factor Authentication",
"disable2faDesc": "Enter your password to disable Two Factor Authentication."
}, },
"general": { "general": {
"name": "Name", "name": "Name",
@@ -33,7 +39,9 @@
"yes": "Yes", "yes": "Yes",
"no": "No", "no": "No",
"confirmPassword": "Confirm Password", "confirmPassword": "Confirm Password",
"loading": "Loading..." "loading": "Loading...",
"2fa": "Two Factor Authentication",
"2faCode": "TOTP Code"
}, },
"setup": { "setup": {
"welcome": "Welcome to your first setup of wg-easy", "welcome": "Welcome to your first setup of wg-easy",
@@ -66,11 +74,9 @@
"signIn": "Sign In", "signIn": "Sign In",
"rememberMe": "Remember me", "rememberMe": "Remember me",
"rememberMeDesc": "Stay logged after closing the browser", "rememberMeDesc": "Stay logged after closing the browser",
"insecure": "You can't log in with an insecure connection. Use HTTPS." "insecure": "You can't log in with an insecure connection. Use HTTPS.",
}, "2faRequired": "Two Factor Authentication is required",
"error": { "2faWrong": "Two Factor Authentication is wrong"
"clear": "Clear",
"login": "Log in error"
}, },
"client": { "client": {
"empty": "There are no clients yet.", "empty": "There are no clients yet.",
@@ -117,8 +123,7 @@
"toast": { "toast": {
"success": "Success", "success": "Success",
"saved": "Saved", "saved": "Saved",
"error": "Error", "error": "Error"
"errored": "Failed to save"
}, },
"form": { "form": {
"actions": "Actions", "actions": "Actions",
@@ -155,7 +160,6 @@
}, },
"interface": { "interface": {
"cidrSuccess": "Changed CIDR", "cidrSuccess": "Changed CIDR",
"cidrError": "Failed to change CIDR",
"device": "Device", "device": "Device",
"deviceDesc": "Ethernet device the wireguard traffic should be forwarded through", "deviceDesc": "Ethernet device the wireguard traffic should be forwarded through",
"mtuDesc": "MTU WireGuard will use", "mtuDesc": "MTU WireGuard will use",
@@ -164,8 +168,7 @@
"restart": "Restart Interface", "restart": "Restart Interface",
"restartDesc": "Restart the WireGuard interface", "restartDesc": "Restart the WireGuard interface",
"restartWarn": "Are you sure to restart the interface? This will disconnect all clients.", "restartWarn": "Are you sure to restart the interface? This will disconnect all clients.",
"restartSuccess": "Interface restarted", "restartSuccess": "Interface restarted"
"restartError": "Failed to restart interface"
}, },
"introText": "Welcome to the admin panel.\n\nHere you can manage the general settings, the configuration, the interface settings and the hooks.\n\nStart by choosing one of the sections in the sidebar." "introText": "Welcome to the admin panel.\n\nHere you can manage the general settings, the configuration, the interface settings and the hooks.\n\nStart by choosing one of the sections in the sidebar."
}, },
@@ -194,7 +197,10 @@
"name": "Name", "name": "Name",
"email": "Email", "email": "Email",
"emailInvalid": "Email must be a valid email", "emailInvalid": "Email must be a valid email",
"passwordMatch": "Passwords must match" "passwordMatch": "Passwords must match",
"totpEnable": "TOTP Enable",
"totpEnableTrue": "TOTP Enable must be true",
"totpCode": "TOTP Code"
}, },
"userConfig": { "userConfig": {
"host": "Host" "host": "Host"

View File

@@ -20,7 +20,7 @@
"dependencies": { "dependencies": {
"@eschricht/nuxt-color-mode": "^1.1.5", "@eschricht/nuxt-color-mode": "^1.1.5",
"@heroicons/vue": "^2.2.0", "@heroicons/vue": "^2.2.0",
"@libsql/client": "^0.15.1", "@libsql/client": "^0.15.2",
"@nuxtjs/i18n": "^9.4.0", "@nuxtjs/i18n": "^9.4.0",
"@nuxtjs/tailwindcss": "^6.13.2", "@nuxtjs/tailwindcss": "^6.13.2",
"@phc/format": "^1.0.0", "@phc/format": "^1.0.0",
@@ -38,9 +38,10 @@
"is-ip": "^5.0.1", "is-ip": "^5.0.1",
"js-sha256": "^0.11.0", "js-sha256": "^0.11.0",
"lowdb": "^7.0.1", "lowdb": "^7.0.1",
"nuxt": "^3.16.1", "nuxt": "^3.16.2",
"otpauth": "^9.4.0",
"pinia": "^3.0.1", "pinia": "^3.0.1",
"qrcode": "^1.5.4", "qr": "^0.4.0",
"radix-vue": "^1.9.17", "radix-vue": "^1.9.17",
"semver": "^7.7.1", "semver": "^7.7.1",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
@@ -53,7 +54,6 @@
"@nuxt/eslint": "1.3.0", "@nuxt/eslint": "1.3.0",
"@types/debug": "^4.1.12", "@types/debug": "^4.1.12",
"@types/phc__format": "^1.0.1", "@types/phc__format": "^1.0.1",
"@types/qrcode": "^1.5.5",
"@types/semver": "^7.7.0", "@types/semver": "^7.7.0",
"drizzle-kit": "^0.30.6", "drizzle-kit": "^0.30.6",
"eslint": "^9.23.0", "eslint": "^9.23.0",

616
src/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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',
});
}
);

View File

@@ -20,5 +20,6 @@ export default defineEventHandler(async (event) => {
username: user.username, username: user.username,
name: user.name, name: user.name,
email: user.email, email: user.email,
totpVerified: user.totpVerified,
}; };
}); });

View File

@@ -1,29 +1,42 @@
import { UserLoginSchema } from '#db/repositories/user/types'; import { UserLoginSchema } from '#db/repositories/user/types';
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const { username, password, remember } = await readValidatedBody( const { username, password, remember, totpCode } = await readValidatedBody(
event, event,
validateZod(UserLoginSchema, 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); // TODO: add localization support
if (!user)
throw createError({
statusCode: 401,
statusMessage: 'Incorrect credentials',
});
const userHashPassword = user.password; if (!result.success) {
const passwordValid = await isPasswordValid(password, userHashPassword); switch (result.error) {
if (!passwordValid) { case 'INCORRECT_CREDENTIALS':
throw createError({ throw createError({
statusCode: 401, statusCode: 401,
statusMessage: 'Incorrect credentials', 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 session = await useWGSession(event, remember);
const data = await session.update({ 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})`); SERVER_DEBUG(`New Session: ${data.id} for ${user.id} (${user.username})`);
return { success: true }; return { status: 'success' };
}); });

View File

@@ -80,6 +80,8 @@ CREATE TABLE `users_table` (
`email` text, `email` text,
`name` text NOT NULL, `name` text NOT NULL,
`role` integer NOT NULL, `role` integer NOT NULL,
`totp_key` text,
`totp_verified` integer NOT NULL,
`enabled` integer NOT NULL, `enabled` integer NOT NULL,
`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
`updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL

View File

@@ -1,7 +1,7 @@
{ {
"version": "6", "version": "6",
"dialect": "sqlite", "dialect": "sqlite",
"id": "8c2af02b-c4bd-4880-a9ad-b38805636208", "id": "91f8ccee-7842-4fd3-bb84-f43e00466b20",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"tables": { "tables": {
"clients_table": { "clients_table": {
@@ -558,6 +558,20 @@
"notNull": true, "notNull": true,
"autoincrement": false "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": { "enabled": {
"name": "enabled", "name": "enabled",
"type": "integer", "type": "integer",

View File

@@ -1,6 +1,6 @@
{ {
"id": "a61263b1-9af1-4d2e-99e9-80d08127b545", "id": "0224c6a5-3456-402d-a40d-0821637015da",
"prevId": "8c2af02b-c4bd-4880-a9ad-b38805636208", "prevId": "91f8ccee-7842-4fd3-bb84-f43e00466b20",
"version": "6", "version": "6",
"dialect": "sqlite", "dialect": "sqlite",
"tables": { "tables": {
@@ -558,6 +558,20 @@
"notNull": true, "notNull": true,
"autoincrement": false "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": { "enabled": {
"name": "enabled", "name": "enabled",
"type": "integer", "type": "integer",

View File

@@ -5,14 +5,14 @@
{ {
"idx": 0, "idx": 0,
"version": "6", "version": "6",
"when": 1741355094140, "when": 1743490907551,
"tag": "0000_short_skin", "tag": "0000_short_skin",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 1, "idx": 1,
"version": "6", "version": "6",
"when": 1741355098159, "when": 1743490912488,
"tag": "0001_classy_the_stranger", "tag": "0001_classy_the_stranger",
"breakpoints": true "breakpoints": true
} }

View File

@@ -10,6 +10,8 @@ export const user = sqliteTable('users_table', {
email: text(), email: text(),
name: text().notNull(), name: text().notNull(),
role: int().$type<Role>().notNull(), role: int().$type<Role>().notNull(),
totpKey: text('totp_key'),
totpVerified: int('totp_verified', { mode: 'boolean' }).notNull(),
enabled: int({ mode: 'boolean' }).notNull(), enabled: int({ mode: 'boolean' }).notNull(),
createdAt: text('created_at') createdAt: text('created_at')
.notNull() .notNull()

View File

@@ -1,7 +1,24 @@
import { eq, sql } from 'drizzle-orm'; import { eq, sql } from 'drizzle-orm';
import { TOTP } from 'otpauth';
import { user } from './schema'; import { user } from './schema';
import type { UserType } from './types';
import type { DBType } from '#db/sqlite'; 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) { function createPreparedStatement(db: DBType) {
return { return {
findAll: db.query.user.findMany().prepare(), findAll: db.query.user.findMany().prepare(),
@@ -21,6 +38,14 @@ function createPreparedStatement(db: DBType) {
}) })
.where(eq(user.id, sql.placeholder('id'))) .where(eq(user.id, sql.placeholder('id')))
.prepare(), .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, email: null,
name: 'Administrator', name: 'Administrator',
role: userCount === 0 ? roles.ADMIN : roles.CLIENT, role: userCount === 0 ? roles.ADMIN : roles.CLIENT,
totpVerified: false,
enabled: true, enabled: true,
}); });
}); });
@@ -105,4 +131,121 @@ export class UserService {
.execute(); .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();
});
}
} }

View File

@@ -16,10 +16,16 @@ const password = z
const remember = z.boolean({ message: t('zod.user.remember') }); 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({ export const UserLoginSchema = z.object({
username: username, username: username,
password: password, password: password,
remember: remember, remember: remember,
totpCode: totpCode.optional(),
}); });
export const UserSetupSchema = z export const UserSetupSchema = z
@@ -58,3 +64,17 @@ export const UserUpdatePasswordSchema = z
.refine((val) => val.newPassword === val.confirmPassword, { .refine((val) => val.newPassword === val.confirmPassword, {
message: t('zod.user.passwordMatch'), 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,
}),
]);

View File

@@ -1,6 +1,6 @@
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import debug from 'debug'; import debug from 'debug';
import QRCode from 'qrcode'; import { encodeQR } from 'qr';
import type { InterfaceType } from '#db/repositories/interface/types'; import type { InterfaceType } from '#db/repositories/interface/types';
const WG_DEBUG = debug('WireGuard'); const WG_DEBUG = debug('WireGuard');
@@ -128,9 +128,10 @@ class WireGuard {
async getClientQRCodeSVG({ clientId }: { clientId: ID }) { async getClientQRCodeSVG({ clientId }: { clientId: ID }) {
const config = await this.getClientConfiguration({ clientId }); const config = await this.getClientConfiguration({ clientId });
return QRCode.toString(config, { return encodeQR(config, 'svg', {
type: 'svg', ecc: 'high',
width: 512, scale: 4,
encoding: 'byte',
}); });
} }

View File

@@ -147,3 +147,10 @@ export function validateZod<T>(
} }
}; };
} }
/**
* exhaustive check
*/
export function assertUnreachable(_: never): never {
throw new Error("Didn't expect to get here");
}