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

@@ -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);
}