Compare commits

...

4 commits

Author SHA1 Message Date
1d8220d92c page/firstRun: use password input for password
All checks were successful
Build dev / build (push) Successful in 51s
because why password should be shown in plain text??
2023-11-09 18:48:37 +01:00
ebf5690519 [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
2023-11-09 18:28:09 +01:00
434ae5843e api/firstRun.post: await for user creation 2023-11-09 17:29:41 +01:00
e509bb22c1 components/entryEditor: Remove unused "modelValue" property
makes eslint happy about that file :)
2023-11-09 13:16:21 +01:00
8 changed files with 75 additions and 19 deletions

View file

@ -7,14 +7,13 @@ type optionalMap<Optional> = Optional extends true ? undefined : string | number
export type fieldDefinition<Optional extends boolean = boolean> = { export type fieldDefinition<Optional extends boolean = boolean> = {
key: string, key: string,
label?: string, label?: string,
type: "text" | "number", type: "text" | "password" | "number",
optional?: Optional, optional?: Optional,
value?: optionalMap<Optional>, value?: optionalMap<Optional>,
} }
const props = defineProps<{ const props = defineProps<{
fields: Array<fieldDefinition>, fields: Array<fieldDefinition>,
modelValue?: any,
}>(); }>();
// eslint-disable-next-line func-call-spacing // eslint-disable-next-line func-call-spacing

View file

@ -9,7 +9,7 @@ import Alerts, { type AlertData } from '~/components/alerts.vue';
const editorFields: Array<fieldDefinition> = [ const editorFields: Array<fieldDefinition> = [
{ key: "username", type: "text", label: "Username", optional: false }, { 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 }, { key: "email", type: "text", label: "email", optional: false },
]; ];

View file

@ -20,12 +20,14 @@ model User {
} }
model Session { model Session {
id BigInt @id @default(dbgenerated("(((unix_timestamp() * 1000) * pow(2,22)) + floor((rand() * pow(2,12))))")) @db.UnsignedBigInt id BigInt @id @default(dbgenerated("(((unix_timestamp() * 1000) * pow(2,22)) + floor((rand() * pow(2,12))))")) @db.UnsignedBigInt
userId BigInt @map("user") @db.UnsignedBigInt userId BigInt @map("user") @db.UnsignedBigInt
expiry_date DateTime? @default(dbgenerated("(now() + interval 30 day)")) @db.Timestamp(0) sessionToken Bytes @db.Binary(64)
user User @relation(fields: [userId], references: [id]) 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([userId], map: "user_idx")
@@index([sessionToken])
@@map("sessions") @@map("sessions")
} }

View file

@ -24,7 +24,7 @@ export default defineEventHandler(async (e) => {
if (typeof email !== "string") throw createError({ message: "email is not string", statusCode: 400 }); if (typeof email !== "string") throw createError({ message: "email is not string", statusCode: 400 });
execSync("npx prisma db push --force-reset"); execSync("npx prisma db push --force-reset");
database.user.create({ await database.user.create({
data: { data: {
id: new Snowflake().state, id: new Snowflake().state,
username, username,

View file

@ -4,7 +4,7 @@ import { defineEventHandler, getCookie, setCookie, readBody } from "h3";
import { database } from "../utils/database"; import { database } from "../utils/database";
import { isString } from "../utils/isString"; import { isString } from "../utils/isString";
import { cookieSettings } from "../utils/rootUtils"; import { cookieSettings } from "../utils/rootUtils";
import Snowflake from "~/utils/snowflake"; import SessionToken from "../utils/SessionToken";
import { createError } from "#imports"; 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." }); 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({ await database.session.create({
data: { data: session.toPrisma(),
id: sessionId.state,
userId: account.id,
},
}); });
setCookie(e, "token", sessionId.toString(), cookieSettings); setCookie(e, "token", session.toString(), cookieSettings);
return { message: "Login successful", token: sessionId.toString() }; return { message: "Login successful", token: session.toString() };
}); });

View file

@ -1,9 +1,11 @@
import { defineEventHandler, getCookie } from "h3"; import { defineEventHandler, getCookie } from "h3";
import { createError } from "#imports"; import SessionToken from "../utils/SessionToken";
import { database } from "~/server/utils/database"; import { database } from "~/server/utils/database";
import getRequestingUser from "~/server/utils/getRequestingUser"; import getRequestingUser from "~/server/utils/getRequestingUser";
import { createError } from "#imports";
const endpointsWithoutAuth: string[] = [ const endpointsWithoutAuth: string[] = [
"/dbtest", "/dbtest",
"/echo", "/echo",
@ -37,7 +39,10 @@ export async function isAuthorised(token: string | undefined): Promise<boolean>
try { try {
await database.session.findUniqueOrThrow({ await database.session.findUniqueOrThrow({
where: { where: {
id: BigInt(token), ...SessionToken.fromString(token).toPrisma(),
expiry_date: {
gte: new Date(),
},
}, },
}); });

View file

@ -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<Session, "expiry_date"> {
return {
id: this.sessionId,
userId: this.userId,
sessionToken: this.sessionToken,
};
}
}

View file

@ -1,6 +1,7 @@
import { getCookie, H3Event } from "h3"; import { getCookie, H3Event } from "h3";
import { database } from "./database"; import { database } from "./database";
import SessionToken from "./SessionToken";
import { createError } from "#imports"; import { createError } from "#imports";
@ -9,7 +10,10 @@ export default async function getRequestingUser(e: H3Event) {
if (!cookie) throw createError("User not found"); if (!cookie) throw createError("User not found");
const { user } = await database.session.findUnique({ const { user } = await database.session.findUnique({
where: { where: {
id: BigInt(cookie), ...SessionToken.fromString(cookie).toPrisma(),
expiry_date: {
gte: new Date(),
},
}, },
select: { select: {
user: { user: {