fix issues and add validation

This commit is contained in:
Fredrik Jensen 2026-02-07 18:05:59 +01:00
parent 60f870ece8
commit 127b19b4e1
12 changed files with 176 additions and 110 deletions

View File

@ -10,7 +10,8 @@
"Bash(pnpm db:migrate:*)", "Bash(pnpm db:migrate:*)",
"Bash(psql:*)", "Bash(psql:*)",
"Bash(pnpm test:*)", "Bash(pnpm test:*)",
"Bash(pnpm vitest run:*)" "Bash(pnpm vitest run:*)",
"Bash(npx tsc:*)"
] ]
} }
} }

View File

@ -1,4 +1,5 @@
import Fastify from "fastify"; import Fastify from "fastify";
import { client } from "./db/index.js";
import { errorHandler } from "./plugins/error-handler.js"; import { errorHandler } from "./plugins/error-handler.js";
import { authenticate } from "./plugins/authenticate.js"; import { authenticate } from "./plugins/authenticate.js";
import { accountContext } from "./plugins/account-context.js"; import { accountContext } from "./plugins/account-context.js";
@ -9,6 +10,10 @@ export function buildApp() {
logger: true, logger: true,
}); });
app.addHook("onClose", async () => {
await client.end();
});
app.register(errorHandler); app.register(errorHandler);
app.register(authenticate); app.register(authenticate);
app.register(accountContext); app.register(accountContext);

View File

@ -3,6 +3,6 @@ import postgres from "postgres";
import { config } from "../config.js"; import { config } from "../config.js";
import * as schema from "./schema.js"; import * as schema from "./schema.js";
const client = postgres(config.DATABASE_URL); export const client = postgres(config.DATABASE_URL);
export const db = drizzle(client, { schema }); export const db = drizzle(client, { schema });

View File

@ -1,4 +1,6 @@
import { boolean, integer, pgTable, text, timestamp, unique, uuid } from "drizzle-orm/pg-core"; import { boolean, integer, pgEnum, pgTable, text, timestamp, unique, uuid } from "drizzle-orm/pg-core";
export const membershipRoleEnum = pgEnum("membership_role", ["owner", "admin", "member"]);
export const users = pgTable("users", { export const users = pgTable("users", {
id: uuid().defaultRandom().primaryKey(), id: uuid().defaultRandom().primaryKey(),
@ -35,7 +37,7 @@ export const memberships = pgTable(
accountId: uuid("account_id") accountId: uuid("account_id")
.notNull() .notNull()
.references(() => accounts.id), .references(() => accounts.id),
role: text().notNull(), role: membershipRoleEnum().notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
}, },
(t) => [unique().on(t.userId, t.accountId)], (t) => [unique().on(t.userId, t.accountId)],

View File

@ -1,5 +1,5 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { eq, and, gt } from "drizzle-orm"; import { eq, and, gt, lt, sql } from "drizzle-orm";
import { db } from "../db/index.js"; import { db } from "../db/index.js";
import { otpCodes } from "../db/schema.js"; import { otpCodes } from "../db/schema.js";
@ -30,36 +30,30 @@ function sendOtp(email: string, code: string): void {
console.log(`[OTP] Code for ${email}: ${code}`); console.log(`[OTP] Code for ${email}: ${code}`);
} }
/** Validate an OTP code for the given email. Returns the email on success, throws on failure. */ /** Validate an OTP code for the given email. Throws on failure. */
export async function verifyOtp(email: string, code: string): Promise<void> { export async function verifyOtp(email: string, code: string): Promise<void> {
// Atomically increment attempts and return the row, preventing concurrent verification
const [otp] = await db const [otp] = await db
.select() .update(otpCodes)
.from(otpCodes) .set({ attempts: sql`${otpCodes.attempts} + 1` })
.where( .where(
and( and(
eq(otpCodes.email, email), eq(otpCodes.email, email),
eq(otpCodes.used, false), eq(otpCodes.used, false),
gt(otpCodes.expiresAt, new Date()), gt(otpCodes.expiresAt, new Date()),
lt(otpCodes.attempts, OTP_MAX_ATTEMPTS),
), ),
) )
.orderBy(otpCodes.createdAt) .returning();
.limit(1);
if (!otp) { if (!otp) {
throw new OtpError("Invalid or expired code"); throw new OtpError("Invalid or expired code");
} }
if (otp.attempts >= OTP_MAX_ATTEMPTS) { const codeMatch =
await db.update(otpCodes).set({ used: true }).where(eq(otpCodes.id, otp.id)); otp.code.length === code.length &&
throw new OtpError("Too many attempts. Request a new code."); crypto.timingSafeEqual(Buffer.from(otp.code), Buffer.from(code));
} if (!codeMatch) {
await db
.update(otpCodes)
.set({ attempts: otp.attempts + 1 })
.where(eq(otpCodes.id, otp.id));
if (otp.code !== code) {
throw new OtpError("Invalid or expired code"); throw new OtpError("Invalid or expired code");
} }

View File

@ -1,6 +1,7 @@
import fp from "fastify-plugin";
import type { FastifyInstance, FastifyError } from "fastify"; import type { FastifyInstance, FastifyError } from "fastify";
export async function errorHandler(app: FastifyInstance) { async function errorHandlerPlugin(app: FastifyInstance) {
app.setErrorHandler((error: FastifyError, _request, reply) => { app.setErrorHandler((error: FastifyError, _request, reply) => {
const statusCode = error.statusCode ?? 500; const statusCode = error.statusCode ?? 500;
@ -13,3 +14,5 @@ export async function errorHandler(app: FastifyInstance) {
}); });
}); });
} }
export const errorHandler = fp(errorHandlerPlugin, { name: "error-handler" });

View File

@ -12,15 +12,22 @@ export async function accountRoutes(app: FastifyInstance) {
// POST /accounts — create a new account (user becomes owner) // POST /accounts — create a new account (user becomes owner)
app.post<{ Body: { name: string } }>( app.post<{ Body: { name: string } }>(
"/accounts", "/accounts",
{ preHandler: [app.authenticate] }, {
preHandler: [app.authenticate],
schema: {
body: {
type: "object",
required: ["name"],
properties: {
name: { type: "string", minLength: 1 },
},
},
},
},
async (request, reply) => { async (request, reply) => {
const userId = request.user.sub; const userId = request.user.sub;
const { name } = request.body; const { name } = request.body;
if (!name || typeof name !== "string") {
return reply.status(400).send({ error: "Account name is required" });
}
const account = await db.transaction(async (tx) => { const account = await db.transaction(async (tx) => {
const [created] = await tx.insert(accounts).values({ name }).returning(); const [created] = await tx.insert(accounts).values({ name }).returning();
await tx.insert(memberships).values({ await tx.insert(memberships).values({

View File

@ -3,22 +3,32 @@ import { requestOtp, OtpRateLimitError } from "../lib/otp.js";
export async function loginRoutes(app: FastifyInstance) { export async function loginRoutes(app: FastifyInstance) {
// POST /login — send OTP to email // POST /login — send OTP to email
app.post<{ Body: { email: string } }>("/login", async (request, reply) => { app.post<{ Body: { email: string } }>(
const { email } = request.body; "/login",
{
schema: {
body: {
type: "object",
required: ["email"],
properties: {
email: { type: "string", format: "email" },
},
},
},
},
async (request, reply) => {
const { email } = request.body;
if (!email || typeof email !== "string") { try {
return reply.status(400).send({ error: "Email is required" }); await requestOtp(email);
} } catch (err) {
if (err instanceof OtpRateLimitError) {
try { return reply.status(429).send({ error: err.message });
await requestOtp(email); }
} catch (err) { throw err;
if (err instanceof OtpRateLimitError) {
return reply.status(429).send({ error: err.message });
} }
throw err;
}
return { message: "OTP sent to your email" }; return { message: "OTP sent to your email" };
}); },
);
} }

View File

@ -20,20 +20,27 @@ export async function meRoutes(app: FastifyInstance) {
}); });
// PATCH /me — update current user's profile (authenticated) // PATCH /me — update current user's profile (authenticated)
app.patch<{ Body: { email?: string; name?: string } }>( app.patch<{ Body: { name: string } }>(
"/me", "/me",
{ preHandler: [app.authenticate] }, {
preHandler: [app.authenticate],
schema: {
body: {
type: "object",
required: ["name"],
properties: {
name: { type: "string", minLength: 1 },
},
},
},
},
async (request, reply) => { async (request, reply) => {
const userId = request.user.sub; const userId = request.user.sub;
const { email, name } = request.body; const { name } = request.body;
if (!email && !name) {
return reply.status(400).send({ error: "Nothing to update" });
}
const [user] = await db const [user] = await db
.update(users) .update(users)
.set({ ...(email && { email }), ...(name && { name }), updatedAt: new Date() }) .set({ name, updatedAt: new Date() })
.where(eq(users.id, userId)) .where(eq(users.id, userId))
.returning(); .returning();

View File

@ -8,77 +8,98 @@ import { getUserAccounts } from "../lib/accounts.js";
export async function sessionRoutes(app: FastifyInstance) { export async function sessionRoutes(app: FastifyInstance) {
// POST /sessions — validate OTP, create session for existing user // POST /sessions — validate OTP, create session for existing user
app.post<{ Body: { email: string; code: string } }>("/", async (request, reply) => { app.post<{ Body: { email: string; code: string } }>(
const { email, code } = request.body; "/",
{
schema: {
body: {
type: "object",
required: ["email", "code"],
properties: {
email: { type: "string", format: "email" },
code: { type: "string", minLength: 6, maxLength: 6 },
},
},
},
},
async (request, reply) => {
const { email, code } = request.body;
if (!email || !code) { try {
return reply.status(400).send({ error: "Email and code are required" }); await verifyOtp(email, code);
} } catch (err) {
if (err instanceof OtpError) {
try { return reply.status(400).send({ error: err.message });
await verifyOtp(email, code); }
} catch (err) { throw err;
if (err instanceof OtpError) {
return reply.status(400).send({ error: err.message });
} }
throw err;
}
const [user] = await db.select().from(users).where(eq(users.email, email)); const [user] = await db.select().from(users).where(eq(users.email, email));
if (!user) { if (!user) {
return reply.status(401).send({ error: "No account found. Please sign up." }); return reply.status(401).send({ error: "No account found. Please sign up." });
} }
const tokens = await createSession(user.id); const tokens = await createSession(user.id);
const userAccounts = await getUserAccounts(user.id); const userAccounts = await getUserAccounts(user.id);
return { ...tokens, user, accounts: userAccounts }; return { ...tokens, user, accounts: userAccounts };
}); },
);
// POST /sessions/refresh — rotate refresh token, return new tokens // POST /sessions/refresh — rotate refresh token, return new tokens
app.post<{ Body: { refreshToken: string } }>("/refresh", async (request, reply) => { app.post<{ Body: { refreshToken: string } }>(
const { refreshToken } = request.body; "/refresh",
{
schema: {
body: {
type: "object",
required: ["refreshToken"],
properties: {
refreshToken: { type: "string", minLength: 1 },
},
},
},
},
async (request, reply) => {
const { refreshToken } = request.body;
if (!refreshToken) { const [session] = await db
return reply.status(400).send({ error: "Refresh token is required" }); .select()
} .from(sessions)
.where(eq(sessions.refreshToken, refreshToken));
const [session] = await db if (!session) {
.select() return reply.status(401).send({ error: "Invalid refresh token" });
.from(sessions) }
.where(eq(sessions.refreshToken, refreshToken));
if (!session) { // If session was already revoked, this is a reuse attempt — revoke all sessions for this user (theft detection)
return reply.status(401).send({ error: "Invalid refresh token" }); if (session.revokedAt) {
} await db
.update(sessions)
.set({ revokedAt: new Date() })
.where(and(eq(sessions.userId, session.userId), isNull(sessions.revokedAt)));
return reply.status(401).send({ error: "Token reuse detected. All sessions revoked." });
}
// If session was already revoked, this is a reuse attempt — revoke all sessions for this user (theft detection) // Check expiry
if (session.revokedAt) { if (session.expiresAt < new Date()) {
return reply.status(401).send({ error: "Refresh token expired" });
}
// Revoke old session
await db await db
.update(sessions) .update(sessions)
.set({ revokedAt: new Date() }) .set({ revokedAt: new Date() })
.where(and(eq(sessions.userId, session.userId), isNull(sessions.revokedAt))); .where(eq(sessions.id, session.id));
return reply.status(401).send({ error: "Token reuse detected. All sessions revoked." });
}
// Check expiry const tokens = await createSession(session.userId);
if (session.expiresAt < new Date()) {
return reply.status(401).send({ error: "Refresh token expired" });
}
// Revoke old session const [user] = await db.select().from(users).where(eq(users.id, session.userId));
await db const userAccounts = await getUserAccounts(session.userId);
.update(sessions)
.set({ revokedAt: new Date() })
.where(eq(sessions.id, session.id));
const tokens = await createSession(session.userId); return { ...tokens, user, accounts: userAccounts };
},
const [user] = await db.select().from(users).where(eq(users.id, session.userId)); );
const userAccounts = await getUserAccounts(session.userId);
return { ...tokens, user, accounts: userAccounts };
});
// POST /sessions/logout — revoke all sessions (authenticated) // POST /sessions/logout — revoke all sessions (authenticated)
app.post("/logout", { preHandler: [app.authenticate] }, async (request, reply) => { app.post("/logout", { preHandler: [app.authenticate] }, async (request, reply) => {

View File

@ -10,15 +10,22 @@ export async function signupRoutes(app: FastifyInstance) {
// POST /signup — validate OTP, create user + account, return tokens // POST /signup — validate OTP, create user + account, return tokens
app.post<{ Body: { email: string; code: string; accountName: string } }>( app.post<{ Body: { email: string; code: string; accountName: string } }>(
"/signup", "/signup",
{
schema: {
body: {
type: "object",
required: ["email", "code", "accountName"],
properties: {
email: { type: "string", format: "email" },
code: { type: "string", minLength: 6, maxLength: 6 },
accountName: { type: "string", minLength: 1 },
},
},
},
},
async (request, reply) => { async (request, reply) => {
const { email, code, accountName } = request.body; const { email, code, accountName } = request.body;
if (!email || !code || !accountName) {
return reply
.status(400)
.send({ error: "Email, code, and accountName are required" });
}
try { try {
await verifyOtp(email, code); await verifyOtp(email, code);
} catch (err) { } catch (err) {

View File

@ -3,6 +3,15 @@ import { config } from "./config.js";
const app = buildApp(); const app = buildApp();
const shutdown = async () => {
app.log.info("Shutting down...");
await app.close();
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
app.listen({ port: config.PORT, host: config.HOST }, (err) => { app.listen({ port: config.PORT, host: config.HOST }, (err) => {
if (err) { if (err) {
app.log.error(err); app.log.error(err);