Files
publisher-dashboard/apps/api-server/src/procedures/auth/signup.ts
RevIQ 319edf70db Fix IP address not being set on sessions from localhost
The extractClientIP() function only checked proxy headers (X-Forwarded-For,
CF-Connecting-IP, etc.) which don't exist when running locally without a proxy.

Changes:
- Add clientIP field to APIContext
- Use Bun's server.requestIP() to get client IP from direct socket connection
- Update getGeoInfo() to accept fallback IP parameter
- Pass context.clientIP to getGeoInfo() in auth procedures

Now sessions will have IP address set even for local development (::1 or 127.0.0.1).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 18:08:21 +08:00

286 lines
8.1 KiB
TypeScript

/**
* Signup procedure - creates a new user account with email + password or passkey
*/
import type { DB } from "@reviq/db-schema";
import type {
PublicKeyCredentialCreationOptionsJSON,
RegistrationResponseJSON,
} from "@simplewebauthn/types";
import type { Kysely } from "kysely";
import type { RPInfo } from "../../utils/webauthn.js";
import { ORPCError } from "@orpc/server";
import { verifyRegistrationResponse } from "@simplewebauthn/server";
import {
COOKIE_NAMES,
COOKIE_OPTIONS,
setCookie,
TOKEN_DURATIONS,
} from "../../utils/cookies.js";
import {
generateExpiry,
generateSecureBase58Token,
} from "../../utils/crypto.js";
import { sendVerificationEmail } from "../../utils/email.js";
import { getGeoInfo, getUserAgent } from "../../utils/geo.js";
import { hashPassword, validatePassword } from "../../utils/password.js";
import { createSession } from "../../utils/session.js";
import { getRPInfo, KNOWN_AAGUIDS } from "../../utils/webauthn.js";
import { os } from "../base.js";
/**
* Create user with password authentication
* Validates password strength and creates user record
*
* @param db - Database connection
* @param email - Normalized email address
* @param password - Plain text password to hash
* @returns User ID of created user
* @throws ORPCError if password is too weak
*/
export async function signupWithPassword(
db: Kysely<DB>,
email: string,
password: string,
): Promise<number> {
// Validate password strength
const validation = validatePassword(password, [email]);
if (!validation.valid) {
throw new ORPCError("BAD_REQUEST", { message: validation.feedback[0] });
}
// Hash password
const passwordHash = await hashPassword(password);
// Create user
const user = await db
.insertInto("users")
.values({
email,
password_hash: passwordHash,
})
.returning(["id"])
.executeTakeFirstOrThrow();
return user.id;
}
/**
* Passkey info input shape - matches contract.auth.signup input
*/
interface PasskeySignupInfo {
challengeId: number;
response: RegistrationResponseJSON;
}
/**
* Create user with passkey authentication
* Verifies WebAuthn registration and creates user + passkey records
*
* @param db - Database connection
* @param email - Normalized email address
* @param passkeyInfo - WebAuthn registration response
* @param rpInfo - Relying Party info for verification
* @returns User ID of created user
* @throws ORPCError if verification fails or challenge expired
*/
export async function signupWithPasskey(
db: Kysely<DB>,
email: string,
passkeyInfo: PasskeySignupInfo,
rpInfo: RPInfo,
): Promise<number> {
const { challengeId, response } = passkeyInfo;
// Fetch the challenge (with expiry check - challenges expire after 15 minutes)
const fifteenMinutesAgo = new Date(Date.now() - 15 * 60 * 1000);
const challengeRow = await db
.selectFrom("webauthn_challenges")
.select("options")
.where("id", "=", String(challengeId))
.where("created_at", ">", fifteenMinutesAgo)
.executeTakeFirst();
if (!challengeRow) {
throw new ORPCError("BAD_REQUEST", {
message: "Registration timed out. Please try again.",
});
}
const options =
challengeRow.options as unknown as PublicKeyCredentialCreationOptionsJSON;
// Verify the registration response
let verification: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
try {
verification = await verifyRegistrationResponse({
response,
expectedChallenge: options.challenge,
expectedOrigin: rpInfo.origins,
expectedRPID: rpInfo.rpID,
});
} catch (error) {
// Delete the challenge
await db
.deleteFrom("webauthn_challenges")
.where("id", "=", String(challengeId))
.execute();
// Log error for debugging but don't expose to client
console.error("WebAuthn registration error:", error);
throw new ORPCError("BAD_REQUEST", {
message: "Failed to register your device. Please try again.",
});
}
const { verified, registrationInfo } = verification;
if (!verified) {
// Delete the challenge
await db
.deleteFrom("webauthn_challenges")
.where("id", "=", String(challengeId))
.execute();
throw new ORPCError("BAD_REQUEST", {
message: "Unable to verify your device.",
});
}
// Create user and passkey in a transaction
const result = await db.transaction().execute(async (trx) => {
// Create user
const user = await trx
.insertInto("users")
.values({
email,
password_hash: null,
})
.returning(["id"])
.executeTakeFirstOrThrow();
const newUserId = user.id;
// Get friendly name from AAGUID
const guidName = KNOWN_AAGUIDS[registrationInfo.aaguid];
const passkeyName = guidName ?? "Default";
// Store the passkey
const { credential, credentialDeviceType, credentialBackedUp } =
registrationInfo;
await trx
.insertInto("passkeys")
.values({
user_id: newUserId,
credential_id: Buffer.from(credential.id, "base64url"),
public_key: Buffer.from(credential.publicKey),
webauthn_user_id: options.user.id,
counter: BigInt(credential.counter),
device_type: credentialDeviceType as "singleDevice" | "multiDevice",
backup_eligible: registrationInfo.credentialBackedUp,
backup_status: credentialBackedUp,
transports: JSON.stringify(response.response.transports ?? []),
rpid: rpInfo.rpID,
name: passkeyName,
})
.execute();
// Delete the challenge
await trx
.deleteFrom("webauthn_challenges")
.where("id", "=", String(challengeId))
.execute();
return { userId: newUserId };
});
return result.userId;
}
/**
* Signup handler
* - Accepts email + (password OR passkeyInfo)
* - Normalizes email to lowercase
* - Checks if email already exists (returns generic error for anti-enumeration)
* - Delegates to signupWithPassword or signupWithPasskey
* - Creates session immediately (7 days)
* - Sets rev.session_token cookie
* - Sends verification email (stubbed)
*/
export const signup = os.auth.signup.handler(async ({ input, context }) => {
const { email: rawEmail, password, passkeyInfo } = input;
// Normalize email to lowercase
const email = rawEmail.toLowerCase();
// Check if email already exists (anti-enumeration: return generic error)
const existingUser = await context.db
.selectFrom("users")
.select(["id"])
.where("email", "=", email)
.executeTakeFirst();
if (existingUser) {
throw new ORPCError("BAD_REQUEST", { message: "Unable to create account" });
}
// Get geo info and user agent for session creation
const geo = getGeoInfo(context.reqHeaders, context.clientIP);
const userAgent = getUserAgent(context.reqHeaders);
let userId: number;
// Delegate to appropriate signup function
if (password) {
userId = await signupWithPassword(context.db, email, password);
} else if (passkeyInfo) {
const rpInfo = getRPInfo(
context.origin,
context.allowedOrigins,
context.rpName,
);
userId = await signupWithPasskey(context.db, email, passkeyInfo, rpInfo);
} else {
// Should never reach here due to schema validation
throw new ORPCError("BAD_REQUEST", {
message: "Either password or passkeyInfo is required",
});
}
// Create session (7 days, trusted mode false initially, no device)
const session = await createSession(context.db, {
userId,
deviceId: null,
trustedMode: false,
geo,
userAgent,
});
// Set session cookie
setCookie(
context.resHeaders,
COOKIE_NAMES.SESSION_TOKEN,
session.token,
COOKIE_OPTIONS.session,
);
// Generate verification token
const verificationToken = generateSecureBase58Token();
const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION);
// Store verification token (store raw token, not hash - it's already high-entropy)
await context.db
.insertInto("email_verifications")
.values({
user_id: userId,
token: verificationToken,
expires_at: expiresAt,
})
.execute();
// Send verification email (stubbed)
await sendVerificationEmail(email, verificationToken);
return { success: true };
});