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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
<slot />
|
<slot />
|
||||||
</template>
|
</template>
|
||||||
<template #description>
|
<template #description>
|
||||||
|
<div class="bg-white">
|
||||||
<img :src="qrCode" />
|
<img :src="qrCode" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<DialogClose>
|
<DialogClose>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
if (data?.status === 'success') {
|
||||||
await navigateTo('/');
|
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
616
src/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
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,
|
username: user.username,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
totpVerified: user.totpVerified,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,28 +1,41 @@
|
|||||||
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)
|
|
||||||
|
if (!result.success) {
|
||||||
|
switch (result.error) {
|
||||||
|
case 'INCORRECT_CREDENTIALS':
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
statusMessage: 'Incorrect credentials',
|
statusMessage: 'Invalid username or password',
|
||||||
});
|
});
|
||||||
|
case 'TOTP_REQUIRED':
|
||||||
const userHashPassword = user.password;
|
return { status: 'TOTP_REQUIRED' };
|
||||||
const passwordValid = await isPasswordValid(password, userHashPassword);
|
case 'INVALID_TOTP_CODE':
|
||||||
if (!passwordValid) {
|
return { status: 'INVALID_TOTP_CODE' };
|
||||||
|
case 'USER_DISABLED':
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
statusMessage: 'Incorrect credentials',
|
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);
|
||||||
|
|
||||||
@@ -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' };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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