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:
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user