diff --git a/.gitignore b/.gitignore index ebdc44c6..d153ed6b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ next-env.d.ts *.log .machinelogs*.json *-audit.json +migrations diff --git a/package.json b/package.json index 3d5fb401..6aed4076 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "dotenvx run -- tsx watch server/index.ts", "db:generate": "drizzle-kit generate", - "db:push": "npx tsx db/migrate.ts", + "db:push": "npx tsx server/db/migrate.ts", "db:hydrate": "npx tsx scripts/hydrate.ts", "db:studio": "drizzle-kit studio", "build": "next build && tsc --project tsconfig.server.json && tsc-alias -p tsconfig.server.json", diff --git a/server/migrations/0000_faithful_katie_power.sql b/server/migrations/0000_faithful_katie_power.sql deleted file mode 100644 index 55307dda..00000000 --- a/server/migrations/0000_faithful_katie_power.sql +++ /dev/null @@ -1,68 +0,0 @@ -CREATE TABLE `exitNodes` ( - `exitNodeId` integer PRIMARY KEY AUTOINCREMENT NOT NULL, - `name` text NOT NULL, - `address` text NOT NULL, - `privateKey` text, - `listenPort` integer -); ---> statement-breakpoint -CREATE TABLE `orgs` ( - `orgId` integer PRIMARY KEY AUTOINCREMENT NOT NULL, - `name` text NOT NULL, - `domain` text NOT NULL -); ---> statement-breakpoint -CREATE TABLE `resources` ( - `resourceId` text(2048) PRIMARY KEY NOT NULL, - `siteId` integer, - `name` text NOT NULL, - `subdomain` text, - FOREIGN KEY (`siteId`) REFERENCES `sites`(`siteId`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -CREATE TABLE `routes` ( - `routeId` integer PRIMARY KEY AUTOINCREMENT NOT NULL, - `exitNodeId` integer, - `subnet` text NOT NULL, - FOREIGN KEY (`exitNodeId`) REFERENCES `exitNodes`(`exitNodeId`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -CREATE TABLE `session` ( - `id` text PRIMARY KEY NOT NULL, - `userId` text NOT NULL, - `expiresAt` integer NOT NULL, - FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action -); ---> statement-breakpoint -CREATE TABLE `sites` ( - `siteId` integer PRIMARY KEY AUTOINCREMENT NOT NULL, - `orgId` integer, - `exitNode` integer, - `name` text NOT NULL, - `subdomain` text, - `pubKey` text, - `subnet` text, - `bytesIn` integer, - `bytesOut` integer, - FOREIGN KEY (`orgId`) REFERENCES `orgs`(`orgId`) ON UPDATE no action ON DELETE cascade, - FOREIGN KEY (`exitNode`) REFERENCES `exitNodes`(`exitNodeId`) ON UPDATE no action ON DELETE set null -); ---> statement-breakpoint -CREATE TABLE `targets` ( - `targetId` integer PRIMARY KEY AUTOINCREMENT NOT NULL, - `resourceId` text, - `ip` text NOT NULL, - `method` text NOT NULL, - `port` integer NOT NULL, - `protocol` text, - `enabled` integer DEFAULT true NOT NULL, - FOREIGN KEY (`resourceId`) REFERENCES `resources`(`resourceId`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -CREATE TABLE `user` ( - `id` text PRIMARY KEY NOT NULL, - `email` text NOT NULL, - `passwordHash` text NOT NULL -); ---> statement-breakpoint -CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`); \ No newline at end of file diff --git a/server/migrations/meta/0000_snapshot.json b/server/migrations/meta/0000_snapshot.json deleted file mode 100644 index 708e4f82..00000000 --- a/server/migrations/meta/0000_snapshot.json +++ /dev/null @@ -1,440 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "fb7ff7a8-20e6-4602-b096-2f2284ad751e", - "prevId": "00000000-0000-0000-0000-000000000000", - "tables": { - "exitNodes": { - "name": "exitNodes", - "columns": { - "exitNodeId": { - "name": "exitNodeId", - "type": "integer", - "primaryKey": true, - "notNull": true, - "autoincrement": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "address": { - "name": "address", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "privateKey": { - "name": "privateKey", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "listenPort": { - "name": "listenPort", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {} - }, - "orgs": { - "name": "orgs", - "columns": { - "orgId": { - "name": "orgId", - "type": "integer", - "primaryKey": true, - "notNull": true, - "autoincrement": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "domain": { - "name": "domain", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {} - }, - "resources": { - "name": "resources", - "columns": { - "resourceId": { - "name": "resourceId", - "type": "text(2048)", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "siteId": { - "name": "siteId", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "subdomain": { - "name": "subdomain", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "resources_siteId_sites_siteId_fk": { - "name": "resources_siteId_sites_siteId_fk", - "tableFrom": "resources", - "tableTo": "sites", - "columnsFrom": [ - "siteId" - ], - "columnsTo": [ - "siteId" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {} - }, - "routes": { - "name": "routes", - "columns": { - "routeId": { - "name": "routeId", - "type": "integer", - "primaryKey": true, - "notNull": true, - "autoincrement": true - }, - "exitNodeId": { - "name": "exitNodeId", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "subnet": { - "name": "subnet", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "routes_exitNodeId_exitNodes_exitNodeId_fk": { - "name": "routes_exitNodeId_exitNodes_exitNodeId_fk", - "tableFrom": "routes", - "tableTo": "exitNodes", - "columnsFrom": [ - "exitNodeId" - ], - "columnsTo": [ - "exitNodeId" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {} - }, - "session": { - "name": "session", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "userId": { - "name": "userId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "expiresAt": { - "name": "expiresAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "session_userId_user_id_fk": { - "name": "session_userId_user_id_fk", - "tableFrom": "session", - "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {} - }, - "sites": { - "name": "sites", - "columns": { - "siteId": { - "name": "siteId", - "type": "integer", - "primaryKey": true, - "notNull": true, - "autoincrement": true - }, - "orgId": { - "name": "orgId", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "exitNode": { - "name": "exitNode", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "subdomain": { - "name": "subdomain", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "pubKey": { - "name": "pubKey", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "subnet": { - "name": "subnet", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "bytesIn": { - "name": "bytesIn", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "bytesOut": { - "name": "bytesOut", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "sites_orgId_orgs_orgId_fk": { - "name": "sites_orgId_orgs_orgId_fk", - "tableFrom": "sites", - "tableTo": "orgs", - "columnsFrom": [ - "orgId" - ], - "columnsTo": [ - "orgId" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "sites_exitNode_exitNodes_exitNodeId_fk": { - "name": "sites_exitNode_exitNodes_exitNodeId_fk", - "tableFrom": "sites", - "tableTo": "exitNodes", - "columnsFrom": [ - "exitNode" - ], - "columnsTo": [ - "exitNodeId" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {} - }, - "targets": { - "name": "targets", - "columns": { - "targetId": { - "name": "targetId", - "type": "integer", - "primaryKey": true, - "notNull": true, - "autoincrement": true - }, - "resourceId": { - "name": "resourceId", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "ip": { - "name": "ip", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "method": { - "name": "method", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "port": { - "name": "port", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "protocol": { - "name": "protocol", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "enabled": { - "name": "enabled", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": true - } - }, - "indexes": {}, - "foreignKeys": { - "targets_resourceId_resources_resourceId_fk": { - "name": "targets_resourceId_resources_resourceId_fk", - "tableFrom": "targets", - "tableTo": "resources", - "columnsFrom": [ - "resourceId" - ], - "columnsTo": [ - "resourceId" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {} - }, - "user": { - "name": "user", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "passwordHash": { - "name": "passwordHash", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "user_email_unique": { - "name": "user_email_unique", - "columns": [ - "email" - ], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {} - } - }, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} \ No newline at end of file diff --git a/server/migrations/meta/_journal.json b/server/migrations/meta/_journal.json deleted file mode 100644 index 28192399..00000000 --- a/server/migrations/meta/_journal.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": "7", - "dialect": "sqlite", - "entries": [ - { - "idx": 0, - "version": "6", - "when": 1727750917675, - "tag": "0000_faithful_katie_power", - "breakpoints": true - } - ] -} \ No newline at end of file diff --git a/server/routers/auth/index.ts b/server/routers/auth/index.ts index e9d2f5d8..2e5b3f86 100644 --- a/server/routers/auth/index.ts +++ b/server/routers/auth/index.ts @@ -1,3 +1,5 @@ export * from "./login"; export * from "./signup"; export * from "./logout"; +export * from "./verifyTotp"; +export * from "./requestTotpSecret"; diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts index 1c1f0ecb..81ede12c 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -1,5 +1,5 @@ import { verify } from "@node-rs/argon2"; -import lucia from "@server/auth"; +import lucia, { verifySession } from "@server/auth"; import db from "@server/db"; import { users } from "@server/db/schema"; import HttpCode from "@server/types/HttpCode"; @@ -42,8 +42,7 @@ export async function login( const { email, password, code } = parsedBody.data; - const sessionId = req.cookies[lucia.sessionCookieName]; - const { session: existingSession } = await lucia.validateSession(sessionId); + const { session: existingSession } = await verifySession(req); if (existingSession) { return response(res, { data: null, @@ -62,7 +61,7 @@ export async function login( return next( createHttpError( HttpCode.BAD_REQUEST, - "A user with that email address does not exist", + "Username or password is incorrect", ), ); } @@ -80,7 +79,7 @@ export async function login( return next( createHttpError( HttpCode.BAD_REQUEST, - "The password you entered is incorrect", + "Username or password is incorrect", ), ); } diff --git a/server/routers/auth/requestTotpSecret.ts b/server/routers/auth/requestTotpSecret.ts new file mode 100644 index 00000000..948454c7 --- /dev/null +++ b/server/routers/auth/requestTotpSecret.ts @@ -0,0 +1,99 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import { encodeHex } from "oslo/encoding"; +import HttpCode from "@server/types/HttpCode"; +import { verifySession, unauthorized } from "@server/auth"; +import { response } from "@server/utils"; +import { db } from "@server/db"; +import { users } from "@server/db/schema"; +import { eq } from "drizzle-orm"; +import { verify } from "@node-rs/argon2"; +import { createTOTPKeyURI } from "oslo/otp"; + +export const requestTotpSecretBody = z.object({ + password: z.string(), +}); + +export type RequestTotpSecretBody = z.infer; + +export type RequestTotpSecretResponse = { + secret: string; +}; + +export async function requestTotpSecret( + req: Request, + res: Response, + next: NextFunction, +): Promise { + const parsedBody = requestTotpSecretBody.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString(), + ), + ); + } + + const { password } = parsedBody.data; + + const { session, user } = await verifySession(req); + if (!session) { + return next(unauthorized()); + } + + const existingUser = await db + .select() + .from(users) + .where(eq(users.id, user.id)); + + if (!existingUser || !existingUser[0]) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "User does not exist"), + ); + } + + const validPassword = await verify(existingUser[0].passwordHash, password, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, + }); + if (!validPassword) { + await new Promise((resolve) => setTimeout(resolve, 250)); // delay to prevent brute force attacks + return next(unauthorized()); + } + + if (user.twoFactorEnabled) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User has already enabled two-factor authentication", + ), + ); + } + + const hex = crypto.getRandomValues(new Uint8Array(20)); + const secret = encodeHex(hex); + const uri = createTOTPKeyURI("pangolin", user.email, hex); + + await db + .update(users) + .set({ + twoFactorSecret: secret, + }) + .where(eq(users.id, user.id)); + + return response(res, { + data: { + secret: uri, + }, + success: true, + error: false, + message: "TOTP secret generated successfully", + status: HttpCode.OK, + }); +} diff --git a/server/routers/auth/verifyTotp.ts b/server/routers/auth/verifyTotp.ts index 75ad54f3..51a8fd41 100644 --- a/server/routers/auth/verifyTotp.ts +++ b/server/routers/auth/verifyTotp.ts @@ -41,20 +41,31 @@ export async function verifyTotp( const { session, user } = await verifySession(req); if (!session) { - return unauthorized(); + return next(unauthorized()); + } + + if (user.twoFactorEnabled) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Two-factor authentication is already enabled", + ), + ); } if (!user.twoFactorSecret) { - return createHttpError( - HttpCode.BAD_REQUEST, - "User has not requested two-factor authentication", + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User has not requested two-factor authentication", + ), ); } const totpController = new TOTPController(); const valid = await totpController.verify( - user.twoFactorSecret, - decodeHex(code), + code, + decodeHex(user.twoFactorSecret), ); if (valid) { diff --git a/server/routers/external.ts b/server/routers/external.ts index d116e87f..46260752 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -47,4 +47,5 @@ unauthenticated.use("/auth", authRouter); authRouter.put("/signup", auth.signup); authRouter.post("/login", auth.login); authRouter.post("/logout", auth.logout); -authRouter.post("/verify-totp", auth.logout); +authRouter.post("/verify-totp", auth.verifyTotp); +authRouter.post("/request-totp-secret", auth.requestTotpSecret);