Add utils package with Web Crypto password hashing

- Create @reviq/utils package with PBKDF2-SHA256 password hashing
  compatible with Cloudflare Workers (uses crypto.subtle)
- Update api-server and CLI to use new utils package for consistent
  password hashing format across the codebase
- Add pino logging to api-server for better request debugging
- Make login request tokens cryptographically secure base58 strings
  instead of database IDs
- Add migration to make login_requests.token non-nullable with unique
  constraint
- Fix RPCLink URL construction for client-side API calls
- Add db:codegen script to root package.json

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
RevIQ
2026-01-09 18:12:33 +08:00
parent cee700f063
commit c1afc39062
25 changed files with 512 additions and 142 deletions

View File

@@ -15,13 +15,16 @@
"dependencies": {
"@formatjs/intl-durationformat": "^0.9.2",
"@noble/hashes": "^2.0.1",
"@orpc/experimental-pino": "^1.13.2",
"@orpc/server": "^1.13.2",
"@reviq/api-contract": "workspace:*",
"@reviq/db": "workspace:*",
"@reviq/db-schema": "workspace:*",
"@reviq/utils": "workspace:*",
"@simplewebauthn/server": "^13.2.2",
"@simplewebauthn/types": "^12.0.0",
"kysely": "^0.28.2",
"pino": "^10.1.0",
"postmark": "^4.0.5",
"zxcvbn": "^4.4.2"
},
@@ -34,6 +37,7 @@
"@types/zxcvbn": "^4.4.5",
"eslint": "catalog:",
"pg": "^8.16.3",
"pino-pretty": "^13.1.3",
"typescript": "catalog:"
}
}

View File

@@ -1,6 +1,8 @@
import type { APIContext } from "./context.js";
import { LoggingHandlerPlugin } from "@orpc/experimental-pino";
import { RPCHandler } from "@orpc/server/fetch";
import { createDb } from "@reviq/db";
import pino from "pino";
import {
DEFAULT_PORT,
DEFAULT_RP_NAME,
@@ -8,8 +10,24 @@ import {
} from "./constants.js";
import { router } from "./router.js";
const logger = pino({
transport: {
target: "pino-pretty",
options: {
colorize: true,
},
},
});
const db = createDb();
const handler = new RPCHandler(router);
const handler = new RPCHandler(router, {
plugins: [
new LoggingHandlerPlugin({
logger,
logRequestResponse: true,
}),
],
});
const port = Bun.env.PORT ?? DEFAULT_PORT;
const allowedOrigins = getAllowedOrigins();
@@ -56,4 +74,4 @@ Bun.serve({
},
});
console.log("API server running on port", port);
logger.info({ port }, "API server running");

View File

@@ -11,6 +11,7 @@ import {
setCookie,
} from "../../utils/cookies.js";
import {
generateBase58Token,
generateDeviceFingerprint,
generateExpiry,
} from "../../utils/crypto.js";
@@ -59,9 +60,9 @@ export const createLoginRequest = os.auth.createLoginRequest.handler(
// User doesn't exist - return fake response for anti-enumeration
if (!user) {
// Generate placeholder token (UUID) for anti-enumeration
// Generate placeholder token (base58) for anti-enumeration
// This prevents attackers from knowing if an email exists based on response
const placeholderToken = crypto.randomUUID();
const placeholderToken = generateBase58Token();
// Set placeholder login request token cookie
setCookie(
@@ -104,14 +105,16 @@ export const createLoginRequest = os.auth.createLoginRequest.handler(
const geo = getGeoInfo(context.reqHeaders);
const userAgent = getUserAgent(context.reqHeaders);
// Create login request
// Create login request with secure token
const expiresAt = generateExpiry(COOKIE_DURATIONS.LOGIN_REQUEST);
const token = generateBase58Token();
const loginRequest = await context.db
await context.db
.insertInto("login_requests")
.values({
user_id: userId,
email,
token,
device_fingerprint: deviceFingerprint,
ip_address: geo.ip,
city: geo.city,
@@ -120,16 +123,13 @@ export const createLoginRequest = os.auth.createLoginRequest.handler(
user_agent: userAgent,
expires_at: expiresAt,
})
.returning(["id"])
.executeTakeFirstOrThrow();
.execute();
const loginRequestId = loginRequest.id;
// Set login request token cookie with the real login request ID
// Set login request token cookie with the secure token
setCookie(
context.resHeaders,
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
loginRequestId,
token,
COOKIE_OPTIONS.loginRequest,
);

View File

@@ -3,9 +3,9 @@
* Public procedure - no authentication required
*
* Flow:
* 1. Read rev.login_request_token cookie (could be real ID or fake UUID)
* 2. If fake token (not found in DB): return { status: 'pending' }
* 3. If valid login request ID:
* 1. Read rev.login_request_token cookie
* 2. If token not found in DB (fake or expired): return { status: 'pending' }
* 3. If valid login request:
* - Check if expired: return { status: 'expired' }
* - Check if not completed: return { status: 'pending' }
* - If completed:
@@ -31,15 +31,6 @@ import {
} from "../../utils/session.js";
import { os } from "../base.js";
/**
* Check if a string looks like a UUID (fake token)
*/
const isUUID = (str: string): boolean => {
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(str);
};
/**
* Login if request is completed handler
* Polls for login completion and creates session when ready
@@ -57,21 +48,7 @@ export const loginIfRequestIsCompleted =
return { status: "pending" as const };
}
// Check if it's a fake token (UUID)
if (isUUID(loginRequestToken)) {
// Fake token - user doesn't exist
// The cookie will expire naturally after 15 minutes
return { status: "pending" as const };
}
// Try to parse as login request ID
const loginRequestId = Number.parseInt(loginRequestToken, 10);
if (Number.isNaN(loginRequestId)) {
// Invalid format - treat as pending
return { status: "pending" as const };
}
// Fetch login request from database
// Fetch login request from database by token
const loginRequest = await context.db
.selectFrom("login_requests")
.select([
@@ -81,7 +58,7 @@ export const loginIfRequestIsCompleted =
"completed_at",
"expires_at",
])
.where("id", "=", String(loginRequestId))
.where("token", "=", loginRequestToken)
.executeTakeFirst();
// Login request not found - might have been deleted or invalid ID
@@ -140,7 +117,7 @@ export const loginIfRequestIsCompleted =
// Delete the login request (it's been consumed)
await context.db
.deleteFrom("login_requests")
.where("id", "=", String(loginRequestId))
.where("id", "=", loginRequest.id)
.execute();
// Set session cookie

View File

@@ -11,23 +11,13 @@ import { verifyPassword } from "../../utils/password.js";
import { isDeviceTrusted } from "../../utils/session.js";
import { os } from "../base.js";
/**
* Check if a string is a valid login request ID (numeric)
*/
const isValidLoginRequestId = (value: string): boolean => {
const num = Number(value);
return !Number.isNaN(num) && Number.isInteger(num) && num > 0;
};
/**
* Login with password handler
* - Reads login request token from cookie
* - If fake token (UUID): returns generic error for anti-enumeration
* - If valid login request ID:
* - Validates login request exists and not expired
* - Verifies password against stored hash
* - If device is trusted: marks login request as completed
* - If device is untrusted: generates confirmation token and sends email
* - Validates login request exists and not expired
* - Verifies password against stored hash
* - If device is trusted: marks login request as completed
* - If device is untrusted: generates confirmation token and sends email
*/
export const loginPassword = os.auth.loginPassword.handler(
async ({ input, context }) => {
@@ -42,25 +32,14 @@ export const loginPassword = os.auth.loginPassword.handler(
// Generic error message for anti-enumeration
const INVALID_CREDENTIALS_ERROR = "Invalid email or password";
// No login request token or invalid format
// No login request token
if (!loginRequestToken) {
throw new ORPCError("BAD_REQUEST", {
message: INVALID_CREDENTIALS_ERROR,
});
}
// Check if token is a fake token (UUID) or valid login request ID
if (!isValidLoginRequestId(loginRequestToken)) {
// Fake token - return generic error for anti-enumeration
throw new ORPCError("BAD_REQUEST", {
message: INVALID_CREDENTIALS_ERROR,
});
}
// Valid login request ID - keep as string (Int8 type)
const loginRequestId = loginRequestToken;
// Fetch login request with user data in single query (optimized JOIN)
// Fetch login request with user data by token
const result = await context.db
.selectFrom("login_requests")
.innerJoin("users", "users.id", "login_requests.user_id")
@@ -73,7 +52,7 @@ export const loginPassword = os.auth.loginPassword.handler(
"login_requests.completed_at",
"users.password_hash",
])
.where("login_requests.id", "=", loginRequestId)
.where("login_requests.token", "=", loginRequestToken)
.executeTakeFirst();
// Login request not found
@@ -124,7 +103,7 @@ export const loginPassword = os.auth.loginPassword.handler(
.set({
completed_at: new Date(),
})
.where("id", "=", loginRequestId)
.where("id", "=", result.id)
.execute();
} else {
// Device is untrusted - generate confirmation token and send email
@@ -135,10 +114,10 @@ export const loginPassword = os.auth.loginPassword.handler(
.set({
token: confirmationToken,
})
.where("id", "=", loginRequestId)
.where("id", "=", result.id)
.execute();
// Send login confirmation email (stubbed for now)
// Send login confirmation email
await sendLoginConfirmationEmail(result.email, confirmationToken);
}

View File

@@ -149,17 +149,7 @@ export const loginRequestMiddleware = os.middleware(
});
}
// Check if token is a valid login request ID (numeric)
const num = Number(loginRequestToken);
if (Number.isNaN(num) || !Number.isInteger(num) || num <= 0) {
throw new ORPCError("BAD_REQUEST", {
message: "Invalid login request",
});
}
const loginRequestId = loginRequestToken;
// Fetch login request with user data
// Fetch login request with user data by token
const result = await db
.selectFrom("login_requests")
.innerJoin("users", "users.id", "login_requests.user_id")
@@ -172,7 +162,7 @@ export const loginRequestMiddleware = os.middleware(
"users.email_verified_at",
"users.is_superuser",
])
.where("login_requests.id", "=", loginRequestId)
.where("login_requests.token", "=", loginRequestToken)
.where("login_requests.expires_at", ">", new Date())
.executeTakeFirst();

View File

@@ -40,6 +40,45 @@ export const generateSecureToken = (): string => {
.join("");
};
/**
* Base58 alphabet (Bitcoin-style, no 0, O, I, l)
*/
const BASE58_ALPHABET =
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
/**
* Generate a cryptographically secure base58 token
* Uses 24 bytes (192 bits) of entropy, producing ~33 character output
*/
export const generateBase58Token = (byteLength = 24): string => {
const bytes = new Uint8Array(byteLength);
crypto.getRandomValues(bytes);
// Convert bytes to base58
let result = "";
let num = BigInt(0);
for (const byte of bytes) {
num = num * 256n + BigInt(byte);
}
while (num > 0n) {
const remainder = Number(num % 58n);
result = BASE58_ALPHABET.charAt(remainder) + result;
num /= 58n;
}
// Handle leading zeros
for (const byte of bytes) {
if (byte === 0) {
result = BASE58_ALPHABET.charAt(0) + result;
} else {
break;
}
}
return result;
};
/**
* Generate expiration date
*/

View File

@@ -1,3 +1,7 @@
import {
hashPassword as hashPasswordUtil,
verifyPassword as verifyPasswordUtil,
} from "@reviq/utils";
import zxcvbn from "zxcvbn";
export interface PasswordValidationResult {
@@ -41,27 +45,11 @@ export const validatePassword = (
};
/**
* Hash a password using Bun's built-in argon2id
* @param password - The plaintext password to hash
* @returns The hashed password
* Hash a password using PBKDF2-SHA256 (Cloudflare Workers compatible)
*/
export const hashPassword = async (password: string): Promise<string> => {
return Bun.password.hash(password, {
algorithm: "argon2id",
memoryCost: 65536, // 64 MiB
timeCost: 3,
});
};
export const hashPassword = hashPasswordUtil;
/**
* Verify a password against a stored hash
* @param password - The plaintext password to verify
* @param hash - The stored password hash
* @returns True if the password matches the hash
*/
export const verifyPassword = async (
password: string,
hash: string,
): Promise<boolean> => {
return Bun.password.verify(password, hash);
};
export const verifyPassword = verifyPasswordUtil;