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 <noreply@anthropic.com>
This commit is contained in:
parent
f1e3892572
commit
13111ae007
157
backend/package-lock.json
generated
157
backend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -36,7 +36,9 @@ onMounted(async () => {
|
||||
<h1 class="text-lg font-bold tracking-wider text-wraith-400">WRAITH</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<NuxtLink to="/" class="text-sm text-gray-400 hover:text-white">Home</NuxtLink>
|
||||
<NuxtLink to="/vault" class="text-sm text-gray-400 hover:text-white">Vault</NuxtLink>
|
||||
<NuxtLink to="/profile" class="text-sm text-gray-400 hover:text-white">Profile</NuxtLink>
|
||||
<NuxtLink to="/settings" class="text-sm text-gray-400 hover:text-white">Settings</NuxtLink>
|
||||
<button @click="auth.logout()" class="text-sm text-gray-500 hover:text-red-400">Logout</button>
|
||||
</div>
|
||||
|
||||
@ -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,6 +39,7 @@ async function handleLogin() {
|
||||
<p class="text-gray-500 mt-1 text-sm">Remote Access Terminal</p>
|
||||
</div>
|
||||
<form @submit.prevent="handleLogin" class="space-y-4 bg-gray-900 p-6 rounded-lg border border-gray-800">
|
||||
<template v-if="!requiresTotp">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1">Email</label>
|
||||
<input v-model="email" type="email" required autofocus
|
||||
@ -38,10 +50,26 @@ async function handleLogin() {
|
||||
<input v-model="password" type="password" required
|
||||
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="text-center mb-2">
|
||||
<p class="text-sm text-gray-400">Enter your authenticator code</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1">TOTP Code</label>
|
||||
<input v-model="totpCode" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="6"
|
||||
required autofocus autocomplete="one-time-code" placeholder="000000"
|
||||
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white text-center text-lg tracking-widest focus:border-wraith-500 focus:outline-none" />
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="error" class="text-red-400 text-sm">{{ error }}</p>
|
||||
<button type="submit" :disabled="loading"
|
||||
class="w-full py-2 bg-wraith-600 hover:bg-wraith-700 text-white rounded font-medium disabled:opacity-50">
|
||||
{{ loading ? 'Signing in...' : 'Sign In' }}
|
||||
{{ loading ? 'Verifying...' : requiresTotp ? 'Verify' : 'Sign In' }}
|
||||
</button>
|
||||
<button v-if="requiresTotp" type="button" @click="requiresTotp = false; totpCode = ''; error = ''"
|
||||
class="w-full py-2 text-sm text-gray-500 hover:text-gray-300">
|
||||
Back to login
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
251
frontend/pages/profile.vue
Normal file
251
frontend/pages/profile.vue
Normal file
@ -0,0 +1,251 @@
|
||||
<script setup lang="ts">
|
||||
const auth = useAuthStore()
|
||||
|
||||
const email = ref('')
|
||||
const displayName = ref('')
|
||||
const currentPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const profileMsg = ref('')
|
||||
const profileError = ref('')
|
||||
const profileLoading = ref(false)
|
||||
|
||||
// TOTP state
|
||||
const totpEnabled = ref(false)
|
||||
const totpSetupData = ref<{ secret: string; qrCode: string } | null>(null)
|
||||
const totpCode = ref('')
|
||||
const totpDisablePassword = ref('')
|
||||
const totpMsg = ref('')
|
||||
const totpError = ref('')
|
||||
const totpLoading = ref(false)
|
||||
|
||||
function headers() {
|
||||
return { Authorization: `Bearer ${auth.token}` }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const profile = await $fetch<{ id: number; email: string; displayName: string | null; totpEnabled: boolean }>('/api/auth/profile', {
|
||||
headers: headers(),
|
||||
})
|
||||
email.value = profile.email
|
||||
displayName.value = profile.displayName || ''
|
||||
totpEnabled.value = profile.totpEnabled
|
||||
} catch {
|
||||
// handled by layout auth guard
|
||||
}
|
||||
})
|
||||
|
||||
async function saveProfile() {
|
||||
profileMsg.value = ''
|
||||
profileError.value = ''
|
||||
|
||||
if (newPassword.value && newPassword.value !== confirmPassword.value) {
|
||||
profileError.value = 'New passwords do not match'
|
||||
return
|
||||
}
|
||||
|
||||
profileLoading.value = true
|
||||
try {
|
||||
const body: Record<string, string> = {}
|
||||
if (email.value) body.email = email.value
|
||||
if (displayName.value !== undefined) body.displayName = displayName.value
|
||||
if (newPassword.value) {
|
||||
body.currentPassword = currentPassword.value
|
||||
body.newPassword = newPassword.value
|
||||
}
|
||||
|
||||
const res = await $fetch<{ message: string }>('/api/auth/profile', {
|
||||
method: 'PUT',
|
||||
body,
|
||||
headers: headers(),
|
||||
})
|
||||
profileMsg.value = res.message
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
// Refresh profile in auth store
|
||||
await auth.fetchProfile()
|
||||
} catch (e: any) {
|
||||
profileError.value = e.data?.message || 'Failed to update profile'
|
||||
} finally {
|
||||
profileLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setupTotp() {
|
||||
totpError.value = ''
|
||||
totpMsg.value = ''
|
||||
totpLoading.value = true
|
||||
try {
|
||||
totpSetupData.value = await $fetch('/api/auth/totp/setup', {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
})
|
||||
} catch (e: any) {
|
||||
totpError.value = e.data?.message || 'Failed to start TOTP setup'
|
||||
} finally {
|
||||
totpLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyTotp() {
|
||||
totpError.value = ''
|
||||
totpMsg.value = ''
|
||||
totpLoading.value = true
|
||||
try {
|
||||
const res = await $fetch<{ message: string }>('/api/auth/totp/verify', {
|
||||
method: 'POST',
|
||||
body: { code: totpCode.value },
|
||||
headers: headers(),
|
||||
})
|
||||
totpMsg.value = res.message
|
||||
totpEnabled.value = true
|
||||
totpSetupData.value = null
|
||||
totpCode.value = ''
|
||||
} catch (e: any) {
|
||||
totpError.value = e.data?.message || 'Verification failed'
|
||||
} finally {
|
||||
totpLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function disableTotp() {
|
||||
totpError.value = ''
|
||||
totpMsg.value = ''
|
||||
totpLoading.value = true
|
||||
try {
|
||||
const res = await $fetch<{ message: string }>('/api/auth/totp/disable', {
|
||||
method: 'POST',
|
||||
body: { password: totpDisablePassword.value },
|
||||
headers: headers(),
|
||||
})
|
||||
totpMsg.value = res.message
|
||||
totpEnabled.value = false
|
||||
totpDisablePassword.value = ''
|
||||
} catch (e: any) {
|
||||
totpError.value = e.data?.message || 'Failed to disable TOTP'
|
||||
} finally {
|
||||
totpLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-2xl mx-auto p-6 space-y-8">
|
||||
<h2 class="text-xl font-bold text-white">Profile</h2>
|
||||
|
||||
<!-- Profile form -->
|
||||
<form @submit.prevent="saveProfile" class="space-y-4 bg-gray-900 p-6 rounded-lg border border-gray-800">
|
||||
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider">Account Details</h3>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1">Email</label>
|
||||
<input v-model="email" type="email"
|
||||
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1">Display Name</label>
|
||||
<input v-model="displayName" type="text"
|
||||
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
|
||||
</div>
|
||||
|
||||
<hr class="border-gray-800" />
|
||||
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider">Change Password</h3>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1">Current Password</label>
|
||||
<input v-model="currentPassword" type="password" autocomplete="current-password"
|
||||
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1">New Password</label>
|
||||
<input v-model="newPassword" type="password" autocomplete="new-password"
|
||||
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1">Confirm New Password</label>
|
||||
<input v-model="confirmPassword" type="password" autocomplete="new-password"
|
||||
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
|
||||
</div>
|
||||
|
||||
<p v-if="profileMsg" class="text-green-400 text-sm">{{ profileMsg }}</p>
|
||||
<p v-if="profileError" class="text-red-400 text-sm">{{ profileError }}</p>
|
||||
|
||||
<button type="submit" :disabled="profileLoading"
|
||||
class="px-4 py-2 bg-wraith-600 hover:bg-wraith-700 text-white rounded disabled:opacity-50">
|
||||
{{ profileLoading ? 'Saving...' : 'Save Changes' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- TOTP Section -->
|
||||
<div class="bg-gray-900 p-6 rounded-lg border border-gray-800 space-y-4">
|
||||
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider">Two-Factor Authentication</h3>
|
||||
|
||||
<!-- TOTP is enabled -->
|
||||
<template v-if="totpEnabled && !totpSetupData">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-2 h-2 rounded-full bg-green-500" />
|
||||
<span class="text-sm text-green-400">TOTP is enabled</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1">Password (to disable)</label>
|
||||
<input v-model="totpDisablePassword" type="password"
|
||||
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
|
||||
</div>
|
||||
<button @click="disableTotp" :disabled="totpLoading || !totpDisablePassword"
|
||||
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded text-sm disabled:opacity-50">
|
||||
{{ totpLoading ? 'Disabling...' : 'Disable TOTP' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- TOTP setup in progress -->
|
||||
<template v-else-if="totpSetupData">
|
||||
<p class="text-sm text-gray-400">Scan this QR code with your authenticator app, then enter the code below to verify.</p>
|
||||
<div class="flex justify-center">
|
||||
<img :src="totpSetupData.qrCode" alt="TOTP QR Code" class="w-48 h-48 rounded" />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-xs text-gray-500 mb-1">Manual key:</p>
|
||||
<code class="text-xs text-wraith-400 select-all">{{ totpSetupData.secret }}</code>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1">Verification Code</label>
|
||||
<input v-model="totpCode" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="6"
|
||||
placeholder="000000" autocomplete="one-time-code"
|
||||
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white text-center text-lg tracking-widest focus:border-wraith-500 focus:outline-none" />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button @click="verifyTotp" :disabled="totpLoading || totpCode.length < 6"
|
||||
class="px-4 py-2 bg-wraith-600 hover:bg-wraith-700 text-white rounded text-sm disabled:opacity-50">
|
||||
{{ totpLoading ? 'Verifying...' : 'Verify & Enable' }}
|
||||
</button>
|
||||
<button @click="totpSetupData = null; totpCode = ''"
|
||||
class="px-4 py-2 text-sm text-gray-500 hover:text-gray-300">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- TOTP not enabled -->
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-2 h-2 rounded-full bg-gray-600" />
|
||||
<span class="text-sm text-gray-500">TOTP is not enabled</span>
|
||||
</div>
|
||||
<button @click="setupTotp" :disabled="totpLoading"
|
||||
class="px-4 py-2 bg-wraith-600 hover:bg-wraith-700 text-white rounded text-sm disabled:opacity-50">
|
||||
{{ totpLoading ? 'Setting up...' : 'Enable TOTP' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<p v-if="totpMsg" class="text-green-400 text-sm">{{ totpMsg }}</p>
|
||||
<p v-if="totpError" class="text-red-400 text-sm">{{ totpError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -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<LoginResponse> {
|
||||
const body: Record<string, string> = { email, password }
|
||||
if (totpCode) body.totpCode = totpCode
|
||||
|
||||
const res = await $fetch<LoginResponse>('/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 = ''
|
||||
|
||||
Loading…
Reference in New Issue
Block a user