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>
128 lines
3.7 KiB
TypeScript
128 lines
3.7 KiB
TypeScript
import { Context } from 'hono';
|
|
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';
|
|
|
|
// 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.
|
|
|
|
// Re-exported for routes that hash/validate passwords outside Better Auth
|
|
export { hashPassword, verifyPassword, validatePassword } from './passwordPolicy.js';
|
|
|
|
export interface AuthUser {
|
|
id: string;
|
|
email: string;
|
|
name: string;
|
|
phone: string | null;
|
|
role: string;
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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 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 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();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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: (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;
|
|
}
|