diff --git a/src/app/components/Form/TextField.vue b/src/app/components/Form/TextField.vue index 6cffda28..69c293db 100644 --- a/src/app/components/Form/TextField.vue +++ b/src/app/components/Form/TextField.vue @@ -13,6 +13,7 @@ :name="id" type="text" :autcomplete="autocomplete" + :disabled="disabled" /> @@ -22,6 +23,7 @@ defineProps<{ label: string; description?: string; autocomplete?: string; + disabled?: boolean; }>(); const data = defineModel(); diff --git a/src/app/pages/me.vue b/src/app/pages/me.vue index a197f665..86cf00f1 100644 --- a/src/app/pages/me.vue +++ b/src/app/pages/me.vue @@ -48,12 +48,66 @@ /> + + + {{ $t('general.2fa') }} + +
+

+ {{ $t('me.enable2faDesc') }} +

+
+ + +

+ {{ $t('me.2faCodeDesc') }} +

+ + +
+
+
+

+ {{ $t('me.disable2faDesc') }} +

+ +
+
+
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 6dd09415..604d837d 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -14,7 +14,14 @@ "email": "E-Mail" }, "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": "2FA Key", + "2faCode": "2FA Code", + "2faCodeDesc": "Enter the code from your authenticator app.", + "disable2fa": "Disable Two Factor Authentication", + "disable2faDesc": "Enter your password to disable Two Factor Authentication." }, "general": { "name": "Name", @@ -33,7 +40,8 @@ "yes": "Yes", "no": "No", "confirmPassword": "Confirm Password", - "loading": "Loading..." + "loading": "Loading...", + "2fa": "Two Factor Authentication" }, "setup": { "welcome": "Welcome to your first setup of wg-easy", @@ -194,7 +202,10 @@ "name": "Name", "email": "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": { "host": "Host" diff --git a/src/server/api/me/totp.post.ts b/src/server/api/me/totp.post.ts new file mode 100644 index 00000000..8b37c142 --- /dev/null +++ b/src/server/api/me/totp.post.ts @@ -0,0 +1,56 @@ +import { Secret, TOTP } from 'otpauth'; +import { UserUpdateTotpSchema } from '#db/repositories/user/types'; + +type Response = + | { + success: boolean; + type: 'create'; + key: string; + uri: string; + } + | { + success: boolean; + type: 'created'; + }; + +export default definePermissionEventHandler( + 'me', + 'update', + async ({ event, user, checkPermissions }) => { + const { code } = await readValidatedBody( + event, + validateZod(UserUpdateTotpSchema, event) + ); + + checkPermissions(user); + + if (!code) { + 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: 'create', + key: key.base32, + uri: totp.toString(), + } as Response; + } else { + await Database.users.verifyTotp(user.id, code); + + return { + success: true, + type: 'created', + } as Response; + } + } +); diff --git a/src/server/api/session.get.ts b/src/server/api/session.get.ts index a16ef0fb..c72859ee 100644 --- a/src/server/api/session.get.ts +++ b/src/server/api/session.get.ts @@ -20,5 +20,6 @@ export default defineEventHandler(async (event) => { username: user.username, name: user.name, email: user.email, + totpVerified: user.totpVerified, }; }); diff --git a/src/server/database/migrations/0000_short_skin.sql b/src/server/database/migrations/0000_short_skin.sql index 638b9e9d..f99d9e5b 100644 --- a/src/server/database/migrations/0000_short_skin.sql +++ b/src/server/database/migrations/0000_short_skin.sql @@ -80,7 +80,8 @@ CREATE TABLE `users_table` ( `email` text, `name` text NOT NULL, `role` integer NOT NULL, - `totp` text, + `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 diff --git a/src/server/database/migrations/meta/0000_snapshot.json b/src/server/database/migrations/meta/0000_snapshot.json index a2cdf466..7fc1e71c 100644 --- a/src/server/database/migrations/meta/0000_snapshot.json +++ b/src/server/database/migrations/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "cd2794c5-3e55-4b55-b2ba-678f53cfdec7", + "id": "91f8ccee-7842-4fd3-bb84-f43e00466b20", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "clients_table": { @@ -558,13 +558,20 @@ "notNull": true, "autoincrement": false }, - "totp": { - "name": "totp", + "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", diff --git a/src/server/database/migrations/meta/0001_snapshot.json b/src/server/database/migrations/meta/0001_snapshot.json index 7758cd86..1e8fe22f 100644 --- a/src/server/database/migrations/meta/0001_snapshot.json +++ b/src/server/database/migrations/meta/0001_snapshot.json @@ -1,6 +1,6 @@ { - "id": "8d03a104-4de0-4efe-9348-962fac317055", - "prevId": "cd2794c5-3e55-4b55-b2ba-678f53cfdec7", + "id": "0224c6a5-3456-402d-a40d-0821637015da", + "prevId": "91f8ccee-7842-4fd3-bb84-f43e00466b20", "version": "6", "dialect": "sqlite", "tables": { @@ -558,13 +558,20 @@ "notNull": true, "autoincrement": false }, - "totp": { - "name": "totp", + "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", diff --git a/src/server/database/migrations/meta/_journal.json b/src/server/database/migrations/meta/_journal.json index 3fff657c..0629bfb4 100644 --- a/src/server/database/migrations/meta/_journal.json +++ b/src/server/database/migrations/meta/_journal.json @@ -5,14 +5,14 @@ { "idx": 0, "version": "6", - "when": 1743422046226, + "when": 1743490907551, "tag": "0000_short_skin", "breakpoints": true }, { "idx": 1, "version": "6", - "when": 1743422053327, + "when": 1743490912488, "tag": "0001_classy_the_stranger", "breakpoints": true } diff --git a/src/server/database/repositories/user/schema.ts b/src/server/database/repositories/user/schema.ts index 1bae456c..77eb13e2 100644 --- a/src/server/database/repositories/user/schema.ts +++ b/src/server/database/repositories/user/schema.ts @@ -10,7 +10,8 @@ export const user = sqliteTable('users_table', { email: text(), name: text().notNull(), role: int().$type().notNull(), - totp: text(), + totpKey: text('totp_key'), + totpVerified: int('totp_verified', { mode: 'boolean' }).notNull(), enabled: int({ mode: 'boolean' }).notNull(), createdAt: text('created_at') .notNull() diff --git a/src/server/database/repositories/user/service.ts b/src/server/database/repositories/user/service.ts index d46d3326..995050b5 100644 --- a/src/server/database/repositories/user/service.ts +++ b/src/server/database/repositories/user/service.ts @@ -1,4 +1,5 @@ import { eq, sql } from 'drizzle-orm'; +import { TOTP } from 'otpauth'; import { user } from './schema'; import type { DBType } from '#db/sqlite'; @@ -21,6 +22,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 +76,7 @@ export class UserService { email: null, name: 'Administrator', role: userCount === 0 ? roles.ADMIN : roles.CLIENT, + totpVerified: false, enabled: true, }); }); @@ -105,4 +115,47 @@ export class UserService { .execute(); }); } + + updateTotpKey(id: ID, key: string | null) { + return this.#statements.updateKey.execute({ id, key }); + } + + 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, + }); + + console.log({ code, key: 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(); + }); + } } diff --git a/src/server/database/repositories/user/types.ts b/src/server/database/repositories/user/types.ts index d237ab9f..ae34e52e 100644 --- a/src/server/database/repositories/user/types.ts +++ b/src/server/database/repositories/user/types.ts @@ -58,3 +58,11 @@ export const UserUpdatePasswordSchema = z .refine((val) => val.newPassword === val.confirmPassword, { message: t('zod.user.passwordMatch'), }); + +export const UserUpdateTotpSchema = z.object({ + code: z + .string({ + message: t('zod.user.totpCode'), + }) + .nullable(), +});