Merge branch 'wt2': Add auth procedures and password utilities
Integrates extracted auth handlers and Bun-based password hashing: - Auth procedures moved to individual handler files - Password hashing using Bun's argon2id (replaces scrypt) - Password validation with zxcvbn - Session, cookie, crypto, email, and geo utilities Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
109
apps/api-server/src/utils/cookies.ts
Normal file
109
apps/api-server/src/utils/cookies.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Cookie configuration for authentication
|
||||
* All cookies use 'rev.' prefix, HttpOnly, Secure, SameSite=Lax
|
||||
*/
|
||||
|
||||
export const COOKIE_NAMES = {
|
||||
SESSION_TOKEN: "rev.session_token",
|
||||
DEVICE_FINGERPRINT: "rev.device_fingerprint",
|
||||
LOGIN_REQUEST_TOKEN: "rev.login_request_token",
|
||||
} as const;
|
||||
|
||||
export const COOKIE_DURATIONS = {
|
||||
SESSION: 60 * 60 * 24 * 7, // 7 days in seconds
|
||||
DEVICE_FINGERPRINT: 60 * 60 * 24 * 365, // 1 year in seconds
|
||||
LOGIN_REQUEST: 60 * 15, // 15 minutes in seconds
|
||||
} as const;
|
||||
|
||||
export const TOKEN_DURATIONS = {
|
||||
EMAIL_VERIFICATION: 60 * 60 * 24, // 24 hours in seconds
|
||||
PASSWORD_RESET: 60 * 60, // 1 hour in seconds
|
||||
} as const;
|
||||
|
||||
// Base cookie options (all cookies share these)
|
||||
const baseCookieOptions = {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
};
|
||||
|
||||
export const COOKIE_OPTIONS = {
|
||||
session: { ...baseCookieOptions, maxAge: COOKIE_DURATIONS.SESSION },
|
||||
device: {
|
||||
...baseCookieOptions,
|
||||
maxAge: COOKIE_DURATIONS.DEVICE_FINGERPRINT,
|
||||
},
|
||||
loginRequest: {
|
||||
...baseCookieOptions,
|
||||
maxAge: COOKIE_DURATIONS.LOGIN_REQUEST,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Cookie options type for setCookie function
|
||||
*/
|
||||
export interface CookieOptions {
|
||||
httpOnly?: boolean;
|
||||
secure?: boolean;
|
||||
sameSite?: "strict" | "lax" | "none";
|
||||
path?: string;
|
||||
maxAge?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse cookie string and get a specific cookie value
|
||||
*/
|
||||
export const getCookie = (
|
||||
headers: Headers,
|
||||
name: string,
|
||||
): string | undefined => {
|
||||
const cookieHeader = headers.get("Cookie");
|
||||
if (!cookieHeader) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const cookies = cookieHeader.split(";").map((c) => c.trim());
|
||||
for (const cookie of cookies) {
|
||||
const [cookieName, ...valueParts] = cookie.split("=");
|
||||
if (cookieName === name) {
|
||||
return valueParts.join("=");
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Set a cookie in the response headers
|
||||
*/
|
||||
export const setCookie = (
|
||||
headers: Headers,
|
||||
name: string,
|
||||
value: string,
|
||||
options: CookieOptions,
|
||||
): void => {
|
||||
const parts = [`${name}=${value}`];
|
||||
if (options.httpOnly) {
|
||||
parts.push("HttpOnly");
|
||||
}
|
||||
if (options.secure) {
|
||||
parts.push("Secure");
|
||||
}
|
||||
if (options.sameSite) {
|
||||
parts.push(`SameSite=${options.sameSite}`);
|
||||
}
|
||||
if (options.path) {
|
||||
parts.push(`Path=${options.path}`);
|
||||
}
|
||||
if (options.maxAge) {
|
||||
parts.push(`Max-Age=${String(options.maxAge)}`);
|
||||
}
|
||||
headers.append("Set-Cookie", parts.join("; "));
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a cookie by setting it to expire immediately
|
||||
*/
|
||||
export const deleteCookie = (headers: Headers, name: string): void => {
|
||||
headers.append("Set-Cookie", `${name}=; Path=/; Max-Age=0`);
|
||||
};
|
||||
38
apps/api-server/src/utils/crypto.ts
Normal file
38
apps/api-server/src/utils/crypto.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Hash a token with SHA-256 for storage in database
|
||||
* Never store raw tokens - always hash first
|
||||
*/
|
||||
export const hashToken = (token: string): string => {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a session token (UUID v4)
|
||||
*/
|
||||
export const generateSessionToken = (): string => {
|
||||
return crypto.randomUUID();
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a device fingerprint (UUID v4)
|
||||
*/
|
||||
export const generateDeviceFingerprint = (): string => {
|
||||
return crypto.randomUUID();
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a secure random token for email verification, password reset, etc.
|
||||
* Uses 32 bytes (256 bits) of entropy
|
||||
*/
|
||||
export const generateSecureToken = (): string => {
|
||||
return randomBytes(32).toString("hex");
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate expiration date
|
||||
*/
|
||||
export const generateExpiry = (seconds: number): Date => {
|
||||
return new Date(Date.now() + seconds * 1000);
|
||||
};
|
||||
46
apps/api-server/src/utils/email.ts
Normal file
46
apps/api-server/src/utils/email.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Email sending utilities (stubbed for now)
|
||||
* Will be implemented in Workstream G with actual Postmark integration
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get the base URL for email links
|
||||
* Read at function call time to allow environment variable changes
|
||||
*/
|
||||
const getBaseUrl = (): string => Bun.env.APP_URL ?? "http://localhost:6827";
|
||||
|
||||
/**
|
||||
* Send verification email to user
|
||||
*/
|
||||
export async function sendVerificationEmail(
|
||||
email: string,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
const url = `${getBaseUrl()}/auth/verify?token=${token}`;
|
||||
console.log(`[EMAIL STUB] Verification email to ${email}`);
|
||||
console.log(`[EMAIL STUB] Verify link: ${url}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send login confirmation email (for untrusted device flow)
|
||||
*/
|
||||
export async function sendLoginConfirmationEmail(
|
||||
email: string,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
const url = `${getBaseUrl()}/auth/confirm?token=${token}`;
|
||||
console.log(`[EMAIL STUB] Login confirmation to ${email}`);
|
||||
console.log(`[EMAIL STUB] Confirm link: ${url}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password reset email
|
||||
*/
|
||||
export async function sendPasswordResetEmail(
|
||||
email: string,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
const url = `${getBaseUrl()}/auth/reset-password?token=${token}`;
|
||||
console.log(`[EMAIL STUB] Password reset to ${email}`);
|
||||
console.log(`[EMAIL STUB] Reset link: ${url}`);
|
||||
}
|
||||
42
apps/api-server/src/utils/geo.ts
Normal file
42
apps/api-server/src/utils/geo.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export interface GeoInfo {
|
||||
ip: string | null;
|
||||
city: string | null;
|
||||
region: string | null;
|
||||
country: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract geolocation info from request headers
|
||||
* Supports Cloudflare headers in production, falls back to standard headers
|
||||
* @param headers - Request headers
|
||||
* @returns Geolocation information extracted from headers
|
||||
*/
|
||||
export const getGeoInfo = (headers: Headers): GeoInfo => {
|
||||
// Try Cloudflare headers first (production)
|
||||
const cfIP = headers.get("CF-Connecting-IP");
|
||||
const cfCountry = headers.get("CF-IPCountry");
|
||||
const cfCity = headers.get("CF-IPCity");
|
||||
const cfRegion = headers.get("CF-Region");
|
||||
|
||||
// Fallback to X-Forwarded-For or X-Real-IP
|
||||
const forwardedFor = headers.get("X-Forwarded-For");
|
||||
const realIP = headers.get("X-Real-IP");
|
||||
|
||||
const ip = cfIP ?? realIP ?? forwardedFor?.split(",")[0]?.trim() ?? null;
|
||||
|
||||
return {
|
||||
ip,
|
||||
city: cfCity ?? null,
|
||||
region: cfRegion ?? null,
|
||||
country: cfCountry ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract User-Agent from request headers
|
||||
* @param headers - Request headers
|
||||
* @returns User-Agent string or "Unknown" if not present
|
||||
*/
|
||||
export const getUserAgent = (headers: Headers): string => {
|
||||
return headers.get("User-Agent") ?? "Unknown";
|
||||
};
|
||||
@@ -1,58 +1,67 @@
|
||||
/**
|
||||
* Password hashing utilities using scrypt from @noble/hashes
|
||||
*/
|
||||
import zxcvbn from "zxcvbn";
|
||||
|
||||
import { scrypt as nobleScrypt } from "@noble/hashes/scrypt.js";
|
||||
import { randomBytes } from "@noble/hashes/utils.js";
|
||||
|
||||
// scrypt parameters: N=2^17, r=8, p=1, dkLen=32
|
||||
const N = 131072;
|
||||
const r = 8;
|
||||
const p = 1;
|
||||
const dkLen = 32;
|
||||
export interface PasswordValidationResult {
|
||||
valid: boolean;
|
||||
feedback: string[];
|
||||
score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a password using scrypt
|
||||
* Format: scrypt$17$8$1$<salt-base64>$<hash-base64>
|
||||
* Validate password strength using zxcvbn
|
||||
* @param password - The password to validate
|
||||
* @param userInputs - User-specific inputs to penalize (email, display name)
|
||||
* @returns Validation result with feedback if invalid
|
||||
*/
|
||||
export const hashPassword = (password: string): string => {
|
||||
const salt = randomBytes(16);
|
||||
const hash = nobleScrypt(password, salt, { N, r, p, dkLen });
|
||||
return `scrypt$17$8$1$${Buffer.from(salt).toString("base64")}$${Buffer.from(hash).toString("base64")}`;
|
||||
export const validatePassword = (
|
||||
password: string,
|
||||
userInputs: string[] = [],
|
||||
): PasswordValidationResult => {
|
||||
const result = zxcvbn(password, userInputs);
|
||||
|
||||
if (result.score < 3) {
|
||||
const feedback =
|
||||
result.feedback.suggestions.length > 0
|
||||
? result.feedback.suggestions
|
||||
: [
|
||||
"Password is too weak. Try a longer phrase or add numbers and symbols.",
|
||||
];
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
feedback,
|
||||
score: result.score,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
feedback: [],
|
||||
score: result.score,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hash a password using Bun's built-in argon2id
|
||||
* @param password - The plaintext password to hash
|
||||
* @returns The hashed password
|
||||
*/
|
||||
export const hashPassword = async (password: string): Promise<string> => {
|
||||
return Bun.password.hash(password, {
|
||||
algorithm: "argon2id",
|
||||
memoryCost: 65536, // 64 MiB
|
||||
timeCost: 3,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify a password against a stored hash
|
||||
* Uses constant-time comparison to prevent timing attacks
|
||||
* @param password - The plaintext password to verify
|
||||
* @param hash - The stored password hash
|
||||
* @returns True if the password matches the hash
|
||||
*/
|
||||
export const verifyPassword = (password: string, stored: string): boolean => {
|
||||
const parts = stored.split("$");
|
||||
if (parts.length !== 5 || parts[0] !== "scrypt") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const saltStr = parts[3];
|
||||
const hashStr = parts[4];
|
||||
if (!(saltStr && hashStr)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const salt = Buffer.from(saltStr, "base64");
|
||||
const storedHash = Buffer.from(hashStr, "base64");
|
||||
const computedHash = nobleScrypt(password, salt, { N, r, p, dkLen });
|
||||
|
||||
// Constant-time comparison
|
||||
if (storedHash.length !== computedHash.length) {
|
||||
return false;
|
||||
}
|
||||
let diff = 0;
|
||||
for (let i = 0; i < storedHash.length; i++) {
|
||||
const storedByte = storedHash[i];
|
||||
const computedByte = computedHash[i];
|
||||
if (storedByte === undefined || computedByte === undefined) {
|
||||
return false;
|
||||
}
|
||||
diff |= storedByte ^ computedByte;
|
||||
}
|
||||
return diff === 0;
|
||||
export const verifyPassword = async (
|
||||
password: string,
|
||||
hash: string,
|
||||
): Promise<boolean> => {
|
||||
return Bun.password.verify(password, hash);
|
||||
};
|
||||
|
||||
112
apps/api-server/src/utils/session.ts
Normal file
112
apps/api-server/src/utils/session.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { Database } from "@reviq/db-schema";
|
||||
import type { Kysely } from "kysely";
|
||||
import type { GeoInfo } from "./geo.js";
|
||||
import { COOKIE_DURATIONS } from "./cookies.js";
|
||||
import { generateExpiry, generateSessionToken, hashToken } from "./crypto.js";
|
||||
|
||||
export interface CreateSessionOptions {
|
||||
userId: number;
|
||||
deviceId: number | null;
|
||||
trustedMode: boolean;
|
||||
geo: GeoInfo;
|
||||
userAgent: string;
|
||||
}
|
||||
|
||||
export interface SessionResult {
|
||||
token: string;
|
||||
sessionId: number;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session for a user
|
||||
* Returns the raw token (to be sent in cookie) and session details
|
||||
*/
|
||||
export async function createSession(
|
||||
db: Kysely<Database>,
|
||||
options: CreateSessionOptions,
|
||||
): Promise<SessionResult> {
|
||||
const token = generateSessionToken();
|
||||
const tokenHash = hashToken(token);
|
||||
const expiresAt = generateExpiry(COOKIE_DURATIONS.SESSION);
|
||||
|
||||
const result = await db
|
||||
.insertInto("sessions")
|
||||
.values({
|
||||
user_id: options.userId,
|
||||
device_id: options.deviceId,
|
||||
token_hash: tokenHash,
|
||||
trusted_mode: options.trustedMode,
|
||||
ip_address: options.geo.ip,
|
||||
city: options.geo.city,
|
||||
region: options.geo.region,
|
||||
country: options.geo.country,
|
||||
user_agent: options.userAgent,
|
||||
expires_at: expiresAt,
|
||||
})
|
||||
.returning(["id"])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return {
|
||||
token,
|
||||
sessionId: Number(result.id),
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a user device record
|
||||
* Creates new device if not exists, updates last_used_at if exists
|
||||
* Returns the device ID
|
||||
*/
|
||||
export async function upsertUserDevice(
|
||||
db: Kysely<Database>,
|
||||
userId: number,
|
||||
deviceFingerprint: string,
|
||||
geo: GeoInfo,
|
||||
userAgent: string,
|
||||
): Promise<number> {
|
||||
const result = await db
|
||||
.insertInto("user_devices")
|
||||
.values({
|
||||
user_id: userId,
|
||||
device_fingerprint: deviceFingerprint,
|
||||
user_agent: userAgent,
|
||||
ip_address: geo.ip,
|
||||
city: geo.city,
|
||||
region: geo.region,
|
||||
country: geo.country,
|
||||
})
|
||||
.onConflict((oc) =>
|
||||
oc.columns(["user_id", "device_fingerprint"]).doUpdateSet({
|
||||
ip_address: geo.ip,
|
||||
city: geo.city,
|
||||
region: geo.region,
|
||||
country: geo.country,
|
||||
user_agent: userAgent,
|
||||
last_used_at: new Date(),
|
||||
}),
|
||||
)
|
||||
.returning(["id"])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return Number(result.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a device is trusted for a user
|
||||
*/
|
||||
export async function isDeviceTrusted(
|
||||
db: Kysely<Database>,
|
||||
userId: number,
|
||||
deviceFingerprint: string,
|
||||
): Promise<boolean> {
|
||||
const device = await db
|
||||
.selectFrom("user_devices")
|
||||
.select(["is_trusted"])
|
||||
.where("user_id", "=", userId)
|
||||
.where("device_fingerprint", "=", deviceFingerprint)
|
||||
.executeTakeFirst();
|
||||
|
||||
return device?.is_trusted ?? false;
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import { formatPasskeyDate, parsePasskeyRow } from "./passkey-helpers.js";
|
||||
/**
|
||||
* Known authenticator AAGUIDs mapped to friendly names
|
||||
*/
|
||||
const KNOWN_AAGUIDS: Record<string, string> = {
|
||||
export const KNOWN_AAGUIDS: Record<string, string> = {
|
||||
"ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4": "Google Password Manager",
|
||||
"adce0002-35bc-c60a-648b-0b25f1f05503": "Chrome on Mac",
|
||||
"08987058-cadc-4b81-b6e1-30de50dcbe96": "Windows Hello",
|
||||
|
||||
Reference in New Issue
Block a user