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