Migrate authentication to Better Auth
Replace the hand-rolled JWT auth with Better Auth 1.6.25 httpOnly cookie sessions, validated against the database on every request so revocation, bans and role changes take effect immediately. Backend: - betterAuth.ts wires the Drizzle adapter, magic links, Google sign-in and the admin plugin; auth-schema.ts maps Better Auth's models onto the existing `users` table so user IDs and their foreign keys survive intact. - routes/auth.ts is gone; Better Auth serves the standard endpoints and authExt.ts carries the flows it doesn't cover. - auth.ts shrinks to session resolution and helpers; sessions/revocation in dashboard.ts now read and delete `auth_sessions` rows directly. - Schema adds the Better Auth core + admin columns (email_verified, image, banned, ban_reason, ban_expires), with migrations and tests. - rateLimit.ts resolves client IPs spoof-resistantly: proxy headers are only honoured from loopback/RFC1918 peers plus TRUSTED_PROXIES. - passwordPolicy.ts centralises password validation. - Bump drizzle-orm, drizzle-kit and better-sqlite3 to versions compatible with Better Auth. Frontend: - auth-client.ts plus a reworked AuthContext and api/client.ts move to cookie-based sessions; no more bearer tokens in requests or middleware. photo-api: - Validate Better Auth session cookies against the shared auth_sessions table instead of verifying JWTs; JWT_SECRET is no longer needed for user auth, and PHOTO_VIEW_SECRET now signs gallery view tokens. BETTER_AUTH_SECRET and BETTER_AUTH_URL are required in production; the deprecated JWT_SECRET stays only as the photo-api view-token fallback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4afa5d6fa0
commit
733d2459df
+92
-331
@@ -1,366 +1,127 @@
|
||||
import * as jose from 'jose';
|
||||
import * as argon2 from 'argon2';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import crypto from 'crypto';
|
||||
import { Context } from 'hono';
|
||||
import { db, dbGet, dbAll, users, magicLinkTokens, userSessions } from '../db/index.js';
|
||||
import { eq, and, gt, sql, isNull } from 'drizzle-orm';
|
||||
import { generateId, getNow, toDbDate } from './utils.js';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { auth } from './betterAuth.js';
|
||||
import { db, dbGet } from '../db/index.js';
|
||||
import { authAccounts } from '../db/auth-schema.js';
|
||||
|
||||
const DEFAULT_DEV_JWT_SECRET = 'your-super-secret-key-change-in-production';
|
||||
const rawJwtSecret = process.env.JWT_SECRET;
|
||||
// Auth is provided by Better Auth (lib/betterAuth.ts): httpOnly cookie
|
||||
// sessions validated against the auth_sessions table on every request, so
|
||||
// revocation (ban/suspend/password reset) applies instantly. This module keeps
|
||||
// the request-side helpers that the route files use.
|
||||
|
||||
// Never allow the insecure default in production: forgeable tokens = full account takeover.
|
||||
if (process.env.NODE_ENV === 'production' && (!rawJwtSecret || rawJwtSecret === DEFAULT_DEV_JWT_SECRET)) {
|
||||
throw new Error('JWT_SECRET must be set to a strong, unique value in production. Refusing to start with the default secret.');
|
||||
}
|
||||
if (!rawJwtSecret) {
|
||||
console.warn('[auth] JWT_SECRET is not set; using an insecure development default. Set JWT_SECRET in production.');
|
||||
}
|
||||
// Re-exported for routes that hash/validate passwords outside Better Auth
|
||||
export { hashPassword, verifyPassword, validatePassword } from './passwordPolicy.js';
|
||||
|
||||
const JWT_SECRET = new TextEncoder().encode(rawJwtSecret || DEFAULT_DEV_JWT_SECRET);
|
||||
const JWT_ISSUER = 'spanglish';
|
||||
const JWT_AUDIENCE = 'spanglish-app';
|
||||
|
||||
export interface JWTPayload {
|
||||
sub: string;
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
phone: string | null;
|
||||
role: string;
|
||||
tokenVersion?: number;
|
||||
iat: number;
|
||||
exp: number;
|
||||
languagePreference: string | null;
|
||||
isClaimed: boolean;
|
||||
rucNumber: string | null;
|
||||
accountStatus: string;
|
||||
emailVerified: boolean;
|
||||
image: string | null;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
/** ID of the Better Auth session backing this request. */
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
// Password hashing with Argon2 (spec requirement)
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return argon2.hash(password, {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: 65536, // 64 MB
|
||||
timeCost: 3,
|
||||
parallelism: 4,
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
// Support both bcrypt (legacy) and argon2 hashes for migration
|
||||
if (hash.startsWith('$argon2')) {
|
||||
return argon2.verify(hash, password);
|
||||
}
|
||||
// Legacy bcrypt support
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
// Generate secure random token for magic links
|
||||
export function generateSecureToken(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
// Create magic link token
|
||||
export async function createMagicLinkToken(
|
||||
userId: string,
|
||||
type: 'login' | 'reset_password' | 'claim_account' | 'email_verification',
|
||||
expiresInMinutes: number = 10
|
||||
): Promise<string> {
|
||||
const token = generateSecureToken();
|
||||
const now = getNow();
|
||||
const expiresAt = toDbDate(new Date(Date.now() + expiresInMinutes * 60 * 1000));
|
||||
|
||||
await (db as any).insert(magicLinkTokens).values({
|
||||
id: generateId(),
|
||||
userId,
|
||||
token,
|
||||
type,
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
// Verify and consume magic link token
|
||||
export async function verifyMagicLinkToken(
|
||||
token: string,
|
||||
type: 'login' | 'reset_password' | 'claim_account' | 'email_verification'
|
||||
): Promise<{ valid: boolean; userId?: string; error?: string }> {
|
||||
const now = getNow();
|
||||
|
||||
const tokenRecord = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(magicLinkTokens)
|
||||
.where(
|
||||
and(
|
||||
eq((magicLinkTokens as any).token, token),
|
||||
eq((magicLinkTokens as any).type, type)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Use a single generic error for all invalid states to avoid leaking token state
|
||||
const genericError = 'Invalid or expired token';
|
||||
|
||||
if (!tokenRecord) {
|
||||
return { valid: false, error: genericError };
|
||||
}
|
||||
|
||||
if (tokenRecord.usedAt) {
|
||||
return { valid: false, error: genericError };
|
||||
}
|
||||
|
||||
if (new Date(tokenRecord.expiresAt) < new Date()) {
|
||||
return { valid: false, error: genericError };
|
||||
}
|
||||
|
||||
// Atomically consume the token: only the request that flips used_at from NULL wins.
|
||||
// This prevents a double-spend race where two concurrent requests both pass the
|
||||
// read-time "not used" check above.
|
||||
const result: any = await (db as any)
|
||||
.update(magicLinkTokens)
|
||||
.set({ usedAt: now })
|
||||
.where(and(
|
||||
eq((magicLinkTokens as any).id, tokenRecord.id),
|
||||
isNull((magicLinkTokens as any).usedAt)
|
||||
));
|
||||
|
||||
const affected = result?.changes ?? result?.rowCount ?? 0;
|
||||
if (affected === 0) {
|
||||
return { valid: false, error: genericError };
|
||||
}
|
||||
|
||||
return { valid: true, userId: tokenRecord.userId };
|
||||
}
|
||||
|
||||
// Create user session
|
||||
export async function createUserSession(
|
||||
userId: string,
|
||||
userAgent?: string,
|
||||
ipAddress?: string
|
||||
): Promise<string> {
|
||||
const sessionToken = generateSecureToken();
|
||||
const now = getNow();
|
||||
const expiresAt = toDbDate(new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)); // 30 days
|
||||
|
||||
await (db as any).insert(userSessions).values({
|
||||
id: generateId(),
|
||||
userId,
|
||||
token: sessionToken,
|
||||
userAgent: userAgent || null,
|
||||
ipAddress: ipAddress || null,
|
||||
lastActiveAt: now,
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return sessionToken;
|
||||
}
|
||||
|
||||
// Get user's active sessions
|
||||
export async function getUserSessions(userId: string) {
|
||||
const now = getNow();
|
||||
|
||||
return dbAll(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(userSessions)
|
||||
.where(
|
||||
and(
|
||||
eq((userSessions as any).userId, userId),
|
||||
gt((userSessions as any).expiresAt, now)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Invalidate a specific session
|
||||
export async function invalidateSession(sessionId: string, userId: string): Promise<boolean> {
|
||||
const result = await (db as any)
|
||||
.delete(userSessions)
|
||||
.where(
|
||||
and(
|
||||
eq((userSessions as any).id, sessionId),
|
||||
eq((userSessions as any).userId, userId)
|
||||
)
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Invalidate all user sessions (logout everywhere)
|
||||
export async function invalidateAllUserSessions(userId: string): Promise<void> {
|
||||
await (db as any)
|
||||
.delete(userSessions)
|
||||
.where(eq((userSessions as any).userId, userId));
|
||||
}
|
||||
|
||||
// Small blocklist of common/weak passwords (and obvious app-specific ones).
|
||||
// Compared case-insensitively after stripping non-alphanumerics so that e.g.
|
||||
// "P@ssw0rd!" still matches "password".
|
||||
const COMMON_PASSWORDS = new Set([
|
||||
'password', 'passw0rd', '123456', '1234567', '12345678', '123456789', '1234567890',
|
||||
'qwerty', 'qwertyuiop', 'letmein', 'welcome', 'admin', 'administrator', 'iloveyou',
|
||||
'monkey', 'dragon', 'sunshine', 'princess', 'football', 'baseball', 'abc123',
|
||||
'spanglish', 'changeme', 'secret', 'master', 'login', 'access',
|
||||
]);
|
||||
|
||||
// Password policy: 10-128 chars, requires a mix of character types, and rejects
|
||||
// common/weak passwords. Centralized so register/reset/change all share it.
|
||||
export function validatePassword(password: string): { valid: boolean; error?: string } {
|
||||
if (password.length < 10) {
|
||||
return { valid: false, error: 'Password must be at least 10 characters long' };
|
||||
}
|
||||
if (password.length > 128) {
|
||||
return { valid: false, error: 'Password must be at most 128 characters long' };
|
||||
}
|
||||
|
||||
const hasLower = /[a-z]/.test(password);
|
||||
const hasUpper = /[A-Z]/.test(password);
|
||||
const hasDigit = /\d/.test(password);
|
||||
const hasSymbol = /[^A-Za-z0-9]/.test(password);
|
||||
|
||||
// Require lowercase, uppercase, and at least one digit or symbol.
|
||||
if (!hasLower || !hasUpper || !(hasDigit || hasSymbol)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Password must include uppercase and lowercase letters and at least one number or symbol',
|
||||
};
|
||||
}
|
||||
|
||||
const normalized = password.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
if (COMMON_PASSWORDS.has(normalized)) {
|
||||
return { valid: false, error: 'Password is too common. Please choose a less guessable password.' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
export async function createToken(userId: string, email: string, role: string, tokenVersion: number = 0): Promise<string> {
|
||||
const token = await new jose.SignJWT({ sub: userId, email, role, tokenVersion })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setIssuer(JWT_ISSUER)
|
||||
.setAudience(JWT_AUDIENCE)
|
||||
.setExpirationTime('1d')
|
||||
.sign(JWT_SECRET);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
// Invalidate all previously issued JWTs for a user (logout-everywhere, password change/reset).
|
||||
export async function bumpTokenVersion(userId: string): Promise<void> {
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set({ tokenVersion: sql`${(users as any).tokenVersion} + 1` })
|
||||
.where(eq((users as any).id, userId));
|
||||
}
|
||||
|
||||
export async function createRefreshToken(userId: string): Promise<string> {
|
||||
const token = await new jose.SignJWT({ sub: userId, type: 'refresh' })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setIssuer(JWT_ISSUER)
|
||||
.setExpirationTime('30d')
|
||||
.sign(JWT_SECRET);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function verifyToken(token: string): Promise<JWTPayload | null> {
|
||||
/**
|
||||
* Resolve the authenticated user for a request from its Better Auth session
|
||||
* cookie, or null when there is no valid session. Suspended/unclaimed/banned
|
||||
* accounts never get API access even with a live session cookie.
|
||||
*/
|
||||
export async function getAuthUser(c: Context): Promise<AuthUser | null> {
|
||||
try {
|
||||
const { payload } = await jose.jwtVerify(token, JWT_SECRET, {
|
||||
issuer: JWT_ISSUER,
|
||||
audience: JWT_AUDIENCE,
|
||||
});
|
||||
return payload as unknown as JWTPayload;
|
||||
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
||||
if (!session?.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = session.user as any;
|
||||
|
||||
// Suspended (banned) or unclaimed accounts must not retain API access
|
||||
if (user.banned) {
|
||||
return null;
|
||||
}
|
||||
if (user.accountStatus && user.accountStatus !== 'active') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
phone: user.phone ?? null,
|
||||
role: user.role ?? 'user',
|
||||
languagePreference: user.languagePreference ?? null,
|
||||
isClaimed: Boolean(user.isClaimed),
|
||||
rucNumber: user.rucNumber ?? null,
|
||||
accountStatus: user.accountStatus ?? 'active',
|
||||
emailVerified: Boolean(user.emailVerified),
|
||||
image: user.image ?? null,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
sessionId: session.session.id,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAuthUser(c: Context): Promise<any | null> {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
const payload = await verifyToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Never load the password hash into request context — it is only needed for
|
||||
// explicit password-verification routes that query it separately.
|
||||
const user = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({
|
||||
id: (users as any).id,
|
||||
email: (users as any).email,
|
||||
name: (users as any).name,
|
||||
phone: (users as any).phone,
|
||||
role: (users as any).role,
|
||||
languagePreference: (users as any).languagePreference,
|
||||
isClaimed: (users as any).isClaimed,
|
||||
googleId: (users as any).googleId,
|
||||
rucNumber: (users as any).rucNumber,
|
||||
accountStatus: (users as any).accountStatus,
|
||||
tokenVersion: (users as any).tokenVersion,
|
||||
createdAt: (users as any).createdAt,
|
||||
updatedAt: (users as any).updatedAt,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq((users as any).id, payload.sub))
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reject tokens issued before a logout-everywhere / password change
|
||||
if ((payload.tokenVersion ?? 0) !== (user.tokenVersion ?? 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Suspended/unclaimed accounts must not retain API access via an old JWT
|
||||
if (user.accountStatus && user.accountStatus !== 'active') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export function requireAuth(roles?: string[]) {
|
||||
return async (c: Context, next: () => Promise<void>) => {
|
||||
const user = await getAuthUser(c);
|
||||
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
|
||||
if (roles && !roles.includes(user.role)) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
|
||||
c.set('user', user);
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
export async function isFirstUser(): Promise<boolean> {
|
||||
const result = await dbAll(
|
||||
(db as any).select().from(users).limit(1)
|
||||
);
|
||||
return !result || result.length === 0;
|
||||
}
|
||||
|
||||
/** Fetch only the password hash column (never expose via getAuthUser). */
|
||||
/**
|
||||
* Fetch only the credential password hash (never exposed via getAuthUser).
|
||||
* Returns null when the user has no password set (Google-only or unclaimed).
|
||||
*/
|
||||
export async function getUserPasswordHash(userId: string): Promise<string | null> {
|
||||
const row = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ password: (users as any).password })
|
||||
.from(users)
|
||||
.where(eq((users as any).id, userId))
|
||||
.select({ password: (authAccounts as any).password })
|
||||
.from(authAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq((authAccounts as any).userId, userId),
|
||||
eq((authAccounts as any).providerId, 'credential')
|
||||
)
|
||||
)
|
||||
);
|
||||
const hash = row?.password;
|
||||
return hash && String(hash).length > 0 ? String(hash) : null;
|
||||
}
|
||||
|
||||
/** Whether the user has a linked Google account. */
|
||||
export async function hasGoogleAccount(userId: string): Promise<boolean> {
|
||||
const row = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ id: (authAccounts as any).id })
|
||||
.from(authAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq((authAccounts as any).userId, userId),
|
||||
eq((authAccounts as any).providerId, 'google')
|
||||
)
|
||||
)
|
||||
);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { mkdtempSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
// Environment must be pinned BEFORE the db/betterAuth singletons are imported
|
||||
// (dotenv never overrides pre-set values).
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ba-test-'));
|
||||
const dbPath = join(dir, 'test.db');
|
||||
process.env.DB_TYPE = 'sqlite';
|
||||
process.env.DATABASE_URL = dbPath;
|
||||
process.env.FRONTEND_URL = 'http://localhost:3002';
|
||||
process.env.BETTER_AUTH_SECRET = 'integration-test-secret-0123456789abcdef';
|
||||
delete process.env.REDIS_URL; // memory lockout/rate-limit backends
|
||||
delete process.env.GOOGLE_CLIENT_ID;
|
||||
|
||||
// Capture outgoing auth emails (magic links, password resets)
|
||||
const sentEmails: Array<{ to: string; subject: string; html: string }> = [];
|
||||
vi.mock('./email.js', () => ({
|
||||
sendEmail: vi.fn(async (opts: any) => {
|
||||
sentEmails.push(opts);
|
||||
}),
|
||||
emailService: {},
|
||||
default: {},
|
||||
}));
|
||||
|
||||
let auth: (typeof import('./betterAuth.js'))['auth'];
|
||||
let db: any;
|
||||
let sqlite: any;
|
||||
|
||||
function lastEmailTo(email: string) {
|
||||
const found = [...sentEmails].reverse().find((e) => e.to === email);
|
||||
expect(found, `expected an email sent to ${email}`).toBeTruthy();
|
||||
return found!;
|
||||
}
|
||||
|
||||
function extractToken(html: string, param = 'token'): string {
|
||||
const match = html.match(new RegExp(`[?&]${param}=([^"&\\s]+)`));
|
||||
expect(match, `expected a ${param} in the email link`).toBeTruthy();
|
||||
return decodeURIComponent(match![1]);
|
||||
}
|
||||
|
||||
function cookieHeaders(setCookie: string | null): Headers {
|
||||
const sessionPart = (setCookie || '')
|
||||
.split(/,(?=[^ ;]+=)/)
|
||||
.map((c) => c.split(';')[0].trim())
|
||||
.filter((c) => c.includes('session_token'))
|
||||
.join('; ');
|
||||
return new Headers({ cookie: sessionPart });
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], {
|
||||
env: { ...process.env },
|
||||
stdio: 'pipe',
|
||||
});
|
||||
return (async () => {
|
||||
({ auth } = await import('./betterAuth.js'));
|
||||
({ db } = await import('../db/index.js'));
|
||||
const Database = (await import('better-sqlite3')).default;
|
||||
sqlite = new Database(dbPath);
|
||||
})();
|
||||
}, 120_000);
|
||||
|
||||
describe('Better Auth integration', () => {
|
||||
it('makes the first registered user an admin, later users regular', async () => {
|
||||
const first = await auth.api.signUpEmail({
|
||||
body: { email: 'admin@test.py', password: 'FirstAdmin1!x', name: 'Admin' },
|
||||
});
|
||||
expect((first.user as any).id).toBeTruthy();
|
||||
|
||||
const row = sqlite.prepare('SELECT role, is_claimed, account_status FROM users WHERE email = ?').get('admin@test.py');
|
||||
expect(row.role).toBe('admin');
|
||||
expect(row.account_status).toBe('active');
|
||||
|
||||
await auth.api.signUpEmail({
|
||||
body: { email: 'user@test.py', password: 'SecondUser1!x', name: 'User' },
|
||||
});
|
||||
const row2 = sqlite.prepare('SELECT role FROM users WHERE email = ?').get('user@test.py');
|
||||
expect(row2.role).toBe('user');
|
||||
});
|
||||
|
||||
it('stores credential passwords as argon2id in auth_accounts, not users', async () => {
|
||||
const acct = sqlite
|
||||
.prepare("SELECT a.password FROM auth_accounts a JOIN users u ON u.id = a.user_id WHERE u.email = ? AND a.provider_id = 'credential'")
|
||||
.get('admin@test.py');
|
||||
expect(acct.password.startsWith('$argon2id$')).toBe(true);
|
||||
const user = sqlite.prepare('SELECT password FROM users WHERE email = ?').get('admin@test.py');
|
||||
expect(user.password).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects passwords that violate the policy', async () => {
|
||||
// Too short
|
||||
await expect(
|
||||
auth.api.signUpEmail({ body: { email: 'weak1@test.py', password: 'Short1!', name: 'W' } })
|
||||
).rejects.toThrow(/at least 10 characters/);
|
||||
|
||||
// Long enough but no character mix (app policy hook)
|
||||
await expect(
|
||||
auth.api.signUpEmail({ body: { email: 'weak2@test.py', password: 'alllowercasepw', name: 'W' } })
|
||||
).rejects.toThrow(/uppercase and lowercase/);
|
||||
|
||||
// Common password normalized (policy blocklist)
|
||||
await expect(
|
||||
auth.api.signUpEmail({ body: { email: 'weak3@test.py', password: 'Spanglish!', name: 'W' } })
|
||||
).rejects.toThrow(/too common/);
|
||||
|
||||
expect(sqlite.prepare("SELECT COUNT(*) AS n FROM users WHERE email LIKE 'weak%'").get().n).toBe(0);
|
||||
});
|
||||
|
||||
it('locks an email after 5 failed sign-ins', async () => {
|
||||
await auth.api.signUpEmail({
|
||||
body: { email: 'lockout@test.py', password: 'LockoutPass1!', name: 'L' },
|
||||
});
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await expect(
|
||||
auth.api.signInEmail({ body: { email: 'lockout@test.py', password: 'WrongPass1!x' } })
|
||||
).rejects.toThrow();
|
||||
}
|
||||
// Correct password now also refused: locked
|
||||
await expect(
|
||||
auth.api.signInEmail({ body: { email: 'lockout@test.py', password: 'LockoutPass1!' } })
|
||||
).rejects.toThrow(/Too many login attempts/);
|
||||
});
|
||||
|
||||
it('refuses sign-in for banned (suspended) users and kills nothing else', async () => {
|
||||
await auth.api.signUpEmail({
|
||||
body: { email: 'banned@test.py', password: 'BannedPass1!x', name: 'B' },
|
||||
});
|
||||
sqlite.prepare("UPDATE users SET banned = 1, account_status = 'suspended' WHERE email = ?").run('banned@test.py');
|
||||
await expect(
|
||||
auth.api.signInEmail({ body: { email: 'banned@test.py', password: 'BannedPass1!x' } })
|
||||
).rejects.toThrow(/suspended|banned/i);
|
||||
});
|
||||
|
||||
it('verifies legacy bcrypt hashes and upgrades them to argon2 on sign-in', async () => {
|
||||
const bcrypt = (await import('bcryptjs')).default;
|
||||
const legacyHash = bcrypt.hashSync('LegacyBcrypt1!', 10);
|
||||
const su = await auth.api.signUpEmail({
|
||||
body: { email: 'legacy@test.py', password: 'TempPass123!x', name: 'Legacy' },
|
||||
});
|
||||
sqlite
|
||||
.prepare("UPDATE auth_accounts SET password = ? WHERE user_id = ? AND provider_id = 'credential'")
|
||||
.run(legacyHash, (su.user as any).id);
|
||||
|
||||
const si = await auth.api.signInEmail({
|
||||
body: { email: 'legacy@test.py', password: 'LegacyBcrypt1!' },
|
||||
});
|
||||
expect(si.user.email).toBe('legacy@test.py');
|
||||
|
||||
// Upgrade happens in the after-hook; poll briefly for it
|
||||
let upgraded = '';
|
||||
for (let i = 0; i < 20 && !upgraded.startsWith('$argon2'); i++) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
upgraded = sqlite
|
||||
.prepare("SELECT password FROM auth_accounts WHERE user_id = ? AND provider_id = 'credential'")
|
||||
.get((su.user as any).id).password;
|
||||
}
|
||||
expect(upgraded.startsWith('$argon2id$')).toBe(true);
|
||||
|
||||
// And the upgraded hash still verifies
|
||||
const again = await auth.api.signInEmail({
|
||||
body: { email: 'legacy@test.py', password: 'LegacyBcrypt1!' },
|
||||
});
|
||||
expect(again.user.email).toBe('legacy@test.py');
|
||||
});
|
||||
|
||||
it('magic link signs in existing users but never creates accounts', async () => {
|
||||
await auth.api.signUpEmail({
|
||||
body: { email: 'magic@test.py', password: 'MagicPass12!x', name: 'M' },
|
||||
});
|
||||
await auth.api.signInMagicLink({
|
||||
body: { email: 'magic@test.py', callbackURL: '/dashboard' },
|
||||
headers: new Headers(),
|
||||
});
|
||||
const email = lastEmailTo('magic@test.py');
|
||||
// Emails link to the frontend page, not the raw API endpoint
|
||||
expect(email.html).toContain('http://localhost:3002/auth/magic-link?token=');
|
||||
const token = extractToken(email.html);
|
||||
|
||||
const verified = await auth.api.magicLinkVerify({
|
||||
query: { token },
|
||||
headers: new Headers(),
|
||||
});
|
||||
expect((verified as any).user?.email ?? (verified as any).session?.userId).toBeTruthy();
|
||||
|
||||
// Unknown email: enumeration-safe success (an email may still go out),
|
||||
// but verification can never create an account (disableSignUp)
|
||||
const ghost = await auth.api.signInMagicLink({
|
||||
body: { email: 'ghost@test.py' },
|
||||
headers: new Headers(),
|
||||
});
|
||||
expect((ghost as any).status).toBe(true);
|
||||
const ghostEmail = sentEmails.filter((e) => e.to === 'ghost@test.py').pop();
|
||||
if (ghostEmail) {
|
||||
const ghostToken = extractToken(ghostEmail.html);
|
||||
await expect(
|
||||
auth.api.magicLinkVerify({ query: { token: ghostToken }, headers: new Headers() })
|
||||
).rejects.toThrow();
|
||||
}
|
||||
expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('ghost@test.py').n).toBe(0);
|
||||
});
|
||||
|
||||
it('completes the guest claim path: magic link session + setPassword', async () => {
|
||||
// Simulate tickets.ts guest creation: user row, no credential account
|
||||
const guestId = 'guest-claim-user-000001';
|
||||
const now = new Date().toISOString();
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO users (id, email, password, name, role, is_claimed, account_status, email_verified, banned, token_version, created_at, updated_at)
|
||||
VALUES (?, ?, NULL, 'Guest', 'user', 0, 'unclaimed', 0, 0, 0, ?, ?)`
|
||||
)
|
||||
.run(guestId, 'guest@test.py', now, now);
|
||||
|
||||
await auth.api.signInMagicLink({
|
||||
body: { email: 'guest@test.py', callbackURL: '/auth/claim-account' },
|
||||
headers: new Headers(),
|
||||
});
|
||||
const token = extractToken(lastEmailTo('guest@test.py').html);
|
||||
const verified = await auth.api.magicLinkVerify({
|
||||
query: { token },
|
||||
returnHeaders: true,
|
||||
headers: new Headers(),
|
||||
});
|
||||
const headers = cookieHeaders(verified.headers.get('set-cookie'));
|
||||
|
||||
// The session works even while unclaimed (the claim endpoint depends on this)
|
||||
const session = await auth.api.getSession({ headers });
|
||||
expect((session?.user as any)?.accountStatus).toBe('unclaimed');
|
||||
|
||||
// Set the password (what /api/auth-ext/claim-account does)
|
||||
await auth.api.setPassword({ body: { newPassword: 'ClaimedPass1!x' }, headers });
|
||||
const acct = sqlite
|
||||
.prepare("SELECT password FROM auth_accounts WHERE user_id = ? AND provider_id = 'credential'")
|
||||
.get(guestId);
|
||||
expect(acct.password.startsWith('$argon2id$')).toBe(true);
|
||||
|
||||
// The claim email uses the claim template with the frontend link
|
||||
const claimEmail = lastEmailTo('guest@test.py');
|
||||
expect(claimEmail.subject).toContain('Claim');
|
||||
expect(claimEmail.html).toContain('callbackURL=%2Fauth%2Fclaim-account');
|
||||
});
|
||||
|
||||
it('password reset revokes existing sessions and applies the new password', async () => {
|
||||
const su = await auth.api.signUpEmail({
|
||||
body: { email: 'reset@test.py', password: 'BeforeReset1!x', name: 'R' },
|
||||
});
|
||||
const userId = (su.user as any).id;
|
||||
// A live session from sign-in
|
||||
await auth.api.signInEmail({ body: { email: 'reset@test.py', password: 'BeforeReset1!x' } });
|
||||
expect(
|
||||
sqlite.prepare('SELECT COUNT(*) AS n FROM auth_sessions WHERE user_id = ?').get(userId).n
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
await auth.api.requestPasswordReset({
|
||||
body: { email: 'reset@test.py', redirectTo: '/auth/reset-password' },
|
||||
});
|
||||
const email = lastEmailTo('reset@test.py');
|
||||
// Reset URLs are either .../reset-password/{token}?... or ...?token={token}
|
||||
const html = email.html;
|
||||
const pathMatch = html.match(/reset-password\/([^?"&\s]+)/);
|
||||
const token = pathMatch ? decodeURIComponent(pathMatch[1]) : extractToken(html);
|
||||
|
||||
await auth.api.resetPassword({ body: { newPassword: 'AfterReset1!x', token } });
|
||||
|
||||
// revokeSessionsOnPasswordReset: true
|
||||
expect(
|
||||
sqlite.prepare('SELECT COUNT(*) AS n FROM auth_sessions WHERE user_id = ?').get(userId).n
|
||||
).toBe(0);
|
||||
|
||||
await expect(
|
||||
auth.api.signInEmail({ body: { email: 'reset@test.py', password: 'BeforeReset1!x' } })
|
||||
).rejects.toThrow();
|
||||
const after = await auth.api.signInEmail({
|
||||
body: { email: 'reset@test.py', password: 'AfterReset1!x' },
|
||||
});
|
||||
expect(after.user.email).toBe('reset@test.py');
|
||||
});
|
||||
|
||||
it('sessions are stored in auth_sessions with 7-day expiry', async () => {
|
||||
const si = await auth.api.signInEmail({
|
||||
body: { email: 'admin@test.py', password: 'FirstAdmin1!x' },
|
||||
});
|
||||
const row = sqlite
|
||||
.prepare('SELECT expires_at FROM auth_sessions WHERE token = ?')
|
||||
.get((si as any).token);
|
||||
expect(row).toBeTruthy();
|
||||
const days = (row.expires_at - Date.now()) / (1000 * 60 * 60 * 24);
|
||||
expect(days).toBeGreaterThan(6.5);
|
||||
expect(days).toBeLessThan(7.5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||
import { magicLink, admin } from 'better-auth/plugins';
|
||||
import { APIError, createAuthMiddleware } from 'better-auth/api';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { db, dbGet, dbAll, isPostgres } from '../db/index.js';
|
||||
import {
|
||||
authUsers,
|
||||
authSessions,
|
||||
authAccounts,
|
||||
authVerifications,
|
||||
authRateLimits,
|
||||
} from '../db/auth-schema.js';
|
||||
import { generateId } from './utils.js';
|
||||
import { hashPassword, verifyPassword, validatePassword } from './passwordPolicy.js';
|
||||
import { sendEmail } from './email.js';
|
||||
import { getLoginLockout } from './stores/loginLockout.js';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
|
||||
|
||||
const DEFAULT_DEV_SECRET = 'spanglish-dev-only-better-auth-secret';
|
||||
const rawSecret = process.env.BETTER_AUTH_SECRET;
|
||||
|
||||
// Never allow a weak/default secret in production: the secret signs session
|
||||
// cookies, so a guessable value means forgeable sessions = account takeover.
|
||||
if (isProduction && (!rawSecret || rawSecret.length < 32 || rawSecret === DEFAULT_DEV_SECRET)) {
|
||||
throw new Error(
|
||||
'BETTER_AUTH_SECRET must be set to a strong value (32+ characters) in production. Refusing to start.'
|
||||
);
|
||||
}
|
||||
if (!rawSecret) {
|
||||
console.warn('[auth] BETTER_AUTH_SECRET is not set; using an insecure development default.');
|
||||
}
|
||||
|
||||
// The site origin plus its www/non-www alias, mirroring the CORS allowlist in
|
||||
// index.ts. Requests whose Origin is not listed here are rejected by Better
|
||||
// Auth's CSRF origin check.
|
||||
function computeTrustedOrigins(): string[] {
|
||||
const origins = new Set<string>([frontendUrl]);
|
||||
try {
|
||||
const url = new URL(frontendUrl);
|
||||
const alias = url.hostname.startsWith('www.')
|
||||
? url.hostname.slice(4)
|
||||
: `www.${url.hostname}`;
|
||||
origins.add(`${url.protocol}//${alias}${url.port ? `:${url.port}` : ''}`);
|
||||
} catch {
|
||||
/* keep frontendUrl as-is */
|
||||
}
|
||||
if (process.env.API_URL) origins.add(process.env.API_URL);
|
||||
return [...origins];
|
||||
}
|
||||
|
||||
// Paths whose request body carries a new password that must satisfy the policy
|
||||
// (Better Auth's minPasswordLength alone is weaker than the app's policy).
|
||||
const PASSWORD_SETTING_PATHS = new Set([
|
||||
'/sign-up/email',
|
||||
'/reset-password',
|
||||
'/change-password',
|
||||
'/set-password',
|
||||
]);
|
||||
|
||||
async function getCredentialAccount(userId: string): Promise<any | null> {
|
||||
return dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(authAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq((authAccounts as any).userId, userId),
|
||||
eq((authAccounts as any).providerId, 'credential')
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export const auth = betterAuth({
|
||||
appName: 'Spanglish',
|
||||
baseURL: process.env.BETTER_AUTH_URL || frontendUrl,
|
||||
basePath: '/api/auth',
|
||||
secret: rawSecret || DEFAULT_DEV_SECRET,
|
||||
trustedOrigins: computeTrustedOrigins(),
|
||||
telemetry: { enabled: false },
|
||||
|
||||
database: drizzleAdapter(db as any, {
|
||||
provider: isPostgres() ? 'pg' : 'sqlite',
|
||||
schema: {
|
||||
user: authUsers,
|
||||
session: authSessions,
|
||||
account: authAccounts,
|
||||
verification: authVerifications,
|
||||
rateLimit: authRateLimits,
|
||||
},
|
||||
// better-sqlite3 cannot run Drizzle's async transactions; operations run
|
||||
// sequentially instead (also the adapter default).
|
||||
transaction: false,
|
||||
}),
|
||||
|
||||
advanced: {
|
||||
cookiePrefix: 'spanglish',
|
||||
useSecureCookies: isProduction,
|
||||
ipAddress: {
|
||||
// Set by the /api/auth/* mount in index.ts from getClientIp(), which
|
||||
// anchors trust in the TCP peer address (only our own proxies may speak
|
||||
// for the client via X-Real-IP / X-Forwarded-For). Never read the raw
|
||||
// forwarded headers here: without the socket they are spoofable, and
|
||||
// Better Auth would fall back to one shared rate-limit bucket.
|
||||
ipAddressHeaders: ['x-client-ip'],
|
||||
},
|
||||
database: {
|
||||
// Match the app's existing ID convention (uuid on pg, nanoid on sqlite)
|
||||
// so Better Auth rows are indistinguishable from legacy rows.
|
||||
generateId: () => generateId(),
|
||||
},
|
||||
},
|
||||
|
||||
user: {
|
||||
additionalFields: {
|
||||
// `role`, `banned`, `banReason`, `banExpires` come from the admin plugin.
|
||||
phone: { type: 'string', required: false, input: true },
|
||||
languagePreference: { type: 'string', required: false, input: true },
|
||||
rucNumber: { type: 'string', required: false, input: false },
|
||||
isClaimed: { type: 'boolean', required: false, input: false, defaultValue: true },
|
||||
accountStatus: { type: 'string', required: false, input: false, defaultValue: 'active' },
|
||||
},
|
||||
},
|
||||
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days, rolling
|
||||
updateAge: 60 * 60 * 24, // refresh expiry at most once a day
|
||||
freshAge: 60 * 60 * 24, // sensitive operations require a session younger than this
|
||||
// Disabled deliberately: every request validates against the session table,
|
||||
// so ban/suspend/password-reset revocations apply instantly — and the Go
|
||||
// photo-api (which reads the same table) can never disagree with us.
|
||||
cookieCache: { enabled: false },
|
||||
storeSessionInDatabase: true,
|
||||
},
|
||||
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
minPasswordLength: 10,
|
||||
maxPasswordLength: 128,
|
||||
autoSignIn: true,
|
||||
requireEmailVerification: false,
|
||||
revokeSessionsOnPasswordReset: true,
|
||||
resetPasswordTokenExpiresIn: 60 * 30, // 30 minutes, matches the legacy flow
|
||||
sendResetPassword: async ({ user, url }) => {
|
||||
try {
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: 'Reset Your Spanglish Password',
|
||||
html: `
|
||||
<h2>Reset Your Password</h2>
|
||||
<p>Click the link below to reset your password. This link expires in 30 minutes.</p>
|
||||
<p><a href="${url}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Reset Password</a></p>
|
||||
<p>Or copy this link: ${url}</p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to send password reset email:', error);
|
||||
}
|
||||
},
|
||||
password: {
|
||||
// Keep the existing argon2id parameters; legacy bcrypt hashes (migrated
|
||||
// into auth_accounts) still verify and are upgraded on login below.
|
||||
hash: (password) => hashPassword(password),
|
||||
verify: ({ hash, password }) => verifyPassword(password, hash),
|
||||
},
|
||||
},
|
||||
|
||||
...(process.env.GOOGLE_CLIENT_ID
|
||||
? {
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID,
|
||||
// Not required for ID-token (Google Identity Services) sign-in,
|
||||
// only for the redirect OAuth flow.
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
account: {
|
||||
accountLinking: {
|
||||
enabled: true,
|
||||
// Google verifies email ownership, so linking by email is safe — this
|
||||
// matches the legacy /api/auth/google auto-link behavior.
|
||||
trustedProviders: ['google'],
|
||||
},
|
||||
},
|
||||
|
||||
rateLimit: {
|
||||
// Explicitly enabled so dev behaves like production (off in dev by default).
|
||||
enabled: true,
|
||||
window: 60,
|
||||
max: 100,
|
||||
// DB-backed rather than Redis: our Redis layer is fail-open by design,
|
||||
// which is the wrong default for auth rate limiting. The per-email login
|
||||
// lockout below is already Redis-shared across replicas.
|
||||
storage: 'database',
|
||||
modelName: 'rateLimit',
|
||||
customRules: {
|
||||
'/sign-in/email': { window: 900, max: 10 },
|
||||
'/sign-up/email': { window: 900, max: 10 },
|
||||
'/sign-in/magic-link': { window: 900, max: 5 },
|
||||
'/magic-link/verify': { window: 900, max: 30 },
|
||||
'/request-password-reset': { window: 900, max: 5 },
|
||||
'/reset-password': { window: 900, max: 10 },
|
||||
'/sign-in/social': { window: 900, max: 20 },
|
||||
'/change-password': { window: 900, max: 10 },
|
||||
},
|
||||
},
|
||||
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
before: async (user) => {
|
||||
// First user to register becomes admin (replaces isFirstUser())
|
||||
const existing = await dbAll<any>((db as any).select().from(authUsers).limit(1));
|
||||
if (!existing || existing.length === 0) {
|
||||
return { data: { ...user, role: 'admin' } };
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
hooks: {
|
||||
before: createAuthMiddleware(async (ctx) => {
|
||||
// Enforce the full password policy (character classes + blocklist) on
|
||||
// every password-setting path, for both client and server-side calls.
|
||||
if (PASSWORD_SETTING_PATHS.has(ctx.path)) {
|
||||
const password = ctx.body?.password ?? ctx.body?.newPassword;
|
||||
if (typeof password === 'string') {
|
||||
const result = validatePassword(password);
|
||||
if (!result.valid) {
|
||||
throw new APIError('BAD_REQUEST', { message: result.error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-email lockout: 5 failures / 15 min, Redis-shared when configured.
|
||||
// Kept as defense-in-depth on top of Better Auth's per-IP rate limits.
|
||||
if (ctx.path === '/sign-in/email' && typeof ctx.body?.email === 'string') {
|
||||
const lockout = await getLoginLockout().isLocked(ctx.body.email);
|
||||
if (lockout.locked) {
|
||||
throw new APIError('TOO_MANY_REQUESTS', {
|
||||
message: 'Too many login attempts. Please try again later.',
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
after: createAuthMiddleware(async (ctx) => {
|
||||
if (ctx.path !== '/sign-in/email' || typeof ctx.body?.email !== 'string') return;
|
||||
const email = ctx.body.email as string;
|
||||
|
||||
if (ctx.context.returned instanceof APIError) {
|
||||
// Failed sign-in attempt counts toward the per-email lockout
|
||||
await getLoginLockout().recordFailure(email);
|
||||
return;
|
||||
}
|
||||
|
||||
await getLoginLockout().clear(email);
|
||||
|
||||
// Transparently upgrade legacy bcrypt hashes to argon2 now that we have
|
||||
// the verified plaintext. Best-effort: never block the login.
|
||||
try {
|
||||
const password = ctx.body?.password;
|
||||
const userId = (ctx.context.newSession?.user as any)?.id;
|
||||
if (typeof password === 'string' && userId) {
|
||||
const account = await getCredentialAccount(userId);
|
||||
if (account?.password && !String(account.password).startsWith('$argon2')) {
|
||||
const upgraded = await hashPassword(password);
|
||||
await (db as any)
|
||||
.update(authAccounts)
|
||||
.set({ password: upgraded, updatedAt: new Date() })
|
||||
.where(eq((authAccounts as any).id, account.id));
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[auth] Failed to upgrade legacy password hash:', err?.message || err);
|
||||
}
|
||||
}),
|
||||
},
|
||||
|
||||
plugins: [
|
||||
magicLink({
|
||||
expiresIn: 60 * 10, // 10 minutes, matches the legacy flow
|
||||
// Magic links never create accounts (parity with the legacy behavior;
|
||||
// account creation is register / Google / guest booking only).
|
||||
disableSignUp: true,
|
||||
// Hashed at rest: a leaked verification table cannot be replayed.
|
||||
storeToken: 'hashed',
|
||||
sendMagicLink: async ({ email, url, token }) => {
|
||||
// Email a frontend URL (not the raw API verify URL) so the login
|
||||
// completes on the site, preserving the legacy UX. The page calls
|
||||
// authClient.magicLink.verify with the token.
|
||||
let callbackURL = '/';
|
||||
try {
|
||||
callbackURL = new URL(url).searchParams.get('callbackURL') || '/';
|
||||
} catch {
|
||||
/* default */
|
||||
}
|
||||
const link = `${frontendUrl}/auth/magic-link?token=${encodeURIComponent(token)}&callbackURL=${encodeURIComponent(callbackURL)}`;
|
||||
const isClaim = callbackURL.startsWith('/auth/claim-account');
|
||||
try {
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: isClaim ? 'Claim Your Spanglish Account' : 'Your Spanglish Login Link',
|
||||
html: isClaim
|
||||
? `
|
||||
<h2>Claim Your Account</h2>
|
||||
<p>An account was created for you during booking. Click below to set up your login credentials. This link expires in 10 minutes.</p>
|
||||
<p><a href="${link}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Claim Account</a></p>
|
||||
<p>Or copy this link: ${link}</p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
`
|
||||
: `
|
||||
<h2>Login to Spanglish</h2>
|
||||
<p>Click the link below to log in. This link expires in 10 minutes.</p>
|
||||
<p><a href="${link}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Log In</a></p>
|
||||
<p>Or copy this link: ${link}</p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to send magic link email:', error);
|
||||
}
|
||||
},
|
||||
}),
|
||||
admin({
|
||||
defaultRole: 'user',
|
||||
adminRoles: ['admin'],
|
||||
bannedUserMessage: 'Account is suspended. Please contact support.',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export type Auth = typeof auth;
|
||||
@@ -0,0 +1,62 @@
|
||||
import * as argon2 from 'argon2';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
// Password hashing with Argon2 (spec requirement)
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return argon2.hash(password, {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: 65536, // 64 MB
|
||||
timeCost: 3,
|
||||
parallelism: 4,
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
// Support both bcrypt (legacy) and argon2 hashes for migration
|
||||
if (hash.startsWith('$argon2')) {
|
||||
return argon2.verify(hash, password);
|
||||
}
|
||||
// Legacy bcrypt support
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
// Small blocklist of common/weak passwords (and obvious app-specific ones).
|
||||
// Compared case-insensitively after stripping non-alphanumerics so that e.g.
|
||||
// "P@ssw0rd!" still matches "password".
|
||||
const COMMON_PASSWORDS = new Set([
|
||||
'password', 'passw0rd', '123456', '1234567', '12345678', '123456789', '1234567890',
|
||||
'qwerty', 'qwertyuiop', 'letmein', 'welcome', 'admin', 'administrator', 'iloveyou',
|
||||
'monkey', 'dragon', 'sunshine', 'princess', 'football', 'baseball', 'abc123',
|
||||
'spanglish', 'changeme', 'secret', 'master', 'login', 'access',
|
||||
]);
|
||||
|
||||
// Password policy: 10-128 chars, requires a mix of character types, and rejects
|
||||
// common/weak passwords. Centralized so register/reset/change all share it.
|
||||
export function validatePassword(password: string): { valid: boolean; error?: string } {
|
||||
if (password.length < 10) {
|
||||
return { valid: false, error: 'Password must be at least 10 characters long' };
|
||||
}
|
||||
if (password.length > 128) {
|
||||
return { valid: false, error: 'Password must be at most 128 characters long' };
|
||||
}
|
||||
|
||||
const hasLower = /[a-z]/.test(password);
|
||||
const hasUpper = /[A-Z]/.test(password);
|
||||
const hasDigit = /\d/.test(password);
|
||||
const hasSymbol = /[^A-Za-z0-9]/.test(password);
|
||||
|
||||
// Require lowercase, uppercase, and at least one digit or symbol.
|
||||
if (!hasLower || !hasUpper || !(hasDigit || hasSymbol)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Password must include uppercase and lowercase letters and at least one number or symbol',
|
||||
};
|
||||
}
|
||||
|
||||
const normalized = password.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
if (COMMON_PASSWORDS.has(normalized)) {
|
||||
return { valid: false, error: 'Password is too common. Please choose a less guessable password.' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getClientIp, isTrustedProxyIp } from './rateLimit.js';
|
||||
|
||||
// Minimal Hono-Context stand-in: headers + the node-server env with the
|
||||
// socket peer address.
|
||||
function fakeContext(opts: { peer?: string; headers?: Record<string, string> }) {
|
||||
const headers = new Map(
|
||||
Object.entries(opts.headers || {}).map(([k, v]) => [k.toLowerCase(), v])
|
||||
);
|
||||
return {
|
||||
req: { header: (name: string) => headers.get(name.toLowerCase()) },
|
||||
env: opts.peer ? { incoming: { socket: { remoteAddress: opts.peer } } } : {},
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('isTrustedProxyIp', () => {
|
||||
it('trusts loopback and private ranges, including IPv4-mapped IPv6', () => {
|
||||
expect(isTrustedProxyIp('127.0.0.1')).toBe(true);
|
||||
expect(isTrustedProxyIp('::1')).toBe(true);
|
||||
expect(isTrustedProxyIp('::ffff:127.0.0.1')).toBe(true);
|
||||
expect(isTrustedProxyIp('10.1.2.3')).toBe(true);
|
||||
expect(isTrustedProxyIp('172.18.0.5')).toBe(true);
|
||||
expect(isTrustedProxyIp('192.168.1.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not trust public addresses or near-miss ranges', () => {
|
||||
expect(isTrustedProxyIp('203.0.113.7')).toBe(false);
|
||||
expect(isTrustedProxyIp('172.15.0.1')).toBe(false); // outside 172.16/12
|
||||
expect(isTrustedProxyIp('172.32.0.1')).toBe(false);
|
||||
expect(isTrustedProxyIp('1270.0.0.1')).toBe(false);
|
||||
expect(isTrustedProxyIp('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getClientIp', () => {
|
||||
it('prefers X-Real-IP when the peer is a trusted proxy', () => {
|
||||
const c = fakeContext({
|
||||
peer: '127.0.0.1',
|
||||
headers: { 'x-real-ip': '203.0.113.7', 'x-forwarded-for': '9.9.9.9' },
|
||||
});
|
||||
expect(getClientIp(c)).toBe('203.0.113.7');
|
||||
});
|
||||
|
||||
it('walks X-Forwarded-For from the right past our own proxy hops', () => {
|
||||
// spoofed prefix, then the real client appended by nginx, then the Next
|
||||
// proxy hop — the rightmost untrusted entry wins
|
||||
const c = fakeContext({
|
||||
peer: '127.0.0.1',
|
||||
headers: { 'x-forwarded-for': '9.9.9.9, 203.0.113.7, 127.0.0.1' },
|
||||
});
|
||||
expect(getClientIp(c)).toBe('203.0.113.7');
|
||||
});
|
||||
|
||||
it('ignores forwarded headers entirely when the peer is untrusted', () => {
|
||||
// A client hitting the API directly cannot pick its own bucket
|
||||
const c = fakeContext({
|
||||
peer: '198.51.100.4',
|
||||
headers: { 'x-forwarded-for': '9.9.9.9', 'x-real-ip': '8.8.8.8' },
|
||||
});
|
||||
expect(getClientIp(c)).toBe('198.51.100.4');
|
||||
});
|
||||
|
||||
it('falls back to the socket address for local traffic with no headers', () => {
|
||||
expect(getClientIp(fakeContext({ peer: '127.0.0.1' }))).toBe('127.0.0.1');
|
||||
expect(getClientIp(fakeContext({ peer: '::ffff:127.0.0.1' }))).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('falls back to the socket address when every forwarded hop is internal', () => {
|
||||
const c = fakeContext({
|
||||
peer: '127.0.0.1',
|
||||
headers: { 'x-forwarded-for': '127.0.0.1' },
|
||||
});
|
||||
expect(getClientIp(c)).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('rejects junk header values instead of using them as bucket keys', () => {
|
||||
const c = fakeContext({
|
||||
peer: '127.0.0.1',
|
||||
headers: { 'x-forwarded-for': 'not-an-ip; DROP TABLE users' },
|
||||
});
|
||||
expect(getClientIp(c)).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('returns "unknown" without a socket address or trusted headers', () => {
|
||||
expect(getClientIp(fakeContext({}))).toBe('unknown');
|
||||
});
|
||||
});
|
||||
@@ -10,11 +10,68 @@ import { getRateLimiter } from './stores/rateLimiter.js';
|
||||
* (horizontal scaling). See lib/stores/rateLimiter.ts.
|
||||
*/
|
||||
|
||||
/** Best-effort client IP extraction (honours common reverse-proxy headers). */
|
||||
// Peers allowed to speak for the client via X-Real-IP / X-Forwarded-For:
|
||||
// loopback (nginx on the same host, the Next.js proxy) and RFC1918 ranges
|
||||
// (the docker-compose scale deployment, where nginx is another container).
|
||||
// Extend with TRUSTED_PROXIES (comma-separated IP prefixes, e.g. "172.20.").
|
||||
const DEFAULT_TRUSTED_PROXY_PREFIXES = ['127.', '10.', '192.168.', '::1'];
|
||||
|
||||
function trustedProxyPrefixes(): string[] {
|
||||
const extra = (process.env.TRUSTED_PROXIES || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return [...DEFAULT_TRUSTED_PROXY_PREFIXES, ...extra];
|
||||
}
|
||||
|
||||
/** Strip the IPv4-mapped IPv6 prefix so "::ffff:127.0.0.1" matches "127.". */
|
||||
function normalizeIp(ip: string | undefined | null): string {
|
||||
const trimmed = (ip || '').trim();
|
||||
return trimmed.toLowerCase().startsWith('::ffff:') ? trimmed.slice(7) : trimmed;
|
||||
}
|
||||
|
||||
export function isTrustedProxyIp(ip: string): boolean {
|
||||
const normalized = normalizeIp(ip);
|
||||
if (!normalized) return false;
|
||||
if (/^172\.(1[6-9]|2[0-9]|3[01])\./.test(normalized)) return true; // 172.16.0.0/12
|
||||
return trustedProxyPrefixes().some((prefix) => normalized === prefix || normalized.startsWith(prefix));
|
||||
}
|
||||
|
||||
// Rough shape check so a junk header value can't become a rate-limit key.
|
||||
function looksLikeIp(value: string): boolean {
|
||||
return value.length > 0 && value.length <= 45 && /^[0-9a-fA-F.:]+$/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spoof-resistant client IP resolution.
|
||||
*
|
||||
* The TCP peer address (via @hono/node-server's env.incoming) anchors the
|
||||
* trust decision: forwarded headers are only honoured when the direct peer is
|
||||
* one of our own proxies. X-Real-IP is preferred because nginx overwrites it
|
||||
* at the edge (deploy/*.conf); X-Forwarded-For is append-only, so it is
|
||||
* walked from the right past our proxy hops — the leftmost entries are
|
||||
* client-controlled and never trusted on their own.
|
||||
*/
|
||||
export function getClientIp(c: Context): string {
|
||||
const forwarded = c.req.header('x-forwarded-for');
|
||||
if (forwarded) return forwarded.split(',')[0].trim();
|
||||
return c.req.header('x-real-ip') || 'unknown';
|
||||
const socketAddr = normalizeIp((c.env as any)?.incoming?.socket?.remoteAddress);
|
||||
|
||||
if (socketAddr && isTrustedProxyIp(socketAddr)) {
|
||||
const realIp = normalizeIp(c.req.header('x-real-ip'));
|
||||
if (realIp && looksLikeIp(realIp)) return realIp;
|
||||
|
||||
const forwarded = c.req.header('x-forwarded-for');
|
||||
if (forwarded) {
|
||||
const chain = forwarded.split(',').map((s) => normalizeIp(s)).filter(Boolean);
|
||||
for (let i = chain.length - 1; i >= 0; i--) {
|
||||
if (!isTrustedProxyIp(chain[i])) {
|
||||
return looksLikeIp(chain[i]) ? chain[i] : socketAddr;
|
||||
}
|
||||
}
|
||||
// Every hop is one of ours: a genuinely local/internal client.
|
||||
}
|
||||
}
|
||||
|
||||
return socketAddr || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user