From e509bb22c10459ab778497ce03ac9eb8fb2cba2a Mon Sep 17 00:00:00 2001 From: Wroclaw Date: Thu, 9 Nov 2023 13:16:21 +0100 Subject: [PATCH 1/4] components/entryEditor: Remove unused "modelValue" property makes eslint happy about that file :) --- components/entryEditor.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/components/entryEditor.vue b/components/entryEditor.vue index 49a0651..2ab5d35 100644 --- a/components/entryEditor.vue +++ b/components/entryEditor.vue @@ -14,7 +14,6 @@ export type fieldDefinition = { const props = defineProps<{ fields: Array, - modelValue?: any, }>(); // eslint-disable-next-line func-call-spacing From 434ae5843e8e3cfa6de89435a6ab441a79e9f2cf Mon Sep 17 00:00:00 2001 From: Wroclaw Date: Thu, 9 Nov 2023 17:29:41 +0100 Subject: [PATCH 2/4] api/firstRun.post: await for user creation --- server/api/firstRun.post.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/api/firstRun.post.ts b/server/api/firstRun.post.ts index 714be3b..70aa5a8 100644 --- a/server/api/firstRun.post.ts +++ b/server/api/firstRun.post.ts @@ -24,7 +24,7 @@ export default defineEventHandler(async (e) => { if (typeof email !== "string") throw createError({ message: "email is not string", statusCode: 400 }); execSync("npx prisma db push --force-reset"); - database.user.create({ + await database.user.create({ data: { id: new Snowflake().state, username, From ebf5690519aa3d4de9cc7a3ab01fdf513a9a6447 Mon Sep 17 00:00:00 2001 From: Wroclaw Date: Thu, 9 Nov 2023 18:28:09 +0100 Subject: [PATCH 3/4] [BREAKING] Auth: replace current auth tokens with more secure ones previously tokens were only like IDs, time based and incrementing counter. An attacker could easily bruteforce them. This patch changes tokens to be completely random. fixes #2 --- schema.prisma | 10 ++++--- server/api/login.post.ts | 13 ++++---- server/middleware/auth.ts | 9 ++++-- server/utils/SessionToken.ts | 49 +++++++++++++++++++++++++++++++ server/utils/getRequestingUser.ts | 6 +++- 5 files changed, 72 insertions(+), 15 deletions(-) create mode 100644 server/utils/SessionToken.ts diff --git a/schema.prisma b/schema.prisma index a507bf7..52e3f86 100644 --- a/schema.prisma +++ b/schema.prisma @@ -20,12 +20,14 @@ model User { } model Session { - id BigInt @id @default(dbgenerated("(((unix_timestamp() * 1000) * pow(2,22)) + floor((rand() * pow(2,12))))")) @db.UnsignedBigInt - userId BigInt @map("user") @db.UnsignedBigInt - expiry_date DateTime? @default(dbgenerated("(now() + interval 30 day)")) @db.Timestamp(0) - user User @relation(fields: [userId], references: [id]) + id BigInt @id @default(dbgenerated("(((unix_timestamp() * 1000) * pow(2,22)) + floor((rand() * pow(2,12))))")) @db.UnsignedBigInt + userId BigInt @map("user") @db.UnsignedBigInt + sessionToken Bytes @db.Binary(64) + expiry_date DateTime? @default(dbgenerated("(now() + interval 30 day)")) @db.Timestamp(0) + user User @relation(fields: [userId], references: [id]) @@index([userId], map: "user_idx") + @@index([sessionToken]) @@map("sessions") } diff --git a/server/api/login.post.ts b/server/api/login.post.ts index 1b7f00d..bb7e022 100644 --- a/server/api/login.post.ts +++ b/server/api/login.post.ts @@ -4,7 +4,7 @@ import { defineEventHandler, getCookie, setCookie, readBody } from "h3"; import { database } from "../utils/database"; import { isString } from "../utils/isString"; import { cookieSettings } from "../utils/rootUtils"; -import Snowflake from "~/utils/snowflake"; +import SessionToken from "../utils/SessionToken"; import { createError } from "#imports"; @@ -40,14 +40,11 @@ export default defineEventHandler(async (e) => { if (account === null) throw createError({ statusCode: 400, message: "Invalid username or password." }); - const sessionId = new Snowflake(); + const session = new SessionToken(account.id); await database.session.create({ - data: { - id: sessionId.state, - userId: account.id, - }, + data: session.toPrisma(), }); - setCookie(e, "token", sessionId.toString(), cookieSettings); - return { message: "Login successful", token: sessionId.toString() }; + setCookie(e, "token", session.toString(), cookieSettings); + return { message: "Login successful", token: session.toString() }; }); diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index fb91a7d..e07e947 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -1,9 +1,11 @@ import { defineEventHandler, getCookie } from "h3"; -import { createError } from "#imports"; +import SessionToken from "../utils/SessionToken"; import { database } from "~/server/utils/database"; import getRequestingUser from "~/server/utils/getRequestingUser"; +import { createError } from "#imports"; + const endpointsWithoutAuth: string[] = [ "/dbtest", "/echo", @@ -37,7 +39,10 @@ export async function isAuthorised(token: string | undefined): Promise try { await database.session.findUniqueOrThrow({ where: { - id: BigInt(token), + ...SessionToken.fromString(token).toPrisma(), + expiry_date: { + gte: new Date(), + }, }, }); diff --git a/server/utils/SessionToken.ts b/server/utils/SessionToken.ts new file mode 100644 index 0000000..6a3129c --- /dev/null +++ b/server/utils/SessionToken.ts @@ -0,0 +1,49 @@ +import crypto from "node:crypto"; +import { type Session } from "@prisma/client"; + +import Snowflake from "~/utils/snowflake"; + +/** Represents a Session token, without expiry data. */ +export default class SessionToken { + userId: bigint; + sessionId: bigint; + sessionToken: Buffer; + + constructor(userId: bigint, sessionId?: bigint, sessionToken?: Buffer) { + this.userId = userId; + this.sessionId = sessionId ?? new Snowflake().state; + this.sessionToken = sessionToken ?? crypto.randomBytes(64); + } + + /** Creates SessionToken from a string. + * @param string The strinct to create from. + * @returns The SessionToken object. + */ + static fromString(string: string): SessionToken { + const parameters = string.split("."); + return new SessionToken( + Buffer.from(parameters[0], "base64").readBigUInt64LE(), + Buffer.from(parameters[1], "base64").readBigUInt64LE(), + Buffer.from(parameters[2], "base64"), + ); + } + + toString(): string { + const stringUserId = Buffer.copyBytesFrom(new BigUint64Array([this.userId])).toString("base64"); + const stringSessionId = Buffer.copyBytesFrom(new BigUint64Array([this.sessionId])).toString("base64"); + const stringSessionToken = this.sessionToken.toString("base64"); + return `${stringUserId}.${stringSessionId}.${stringSessionToken}`; + } + + /** Returns this SessionToken as Prisma object. + * For use in where parameter. + * @returns this as prisma object. + */ + toPrisma(): Omit { + return { + id: this.sessionId, + userId: this.userId, + sessionToken: this.sessionToken, + }; + } +} diff --git a/server/utils/getRequestingUser.ts b/server/utils/getRequestingUser.ts index 13ed3f9..60c4173 100644 --- a/server/utils/getRequestingUser.ts +++ b/server/utils/getRequestingUser.ts @@ -1,6 +1,7 @@ import { getCookie, H3Event } from "h3"; import { database } from "./database"; +import SessionToken from "./SessionToken"; import { createError } from "#imports"; @@ -9,7 +10,10 @@ export default async function getRequestingUser(e: H3Event) { if (!cookie) throw createError("User not found"); const { user } = await database.session.findUnique({ where: { - id: BigInt(cookie), + ...SessionToken.fromString(cookie).toPrisma(), + expiry_date: { + gte: new Date(), + }, }, select: { user: { From 1d8220d92c27802bc0b5ef858cef4ca786b9276b Mon Sep 17 00:00:00 2001 From: Wroclaw Date: Thu, 9 Nov 2023 18:48:37 +0100 Subject: [PATCH 4/4] page/firstRun: use password input for password because why password should be shown in plain text?? --- components/entryEditor.vue | 2 +- pages/firstRun.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/components/entryEditor.vue b/components/entryEditor.vue index 2ab5d35..4f2bda2 100644 --- a/components/entryEditor.vue +++ b/components/entryEditor.vue @@ -7,7 +7,7 @@ type optionalMap = Optional extends true ? undefined : string | number export type fieldDefinition = { key: string, label?: string, - type: "text" | "number", + type: "text" | "password" | "number", optional?: Optional, value?: optionalMap, } diff --git a/pages/firstRun.vue b/pages/firstRun.vue index 7e8b54d..238ef05 100644 --- a/pages/firstRun.vue +++ b/pages/firstRun.vue @@ -9,7 +9,7 @@ import Alerts, { type AlertData } from '~/components/alerts.vue'; const editorFields: Array = [ { key: "username", type: "text", label: "Username", optional: false }, - { key: "password", type: "text", label: "Password", optional: false }, + { key: "password", type: "password", label: "Password", optional: false }, { key: "email", type: "text", label: "email", optional: false }, ];