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