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