diff --git a/apps/api-server/src/__tests__/e2e/admin.test.ts b/apps/api-server/src/__tests__/e2e/admin.test.ts index bb0245b..0ee9692 100644 --- a/apps/api-server/src/__tests__/e2e/admin.test.ts +++ b/apps/api-server/src/__tests__/e2e/admin.test.ts @@ -1095,15 +1095,17 @@ describeE2E("admin", () => { expect(org?.display_name).toBe("New Organization"); // Verify owner membership - const membership = await db - .selectFrom("org_members") - .where("org_id", "=", org?.id) - .where("user_id", "=", owner.id) - .selectAll() - .executeTakeFirst(); + if (org) { + const membership = await db + .selectFrom("org_members") + .where("org_id", "=", org.id) + .where("user_id", "=", owner.id) + .selectAll() + .executeTakeFirst(); - expect(membership).toBeDefined(); - expect(membership?.role).toBe("owner"); + expect(membership).toBeDefined(); + expect(membership?.role).toBe("owner"); + } }); test("normalizes owner email to lowercase", async () => { diff --git a/apps/api-server/src/procedures/auth/forgot-password.ts b/apps/api-server/src/procedures/auth/forgot-password.ts index b3dca93..d4c2d54 100644 --- a/apps/api-server/src/procedures/auth/forgot-password.ts +++ b/apps/api-server/src/procedures/auth/forgot-password.ts @@ -30,26 +30,29 @@ export const forgotPassword = os.auth.forgotPassword.handler( // If user exists, create password reset token and send email if (user) { - // Delete any existing password reset tokens for this user (security measure) - await context.db - .deleteFrom("password_resets") - .where("user_id", "=", user.id) - .execute(); - // Generate secure base58 token const token = generateSecureBase58Token(); // Create password reset record with 1 hour expiry const expiresAt = generateExpiry(TOKEN_DURATIONS.PASSWORD_RESET); - await context.db - .insertInto("password_resets") - .values({ - user_id: user.id, - token, - expires_at: expiresAt, - }) - .execute(); + // Delete old tokens and insert new one in transaction + await context.db.transaction().execute(async (trx) => { + // Delete any existing password reset tokens for this user (security measure) + await trx + .deleteFrom("password_resets") + .where("user_id", "=", user.id) + .execute(); + + await trx + .insertInto("password_resets") + .values({ + user_id: user.id, + token, + expires_at: expiresAt, + }) + .execute(); + }); // Send password reset email await sendPasswordResetEmail({ diff --git a/apps/api-server/src/procedures/auth/login-if-completed.ts b/apps/api-server/src/procedures/auth/login-if-completed.ts index cf438e1..adf8856 100644 --- a/apps/api-server/src/procedures/auth/login-if-completed.ts +++ b/apps/api-server/src/procedures/auth/login-if-completed.ts @@ -89,36 +89,39 @@ export const loginIfRequestIsCompleted = const geo = getGeoInfo(context.reqHeaders, context.clientIP); const userAgent = getUserAgent(context.reqHeaders); - // Upsert user device - const deviceId = await upsertUserDevice( - context.db, - userId, - deviceFingerprint, - geo, - userAgent, - ); + // Create session in transaction (atomic: device upsert + session + login_request delete) + const { session, deviceTrusted } = await context.db + .transaction() + .execute(async (trx) => { + // Upsert user device + const deviceId = await upsertUserDevice( + trx, + userId, + deviceFingerprint, + geo, + userAgent, + ); - // Check if device is already trusted - const deviceTrusted = await isDeviceTrusted( - context.db, - userId, - deviceFingerprint, - ); + // Check if device is already trusted + const trusted = await isDeviceTrusted(trx, userId, deviceFingerprint); - // Create session with trusted mode = true (email-confirmed login) - const session = await createSession(context.db, { - userId, - deviceId, - trustedMode: true, - geo, - userAgent, - }); + // Create session with trusted mode = true (email-confirmed login) + const newSession = await createSession(trx, { + userId, + deviceId, + trustedMode: true, + geo, + userAgent, + }); - // Delete the login request (it's been consumed) - await context.db - .deleteFrom("login_requests") - .where("id", "=", loginRequest.id) - .execute(); + // Delete the login request (it's been consumed) + await trx + .deleteFrom("login_requests") + .where("id", "=", loginRequest.id) + .execute(); + + return { session: newSession, deviceTrusted: trusted }; + }); // Set session cookie setCookie( diff --git a/apps/api-server/src/procedures/auth/signup.ts b/apps/api-server/src/procedures/auth/signup.ts index f18d02a..ecfc1bf 100644 --- a/apps/api-server/src/procedures/auth/signup.ts +++ b/apps/api-server/src/procedures/auth/signup.ts @@ -269,13 +269,34 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => { }); } - // Create session (7 days, trusted mode false initially, no device) - const session = await createSession(context.db, { - userId, - deviceId: null, - trustedMode: false, - geo, - userAgent, + // Generate verification token + const verificationToken = generateSecureBase58Token(); + const verificationExpiresAt = generateExpiry( + TOKEN_DURATIONS.EMAIL_VERIFICATION, + ); + + // Create session and email verification in transaction + const session = await context.db.transaction().execute(async (trx) => { + // Create session (7 days, trusted mode false initially, no device) + const newSession = await createSession(trx, { + userId, + deviceId: null, + trustedMode: false, + geo, + userAgent, + }); + + // Store verification token (store raw token, not hash - it's already high-entropy) + await trx + .insertInto("email_verifications") + .values({ + user_id: userId, + token: verificationToken, + expires_at: verificationExpiresAt, + }) + .execute(); + + return newSession; }); // Set session cookie diff --git a/apps/api-server/src/utils/session.ts b/apps/api-server/src/utils/session.ts index 8473fbc..0971589 100644 --- a/apps/api-server/src/utils/session.ts +++ b/apps/api-server/src/utils/session.ts @@ -1,6 +1,11 @@ import type { Database } from "@reviq/db-schema"; -import type { Kysely } from "kysely"; +import type { Kysely, Transaction } from "kysely"; import type { GeoInfo } from "./geo.js"; +import { + isDeviceTrusted as dbIsDeviceTrusted, + upsertUserDevice as dbUpsertUserDevice, + insertSession, +} from "@reviq/db"; import { COOKIE_DURATIONS } from "./cookies.js"; import { generateExpiry, generateSessionToken, hashToken } from "./crypto.js"; @@ -23,33 +28,26 @@ export interface SessionResult { * Returns the raw token (to be sent in cookie) and session details */ export async function createSession( - db: Kysely, + db: Kysely | Transaction, options: CreateSessionOptions, ): Promise { const token = generateSessionToken(); const tokenHash = await hashToken(token); const expiresAt = generateExpiry(COOKIE_DURATIONS.SESSION); - const result = await db - .insertInto("sessions") - .values({ - user_id: options.userId, - device_id: options.deviceId, - token_hash: tokenHash, - trusted_mode: options.trustedMode, - ip_address: options.geo.ip, - city: options.geo.city, - region: options.geo.region, - country: options.geo.country, - user_agent: options.userAgent, - expires_at: expiresAt, - }) - .returning(["id"]) - .executeTakeFirstOrThrow(); + const result = await insertSession(db, { + userId: options.userId, + deviceId: options.deviceId, + tokenHash, + trustedMode: options.trustedMode, + geo: options.geo, + userAgent: options.userAgent, + expiresAt, + }); return { token, - sessionId: Number(result.id), + sessionId: result.sessionId, expiresAt, }; } @@ -60,53 +58,22 @@ export async function createSession( * Returns the device ID */ export async function upsertUserDevice( - db: Kysely, + db: Kysely | Transaction, userId: number, deviceFingerprint: string, geo: GeoInfo, userAgent: string, ): Promise { - const result = await db - .insertInto("user_devices") - .values({ - user_id: userId, - device_fingerprint: deviceFingerprint, - user_agent: userAgent, - ip_address: geo.ip, - city: geo.city, - region: geo.region, - country: geo.country, - }) - .onConflict((oc) => - oc.columns(["user_id", "device_fingerprint"]).doUpdateSet({ - ip_address: geo.ip, - city: geo.city, - region: geo.region, - country: geo.country, - user_agent: userAgent, - last_used_at: new Date(), - }), - ) - .returning(["id"]) - .executeTakeFirstOrThrow(); - - return Number(result.id); + return dbUpsertUserDevice(db, userId, deviceFingerprint, geo, userAgent); } /** * Check if a device is trusted for a user */ export async function isDeviceTrusted( - db: Kysely, + db: Kysely | Transaction, userId: number, deviceFingerprint: string, ): Promise { - const device = await db - .selectFrom("user_devices") - .select(["is_trusted"]) - .where("user_id", "=", userId) - .where("device_fingerprint", "=", deviceFingerprint) - .executeTakeFirst(); - - return device?.is_trusted ?? false; + return dbIsDeviceTrusted(db, userId, deviceFingerprint); } diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index e6cd549..7571b38 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -33,3 +33,7 @@ export { parseToken, TOKEN_PREFIX, } from "./helpers/token.js"; +/** + * Export model operations + */ +export * from "./models/index.js"; diff --git a/packages/db/src/models/index.ts b/packages/db/src/models/index.ts new file mode 100644 index 0000000..7f480b1 --- /dev/null +++ b/packages/db/src/models/index.ts @@ -0,0 +1,7 @@ +/** + * Database model operations + * Reusable database functions organized by table + */ + +export * from "./sessions.js"; +export * from "./user-devices.js"; diff --git a/packages/db/src/models/sessions.ts b/packages/db/src/models/sessions.ts new file mode 100644 index 0000000..56ce956 --- /dev/null +++ b/packages/db/src/models/sessions.ts @@ -0,0 +1,53 @@ +/** + * Database operations for sessions table + */ + +import type { Database } from "@reviq/db-schema"; +import type { Kysely, Transaction } from "kysely"; +import type { DeviceGeoInfo } from "./user-devices.js"; + +/** Options for inserting a session */ +export interface InsertSessionOptions { + userId: number; + deviceId: number | null; + tokenHash: string; + trustedMode: boolean; + geo: DeviceGeoInfo; + userAgent: string; + expiresAt: Date; +} + +/** Result of session insertion */ +export interface InsertSessionResult { + sessionId: number; +} + +/** + * Insert a new session record + * Note: Token generation and hashing should be done by the caller + */ +export async function insertSession( + db: Kysely | Transaction, + options: InsertSessionOptions, +): Promise { + const result = await db + .insertInto("sessions") + .values({ + user_id: options.userId, + device_id: options.deviceId, + token_hash: options.tokenHash, + trusted_mode: options.trustedMode, + ip_address: options.geo.ip, + city: options.geo.city, + region: options.geo.region, + country: options.geo.country, + user_agent: options.userAgent, + expires_at: options.expiresAt, + }) + .returning(["id"]) + .executeTakeFirstOrThrow(); + + return { + sessionId: Number(result.id), + }; +} diff --git a/packages/db/src/models/user-devices.ts b/packages/db/src/models/user-devices.ts new file mode 100644 index 0000000..fdd87c8 --- /dev/null +++ b/packages/db/src/models/user-devices.ts @@ -0,0 +1,71 @@ +/** + * Database operations for user_devices table + */ + +import type { Database } from "@reviq/db-schema"; +import type { Kysely, Transaction } from "kysely"; + +/** Geo information for device tracking */ +export interface DeviceGeoInfo { + ip: string | null; + city: string | null; + region: string | null; + country: string | null; +} + +/** + * Upsert a user device record + * Creates new device if not exists, updates last_used_at if exists + * @returns The device ID + */ +export async function upsertUserDevice( + db: Kysely | Transaction, + userId: number, + deviceFingerprint: string, + geo: DeviceGeoInfo, + userAgent: string, +): Promise { + const result = await db + .insertInto("user_devices") + .values({ + user_id: userId, + device_fingerprint: deviceFingerprint, + user_agent: userAgent, + ip_address: geo.ip, + city: geo.city, + region: geo.region, + country: geo.country, + }) + .onConflict((oc) => + oc.columns(["user_id", "device_fingerprint"]).doUpdateSet({ + ip_address: geo.ip, + city: geo.city, + region: geo.region, + country: geo.country, + user_agent: userAgent, + last_used_at: new Date(), + }), + ) + .returning(["id"]) + .executeTakeFirstOrThrow(); + + return Number(result.id); +} + +/** + * Check if a device is trusted for a user + */ +export async function isDeviceTrusted( + db: Kysely | Transaction, + userId: number, + deviceFingerprint: string, +): Promise { + const device = await db + .selectFrom("user_devices") + .select(["is_trusted"]) + .where("user_id", "=", userId) + .where("device_fingerprint", "=", deviceFingerprint) + .executeTakeFirst(); + + return device?.is_trusted ?? false; +}