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
@@ -1,18 +1,12 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, dbAll, users, tickets, payments, events, invoices, User } from '../db/index.js';
|
||||
import { db, dbGet, dbAll, users, tickets, payments, events, invoices } from '../db/index.js';
|
||||
import { eq, desc, and, gt, sql, inArray } from 'drizzle-orm';
|
||||
import { requireAuth, getUserSessions, invalidateSession, invalidateAllUserSessions, bumpTokenVersion, createToken, hashPassword, validatePassword, getUserPasswordHash } from '../lib/auth.js';
|
||||
import { generateId, getNow } from '../lib/utils.js';
|
||||
|
||||
// User type that includes all fields (some added in schema updates)
|
||||
type AuthUser = User & {
|
||||
isClaimed: boolean;
|
||||
googleId: string | null;
|
||||
rucNumber: string | null;
|
||||
accountStatus: string;
|
||||
};
|
||||
import { requireAuth, getUserPasswordHash, hasGoogleAccount, validatePassword, type AuthUser } from '../lib/auth.js';
|
||||
import { auth } from '../lib/betterAuth.js';
|
||||
import { authSessions, authAccounts } from '../db/auth-schema.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
|
||||
const dashboard = new Hono();
|
||||
|
||||
@@ -50,7 +44,7 @@ dashboard.get('/profile', async (c) => {
|
||||
isClaimed: user.isClaimed,
|
||||
accountStatus: user.accountStatus,
|
||||
hasPassword,
|
||||
hasGoogleLinked: !!user.googleId,
|
||||
hasGoogleLinked: await hasGoogleAccount(user.id),
|
||||
memberSince: user.createdAt,
|
||||
membershipDays,
|
||||
createdAt: user.createdAt,
|
||||
@@ -423,49 +417,77 @@ dashboard.get('/invoices', async (c) => {
|
||||
|
||||
// ==================== Security Routes ====================
|
||||
|
||||
// Get active sessions
|
||||
// Get active sessions (Better Auth session table; validated per-request so
|
||||
// this list is always live). Session tokens are never exposed to the client.
|
||||
dashboard.get('/sessions', async (c) => {
|
||||
const user = (c as any).get('user') as AuthUser;
|
||||
|
||||
const sessions = await getUserSessions(user.id);
|
||||
|
||||
|
||||
const sessions = await dbAll<any>(
|
||||
(db as any)
|
||||
.select({
|
||||
id: (authSessions as any).id,
|
||||
userAgent: (authSessions as any).userAgent,
|
||||
ipAddress: (authSessions as any).ipAddress,
|
||||
createdAt: (authSessions as any).createdAt,
|
||||
updatedAt: (authSessions as any).updatedAt,
|
||||
expiresAt: (authSessions as any).expiresAt,
|
||||
})
|
||||
.from(authSessions)
|
||||
.where(
|
||||
and(
|
||||
eq((authSessions as any).userId, user.id),
|
||||
gt((authSessions as any).expiresAt, new Date())
|
||||
)
|
||||
)
|
||||
.orderBy(desc((authSessions as any).updatedAt))
|
||||
);
|
||||
|
||||
return c.json({
|
||||
sessions: sessions.map((s: any) => ({
|
||||
id: s.id,
|
||||
userAgent: s.userAgent,
|
||||
ipAddress: s.ipAddress,
|
||||
lastActiveAt: s.lastActiveAt,
|
||||
lastActiveAt: s.updatedAt,
|
||||
createdAt: s.createdAt,
|
||||
expiresAt: s.expiresAt,
|
||||
current: s.id === user.sessionId,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
// Revoke a specific session
|
||||
// Revoke a specific session. Deleting the row is immediately effective:
|
||||
// sessions are validated against the table on every request (no cookie cache).
|
||||
dashboard.delete('/sessions/:id', async (c) => {
|
||||
const user = (c as any).get('user') as AuthUser;
|
||||
const sessionId = c.req.param('id');
|
||||
|
||||
await invalidateSession(sessionId, user.id);
|
||||
|
||||
|
||||
await (db as any)
|
||||
.delete(authSessions)
|
||||
.where(
|
||||
and(
|
||||
eq((authSessions as any).id, sessionId),
|
||||
eq((authSessions as any).userId, user.id)
|
||||
)
|
||||
);
|
||||
|
||||
return c.json({ message: 'Session revoked' });
|
||||
});
|
||||
|
||||
// Revoke all sessions (logout everywhere). Bumping the token version invalidates
|
||||
// every previously issued JWT for this user, which is the actual enforcement
|
||||
// mechanism (auth is stateless JWT, not DB-session based).
|
||||
// Revoke all other sessions (logout everywhere else); the current session
|
||||
// stays valid so this device remains signed in.
|
||||
dashboard.post('/sessions/revoke-all', async (c) => {
|
||||
const user = (c as any).get('user') as AuthUser;
|
||||
|
||||
await invalidateAllUserSessions(user.id);
|
||||
await bumpTokenVersion(user.id);
|
||||
|
||||
// Issue a fresh token so the current device stays signed in
|
||||
const refreshed = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).id, user.id))
|
||||
);
|
||||
const token = await createToken(user.id, user.email, user.role, refreshed?.tokenVersion ?? 0);
|
||||
|
||||
return c.json({ message: 'All other sessions revoked.', token });
|
||||
await (db as any)
|
||||
.delete(authSessions)
|
||||
.where(
|
||||
and(
|
||||
eq((authSessions as any).userId, user.id),
|
||||
sql`${(authSessions as any).id} != ${user.sessionId}`
|
||||
)
|
||||
);
|
||||
|
||||
return c.json({ message: 'All other sessions revoked.' });
|
||||
});
|
||||
|
||||
// Set password (for users without one)
|
||||
@@ -476,53 +498,57 @@ const setPasswordSchema = z.object({
|
||||
dashboard.post('/set-password', zValidator('json', setPasswordSchema), async (c) => {
|
||||
const user = (c as any).get('user') as AuthUser;
|
||||
const { password } = c.req.valid('json');
|
||||
|
||||
|
||||
// Check if user already has a password
|
||||
if (await getUserPasswordHash(user.id)) {
|
||||
return c.json({ error: 'Password already set. Use change password instead.' }, 400);
|
||||
}
|
||||
|
||||
|
||||
// setPassword is a server-only Better Auth endpoint, so the HTTP-layer
|
||||
// policy hook does not cover it — validate explicitly.
|
||||
const passwordValidation = validatePassword(password);
|
||||
if (!passwordValidation.valid) {
|
||||
return c.json({ error: passwordValidation.error }, 400);
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(password);
|
||||
const now = getNow();
|
||||
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set({
|
||||
password: hashedPassword,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((users as any).id, user.id));
|
||||
|
||||
|
||||
try {
|
||||
await auth.api.setPassword({
|
||||
body: { newPassword: password },
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
} catch (err: any) {
|
||||
return c.json({ error: err?.body?.message || 'Failed to set password' }, 400);
|
||||
}
|
||||
|
||||
return c.json({ message: 'Password set successfully' });
|
||||
});
|
||||
|
||||
// Unlink Google account (only if password is set)
|
||||
dashboard.post('/unlink-google', async (c) => {
|
||||
const user = (c as any).get('user') as AuthUser;
|
||||
|
||||
if (!user.googleId) {
|
||||
|
||||
if (!(await hasGoogleAccount(user.id))) {
|
||||
return c.json({ error: 'Google account not linked' }, 400);
|
||||
}
|
||||
|
||||
|
||||
if (!(await getUserPasswordHash(user.id))) {
|
||||
return c.json({ error: 'Cannot unlink Google without a password set' }, 400);
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
|
||||
|
||||
await (db as any)
|
||||
.delete(authAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq((authAccounts as any).userId, user.id),
|
||||
eq((authAccounts as any).providerId, 'google')
|
||||
)
|
||||
);
|
||||
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set({
|
||||
googleId: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.set({ updatedAt: getNow() })
|
||||
.where(eq((users as any).id, user.id));
|
||||
|
||||
|
||||
return c.json({ message: 'Google account unlinked' });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user