From 13111ae007985779b5a0b225c39b077114867bce Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Fri, 13 Mar 2026 08:36:03 -0400 Subject: [PATCH] feat: nav bar (Home link), profile management, TOTP 2FA - Add Home/Profile links to nav bar alongside Vault/Settings/Logout - Profile page: change email, display name, password - TOTP 2FA: setup with QR code, verify, disable with password - Login flow: two-step TOTP challenge when 2FA is enabled - Backend: new endpoints PUT /profile, POST /totp/setup|verify|disable - Migration: add totp_secret and totp_enabled columns to users Co-Authored-By: Claude Opus 4.6 --- backend/package-lock.json | 157 ++++++++++- backend/package.json | 4 + .../20260313120000_add_totp/migration.sql | 3 + backend/prisma/schema.prisma | 2 + backend/src/auth/auth.controller.ts | 28 +- backend/src/auth/auth.service.ts | 117 +++++++- backend/src/auth/dto/login.dto.ts | 6 +- frontend/layouts/default.vue | 2 + frontend/pages/login.vue | 52 +++- frontend/pages/profile.vue | 251 ++++++++++++++++++ frontend/stores/auth.store.ts | 28 +- 11 files changed, 616 insertions(+), 34 deletions(-) create mode 100644 backend/prisma/migrations/20260313120000_add_totp/migration.sql create mode 100644 frontend/pages/profile.vue diff --git a/backend/package-lock.json b/backend/package-lock.json index 0931568..9e04896 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -23,8 +23,10 @@ "class-validator": "^0.14.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "qrcode": "^1.5.4", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.0", + "speakeasy": "^2.0.0", "ssh2": "^1.15.0", "uuid": "^13.0.0", "ws": "^8.16.0" @@ -36,6 +38,8 @@ "@types/jest": "^30.0.0", "@types/node": "^20.0.0", "@types/passport-jwt": "^4.0.0", + "@types/qrcode": "^1.5.6", + "@types/speakeasy": "^2.0.10", "@types/ssh2": "^1.15.0", "@types/uuid": "^10.0.0", "@types/ws": "^8.5.0", @@ -2460,6 +2464,16 @@ "@types/passport": "*" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", @@ -2495,6 +2509,16 @@ "@types/node": "*" } }, + "node_modules/@types/speakeasy": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/speakeasy/-/speakeasy-2.0.10.tgz", + "integrity": "sha512-QVRlDW5r4yl7p7xkNIbAIC/JtyOcClDIIdKfuG7PWdDT1MmyhtXSANsildohy0K+Lmvf/9RUtLbNLMacvrVwxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/ssh2": { "version": "1.15.5", "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", @@ -3143,6 +3167,12 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base32.js": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz", + "integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==", + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3516,7 +3546,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4044,6 +4073,15 @@ "ms": "2.0.0" } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -4188,6 +4226,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -4719,7 +4763,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, "license": "MIT", "dependencies": { "locate-path": "^5.0.0", @@ -4916,7 +4959,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -6504,7 +6546,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, "license": "MIT", "dependencies": { "p-locate": "^4.1.0" @@ -7179,7 +7220,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, "license": "MIT", "dependencies": { "p-limit": "^2.2.0" @@ -7192,7 +7232,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -7208,7 +7247,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -7302,7 +7340,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7458,6 +7495,15 @@ "node": ">=4" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -7566,6 +7612,75 @@ ], "license": "MIT" }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -7683,7 +7798,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7699,6 +7813,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -8162,6 +8282,18 @@ "node": ">=0.10.0" } }, + "node_modules/speakeasy": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz", + "integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==", + "license": "MIT", + "dependencies": { + "base32.js": "0.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -9234,6 +9366,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -9254,7 +9392,6 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", diff --git a/backend/package.json b/backend/package.json index 07eec82..b2e01e9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,8 +28,10 @@ "class-validator": "^0.14.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "qrcode": "^1.5.4", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.0", + "speakeasy": "^2.0.0", "ssh2": "^1.15.0", "uuid": "^13.0.0", "ws": "^8.16.0" @@ -41,6 +43,8 @@ "@types/jest": "^30.0.0", "@types/node": "^20.0.0", "@types/passport-jwt": "^4.0.0", + "@types/qrcode": "^1.5.6", + "@types/speakeasy": "^2.0.10", "@types/ssh2": "^1.15.0", "@types/uuid": "^10.0.0", "@types/ws": "^8.5.0", diff --git a/backend/prisma/migrations/20260313120000_add_totp/migration.sql b/backend/prisma/migrations/20260313120000_add_totp/migration.sql new file mode 100644 index 0000000..ba8187f --- /dev/null +++ b/backend/prisma/migrations/20260313120000_add_totp/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "totp_secret" TEXT; +ALTER TABLE "users" ADD COLUMN "totp_enabled" BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 22630b6..1b03bce 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -12,6 +12,8 @@ model User { email String @unique passwordHash String @map("password_hash") displayName String? @map("display_name") + totpSecret String? @map("totp_secret") + totpEnabled Boolean @default(false) @map("totp_enabled") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index d9edf8c..40c9f65 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Post, Get, Body, Request, UseGuards } from '@nestjs/common'; +import { Controller, Post, Get, Put, Body, Request, UseGuards } from '@nestjs/common'; import { AuthService } from './auth.service'; import { JwtAuthGuard } from './jwt-auth.guard'; import { LoginDto } from './dto/login.dto'; @@ -9,7 +9,7 @@ export class AuthController { @Post('login') login(@Body() dto: LoginDto) { - return this.auth.login(dto.email, dto.password); + return this.auth.login(dto.email, dto.password, dto.totpCode); } @UseGuards(JwtAuthGuard) @@ -17,4 +17,28 @@ export class AuthController { getProfile(@Request() req: any) { return this.auth.getProfile(req.user.sub); } + + @UseGuards(JwtAuthGuard) + @Put('profile') + updateProfile(@Request() req: any, @Body() body: { email?: string; displayName?: string; currentPassword?: string; newPassword?: string }) { + return this.auth.updateProfile(req.user.sub, body); + } + + @UseGuards(JwtAuthGuard) + @Post('totp/setup') + totpSetup(@Request() req: any) { + return this.auth.totpSetup(req.user.sub); + } + + @UseGuards(JwtAuthGuard) + @Post('totp/verify') + totpVerify(@Request() req: any, @Body() body: { code: string }) { + return this.auth.totpVerify(req.user.sub, body.code); + } + + @UseGuards(JwtAuthGuard) + @Post('totp/disable') + totpDisable(@Request() req: any, @Body() body: { password: string }) { + return this.auth.totpDisable(req.user.sub, body.password); + } } diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 7f3d812..1d1b295 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -1,7 +1,9 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { PrismaService } from '../prisma/prisma.service'; import * as bcrypt from 'bcrypt'; +import * as speakeasy from 'speakeasy'; +import * as QRCode from 'qrcode'; @Injectable() export class AuthService { @@ -10,13 +12,28 @@ export class AuthService { private jwt: JwtService, ) {} - async login(email: string, password: string) { + async login(email: string, password: string, totpCode?: string) { const user = await this.prisma.user.findUnique({ where: { email } }); if (!user) throw new UnauthorizedException('Invalid credentials'); const valid = await bcrypt.compare(password, user.passwordHash); if (!valid) throw new UnauthorizedException('Invalid credentials'); + // If TOTP is enabled, require a valid code + if (user.totpEnabled && user.totpSecret) { + if (!totpCode) { + // Signal frontend to show TOTP input + return { requires_totp: true }; + } + const verified = speakeasy.totp.verify({ + secret: user.totpSecret, + encoding: 'base32', + token: totpCode, + window: 1, + }); + if (!verified) throw new UnauthorizedException('Invalid TOTP code'); + } + const payload = { sub: user.id, email: user.email }; return { access_token: this.jwt.sign(payload), @@ -27,6 +44,100 @@ export class AuthService { async getProfile(userId: number) { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new UnauthorizedException(); - return { id: user.id, email: user.email, displayName: user.displayName }; + return { + id: user.id, + email: user.email, + displayName: user.displayName, + totpEnabled: user.totpEnabled, + }; + } + + async updateProfile(userId: number, data: { email?: string; displayName?: string; currentPassword?: string; newPassword?: string }) { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) throw new UnauthorizedException(); + + const update: any = {}; + + if (data.email && data.email !== user.email) { + update.email = data.email; + } + if (data.displayName !== undefined) { + update.displayName = data.displayName; + } + if (data.newPassword) { + if (!data.currentPassword) { + throw new BadRequestException('Current password required to set new password'); + } + const valid = await bcrypt.compare(data.currentPassword, user.passwordHash); + if (!valid) throw new BadRequestException('Current password is incorrect'); + update.passwordHash = await bcrypt.hash(data.newPassword, 10); + } + + if (Object.keys(update).length === 0) { + return { message: 'No changes' }; + } + + await this.prisma.user.update({ where: { id: userId }, data: update }); + return { message: 'Profile updated' }; + } + + async totpSetup(userId: number) { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) throw new UnauthorizedException(); + if (user.totpEnabled) throw new BadRequestException('TOTP already enabled'); + + const secret = speakeasy.generateSecret({ + name: `Wraith (${user.email})`, + issuer: 'Wraith', + }); + + // Store secret temporarily (not enabled until verified) + await this.prisma.user.update({ + where: { id: userId }, + data: { totpSecret: secret.base32 }, + }); + + const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url!); + + return { + secret: secret.base32, + qrCode: qrCodeUrl, + }; + } + + async totpVerify(userId: number, code: string) { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user || !user.totpSecret) throw new BadRequestException('TOTP not set up'); + + const verified = speakeasy.totp.verify({ + secret: user.totpSecret, + encoding: 'base32', + token: code, + window: 1, + }); + + if (!verified) throw new BadRequestException('Invalid code — try again'); + + await this.prisma.user.update({ + where: { id: userId }, + data: { totpEnabled: true }, + }); + + return { message: 'TOTP enabled successfully' }; + } + + async totpDisable(userId: number, password: string) { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) throw new UnauthorizedException(); + + const valid = await bcrypt.compare(password, user.passwordHash); + if (!valid) throw new BadRequestException('Password incorrect'); + + await this.prisma.user.update({ + where: { id: userId }, + data: { totpEnabled: false, totpSecret: null }, + }); + + return { message: 'TOTP disabled' }; } } diff --git a/backend/src/auth/dto/login.dto.ts b/backend/src/auth/dto/login.dto.ts index 77c0139..9f0a7c2 100644 --- a/backend/src/auth/dto/login.dto.ts +++ b/backend/src/auth/dto/login.dto.ts @@ -1,4 +1,4 @@ -import { IsEmail, IsString, MinLength } from 'class-validator'; +import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator'; export class LoginDto { @IsEmail() @@ -7,4 +7,8 @@ export class LoginDto { @IsString() @MinLength(1) password: string; + + @IsOptional() + @IsString() + totpCode?: string; } diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index 25b7b5d..97395a8 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -36,7 +36,9 @@ onMounted(async () => {

WRAITH

+ Home Vault + Profile Settings
diff --git a/frontend/pages/login.vue b/frontend/pages/login.vue index f5a0c1e..0774cf9 100644 --- a/frontend/pages/login.vue +++ b/frontend/pages/login.vue @@ -4,14 +4,25 @@ definePageMeta({ layout: 'auth' }) const auth = useAuthStore() const email = ref('admin@wraith.local') const password = ref('') +const totpCode = ref('') const error = ref('') const loading = ref(false) +const requiresTotp = ref(false) async function handleLogin() { error.value = '' loading.value = true try { - await auth.login(email.value, password.value) + const res = await auth.login( + email.value, + password.value, + requiresTotp.value ? totpCode.value : undefined, + ) + if (res.requires_totp) { + requiresTotp.value = true + loading.value = false + return + } navigateTo('/') } catch (e: any) { error.value = e.data?.message || 'Login failed' @@ -28,20 +39,37 @@ async function handleLogin() {

Remote Access Terminal

-
- - -
-
- - -
+ +

{{ error }}

+
diff --git a/frontend/pages/profile.vue b/frontend/pages/profile.vue new file mode 100644 index 0000000..8ec7aa5 --- /dev/null +++ b/frontend/pages/profile.vue @@ -0,0 +1,251 @@ + + + diff --git a/frontend/stores/auth.store.ts b/frontend/stores/auth.store.ts index 4ad0940..a9ed9da 100644 --- a/frontend/stores/auth.store.ts +++ b/frontend/stores/auth.store.ts @@ -4,6 +4,13 @@ interface User { id: number email: string displayName: string | null + totpEnabled?: boolean +} + +interface LoginResponse { + access_token?: string + user?: User + requires_totp?: boolean } export const useAuthStore = defineStore('auth', { @@ -15,14 +22,23 @@ export const useAuthStore = defineStore('auth', { isAuthenticated: (state) => !!state.token, }, actions: { - async login(email: string, password: string) { - const res = await $fetch<{ access_token: string; user: User }>('/api/auth/login', { + async login(email: string, password: string, totpCode?: string): Promise { + const body: Record = { email, password } + if (totpCode) body.totpCode = totpCode + + const res = await $fetch('/api/auth/login', { method: 'POST', - body: { email, password }, + body, }) - this.token = res.access_token - this.user = res.user - localStorage.setItem('wraith_token', res.access_token) + + if (res.requires_totp) { + return { requires_totp: true } + } + + this.token = res.access_token! + this.user = res.user! + localStorage.setItem('wraith_token', res.access_token!) + return res }, logout() { this.token = ''