From ebf5690519aa3d4de9cc7a3ab01fdf513a9a6447 Mon Sep 17 00:00:00 2001 From: Wroclaw Date: Thu, 9 Nov 2023 18:28:09 +0100 Subject: [PATCH] [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: {