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>
97 lines
3.4 KiB
TypeScript
97 lines
3.4 KiB
TypeScript
import { Hono } from 'hono';
|
|
import { zValidator } from '@hono/zod-validator';
|
|
import { z } from 'zod';
|
|
import { eq } from 'drizzle-orm';
|
|
import { auth } from '../lib/betterAuth.js';
|
|
import { validatePassword } from '../lib/passwordPolicy.js';
|
|
import { db, dbGet, users } from '../db/index.js';
|
|
import { getNow, toDbBool } from '../lib/utils.js';
|
|
import { rateLimitMiddleware } from '../lib/rateLimit.js';
|
|
|
|
// Custom auth flows that Better Auth doesn't provide out of the box. Mounted
|
|
// at /api/auth-ext to avoid colliding with Better Auth's /api/auth/* handler.
|
|
const authExtRateLimit = rateLimitMiddleware({ max: 20, windowMs: 15 * 60 * 1000, prefix: 'auth-ext' });
|
|
|
|
const authExt = new Hono();
|
|
|
|
const claimAccountSchema = z.object({
|
|
password: z.string().min(10, 'Password must be at least 10 characters'),
|
|
});
|
|
|
|
// Complete a progressive-account claim. The user arrives here already holding
|
|
// a session established by the claim magic link; this endpoint deliberately
|
|
// accepts accountStatus 'unclaimed' sessions (requireAuth would reject them)
|
|
// and is the ONLY endpoint that does.
|
|
authExt.post('/claim-account', authExtRateLimit, zValidator('json', claimAccountSchema), async (c) => {
|
|
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
if (!session?.user) {
|
|
return c.json({ error: 'Unauthorized. Please use the claim link from your email.' }, 401);
|
|
}
|
|
|
|
const user = session.user as any;
|
|
if (user.banned || user.accountStatus === 'suspended') {
|
|
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
|
|
}
|
|
if (user.isClaimed && user.accountStatus === 'active') {
|
|
return c.json({ error: 'Account is already claimed' }, 400);
|
|
}
|
|
|
|
const { password } = c.req.valid('json');
|
|
const passwordValidation = validatePassword(password);
|
|
if (!passwordValidation.valid) {
|
|
return c.json({ error: passwordValidation.error }, 400);
|
|
}
|
|
|
|
// Creates the credential account with the argon2id hash from lib/betterAuth.ts
|
|
await auth.api.setPassword({
|
|
body: { newPassword: password },
|
|
headers: c.req.raw.headers,
|
|
});
|
|
|
|
// The magic link click proved email ownership
|
|
await (db as any)
|
|
.update(users)
|
|
.set({
|
|
isClaimed: toDbBool(true),
|
|
accountStatus: 'active',
|
|
emailVerified: true,
|
|
updatedAt: getNow(),
|
|
})
|
|
.where(eq((users as any).id, user.id));
|
|
|
|
return c.json({
|
|
message: 'Account claimed successfully!',
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
role: user.role,
|
|
isClaimed: true,
|
|
phone: user.phone ?? null,
|
|
rucNumber: user.rucNumber ?? null,
|
|
languagePreference: user.languagePreference ?? null,
|
|
},
|
|
});
|
|
});
|
|
|
|
// Whether an email belongs to an unclaimed account. Deliberate, rate-limited
|
|
// exception to enumeration-safety, matching the legacy register/login UX that
|
|
// surfaced "this account can be claimed".
|
|
authExt.get('/claim-eligibility', authExtRateLimit, async (c) => {
|
|
const email = c.req.query('email');
|
|
if (!email || !z.string().email().safeParse(email).success) {
|
|
return c.json({ canClaim: false });
|
|
}
|
|
|
|
const user = await dbGet<any>(
|
|
(db as any).select().from(users).where(eq((users as any).email, email))
|
|
);
|
|
|
|
const canClaim = !!user && !user.banned && user.accountStatus !== 'suspended'
|
|
&& (!user.isClaimed || user.accountStatus === 'unclaimed');
|
|
|
|
return c.json({ canClaim });
|
|
});
|
|
|
|
export default authExt;
|