From 733d2459df185ae964e2fc3bbadd8ef1e2d39adf Mon Sep 17 00:00:00 2001 From: Michilis Date: Wed, 29 Jul 2026 19:07:04 +0000 Subject: [PATCH] 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 --- README.md | 6 +- backend/.env.example | 22 +- backend/package.json | 7 +- backend/src/db/auth-schema.ts | 200 +++++ backend/src/db/migrate.test.ts | 100 +++ backend/src/db/migrate.ts | 229 ++++++ backend/src/db/schema.ts | 14 +- backend/src/index.ts | 256 +++---- backend/src/lib/auth.ts | 423 +++-------- .../src/lib/betterAuth.integration.test.ts | 293 ++++++++ backend/src/lib/betterAuth.ts | 341 +++++++++ backend/src/lib/passwordPolicy.ts | 62 ++ backend/src/lib/rateLimit.test.ts | 87 +++ backend/src/lib/rateLimit.ts | 65 +- backend/src/routes/auth.ts | 684 ------------------ backend/src/routes/authExt.ts | 96 +++ backend/src/routes/dashboard.ts | 144 ++-- backend/src/routes/tickets.ts | 17 +- backend/src/routes/users.ts | 22 +- backend/tsconfig.json | 2 +- frontend/package.json | 1 + .../app/(public)/auth/claim-account/page.tsx | 67 +- .../src/app/(public)/auth/magic-link/page.tsx | 14 +- .../dashboard/components/AccountTab.tsx | 16 +- frontend/src/app/(public)/dashboard/page.tsx | 8 +- .../(public)/photos/[slug]/GalleryClient.tsx | 41 +- frontend/src/app/admin/emails/page.tsx | 10 +- frontend/src/app/admin/gallery/page.tsx | 6 +- frontend/src/context/AuthContext.tsx | 246 +++---- frontend/src/lib/api/auth.ts | 124 ++-- frontend/src/lib/api/client.ts | 23 +- frontend/src/lib/api/media.ts | 7 +- frontend/src/lib/api/photos.ts | 11 +- frontend/src/lib/api/types.ts | 3 + frontend/src/lib/auth-client.ts | 22 + frontend/src/middleware.ts | 18 +- package.json | 8 + photo-api/.env.example | 10 +- photo-api/cmd/photo-api/main.go | 2 +- photo-api/go.mod | 1 - photo-api/go.sum | 2 - photo-api/internal/auth/auth.go | 106 +-- photo-api/internal/config/config.go | 11 +- photo-api/internal/httpapi/access.go | 2 +- photo-api/internal/httpapi/api_test.go | 123 +++- photo-api/internal/httpapi/dto.go | 2 +- photo-api/internal/store/access.go | 61 +- 47 files changed, 2430 insertions(+), 1585 deletions(-) create mode 100644 backend/src/db/auth-schema.ts create mode 100644 backend/src/db/migrate.test.ts create mode 100644 backend/src/lib/betterAuth.integration.test.ts create mode 100644 backend/src/lib/betterAuth.ts create mode 100644 backend/src/lib/passwordPolicy.ts create mode 100644 backend/src/lib/rateLimit.test.ts delete mode 100644 backend/src/routes/auth.ts create mode 100644 backend/src/routes/authExt.ts create mode 100644 frontend/src/lib/auth-client.ts diff --git a/README.md b/README.md index ce5feda..48b1349 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A full-stack web app for organizing and managing language exchange events (Asunc - **Backend**: Node.js + TypeScript, Hono, Drizzle ORM, SQLite (default) or PostgreSQL - **Photo service**: standalone Go module (`photo-api/`), its own binary/deploy unit, sharing the backend database and `JWT_SECRET` -- **Auth**: JWT (via `jose`), **Argon2id** password hashing (with legacy bcrypt verification for older hashes) +- **Auth**: [Better Auth](https://better-auth.com) — httpOnly cookie sessions (DB-validated on every request for instant revocation), **Argon2id** password hashing (with legacy bcrypt verification for older hashes), magic links, Google sign-in, admin ban/suspend - **Email**: `nodemailer` (SMTP) with optional provider config - **Frontend**: Next.js (App Router), Tailwind CSS, Heroicons, skeleton loading states, custom error / global-error pages @@ -102,7 +102,7 @@ npm run dev --workspace=frontend Key settings (see `backend/.env.example` for the full list): - **DB**: `DB_TYPE=sqlite|postgres`, `DATABASE_URL=./data/spanglish.db` (or Postgres URL) -- **Auth**: `JWT_SECRET` (change in production) +- **Auth**: `BETTER_AUTH_SECRET` (required in production, 32+ chars), `BETTER_AUTH_URL` (public site origin; falls back to `FRONTEND_URL`), optional `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET` - **URLs/ports**: `PORT`, `API_URL`, `FRONTEND_URL` - **Email**: `EMAIL_PROVIDER` (`console|smtp|resend`) and corresponding credentials - **Payments (optional)**: LNbits (Lightning) configuration for the automatic provider. Manual providers (TPago link, bank transfer, card, cash) need no API keys — the TPago pay link is configured and sent via an email template. @@ -114,7 +114,7 @@ Key settings (see `photo-api/.env.example`): - **Port**: `PORT=3003` (dev) - **DB**: `DB_TYPE` and `DATABASE_URL` — point at the **same** database as the backend -- **Auth**: `JWT_SECRET` — must match `backend/.env` (the service validates the backend's JWTs) +- **Auth**: none needed for user auth — the service validates Better Auth session cookies against the shared database. `PHOTO_VIEW_SECRET` signs gallery image view tokens (falls back to `JWT_SECRET` during migration). - **Storage**: `STORAGE_PATH` (local disk) or `S3_ENDPOINT` + `S3_BUCKET` (S3/Garage/MinIO); S3 downloads use short-lived presigned URLs - **Uploads/worker**: `MAX_UPLOAD_MB`, `WORKER_CONCURRENCY` (a worker generates thumb/preview JPEG variants with EXIF stripped) diff --git a/backend/.env.example b/backend/.env.example index bed4f2b..5b57d4c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -50,13 +50,33 @@ DATABASE_URL=./data/spanglish.db # Use path-style addressing (true for Garage/MinIO). Defaults to true. # S3_FORCE_PATH_STYLE=true -# JWT Secret (change in production!) +# Better Auth session secret. REQUIRED in production, 32+ characters. +# Generate one with: openssl rand -base64 48 +# Rotating it signs everyone out (cookie signatures invalidate). +BETTER_AUTH_SECRET= + +# Public site origin Better Auth builds its URLs against (falls back to +# FRONTEND_URL when unset). E.g. https://spanglishcommunity.com +BETTER_AUTH_URL= + +# Extra reverse-proxy IP prefixes allowed to set X-Real-IP / X-Forwarded-For +# (comma-separated, e.g. "172.20."). Loopback and RFC1918 ranges are always +# trusted; anything else is treated as a client and rate-limited by its +# actual socket address. +# TRUSTED_PROXIES= + +# DEPRECATED: no longer used for API auth (Better Auth replaced the JWTs). +# Still read by photo-api as the fallback secret for gallery view tokens +# until PHOTO_VIEW_SECRET is set there; safe to remove after that. JWT_SECRET=your-super-secret-key-change-in-production # Google OAuth (optional - for Google Sign-In) # Get your Client ID from: https://console.cloud.google.com/apis/credentials # Note: The same Client ID should be used in frontend/.env GOOGLE_CLIENT_ID= +# Only needed for the redirect OAuth flow; the Google Identity Services +# button (ID token sign-in) works with the Client ID alone. +GOOGLE_CLIENT_SECRET= # Server Configuration PORT=3001 diff --git a/backend/package.json b/backend/package.json index c0c948d..2330501 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,9 +20,10 @@ "@hono/zod-openapi": "^0.14.4", "argon2": "^0.44.0", "bcryptjs": "^2.4.3", - "better-sqlite3": "^11.0.0", + "better-auth": "1.6.25", + "better-sqlite3": "^12.11.1", "dotenv": "^17.2.3", - "drizzle-orm": "^0.31.2", + "drizzle-orm": "^0.45.2", "hono": "^4.4.7", "ioredis": "^5.11.1", "jose": "^5.4.0", @@ -42,7 +43,7 @@ "@types/pdfkit": "^0.17.4", "@types/pg": "^8.11.6", "@types/qrcode": "^1.5.6", - "drizzle-kit": "^0.22.8", + "drizzle-kit": "^0.31.10", "ioredis-mock": "^8.13.1", "tsx": "^4.15.7", "typescript": "^5.5.2", diff --git a/backend/src/db/auth-schema.ts b/backend/src/db/auth-schema.ts new file mode 100644 index 0000000..ebe4ec2 --- /dev/null +++ b/backend/src/db/auth-schema.ts @@ -0,0 +1,200 @@ +import { sqliteTable, text, integer, customType as sqliteCustomType } from 'drizzle-orm/sqlite-core'; +import { + pgTable, + uuid, + varchar, + text as pgText, + timestamp, + boolean as pgBoolean, + bigint, + customType as pgCustomType, +} from 'drizzle-orm/pg-core'; + +// Better Auth table definitions for both dialects. +// +// The `user` model maps onto the EXISTING `users` table so user IDs (and every +// foreign key that references them) survive the auth migration untouched. Only +// the columns Better Auth reads/writes are declared here; legacy columns +// (password, google_id, token_version) stay physically present but invisible +// to Better Auth. The full application-facing definition lives in schema.ts — +// two Drizzle table objects can safely describe the same SQL table. +const dbType = process.env.DB_TYPE || 'sqlite'; + +// Better Auth hands the adapter JS Date objects, but the legacy sqlite `users` +// timestamps are ISO-8601 TEXT columns. Bridge the two representations. +const isoText = sqliteCustomType<{ data: Date; driverData: string }>({ + dataType() { + return 'text'; + }, + toDriver(value: Date): string { + return (value instanceof Date ? value : new Date(value)).toISOString(); + }, + fromDriver(value: string): Date { + return new Date(value); + }, +}); + +// Legacy pg `users.is_claimed` is an INTEGER 0/1 column; expose it as boolean. +const pgIntBool = pgCustomType<{ data: boolean; driverData: number }>({ + dataType() { + return 'integer'; + }, + toDriver(value: boolean): number { + return value ? 1 : 0; + }, + fromDriver(value: number | boolean): boolean { + return Boolean(value); + }, +}); + +// ==================== SQLite ==================== + +export const sqliteAuthUsers = sqliteTable('users', { + id: text('id').primaryKey(), + name: text('name').notNull(), + email: text('email').notNull().unique(), + emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false), + image: text('image'), + createdAt: isoText('created_at').notNull(), + updatedAt: isoText('updated_at').notNull(), + // admin plugin fields + role: text('role').notNull().default('user'), + banned: integer('banned', { mode: 'boolean' }).notNull().default(false), + banReason: text('ban_reason'), + banExpires: integer('ban_expires', { mode: 'timestamp_ms' }), + // application additionalFields + phone: text('phone'), + languagePreference: text('language_preference'), + rucNumber: text('ruc_number'), + isClaimed: integer('is_claimed', { mode: 'boolean' }).notNull().default(true), + accountStatus: text('account_status').notNull().default('active'), +}); + +export const sqliteAuthSessions = sqliteTable('auth_sessions', { + id: text('id').primaryKey(), + userId: text('user_id') + .notNull() + .references(() => sqliteAuthUsers.id, { onDelete: 'cascade' }), + token: text('token').notNull().unique(), + expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + // admin plugin (impersonation) + impersonatedBy: text('impersonated_by'), + createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), +}); + +export const sqliteAuthAccounts = sqliteTable('auth_accounts', { + id: text('id').primaryKey(), + userId: text('user_id') + .notNull() + .references(() => sqliteAuthUsers.id, { onDelete: 'cascade' }), + accountId: text('account_id').notNull(), + providerId: text('provider_id').notNull(), + accessToken: text('access_token'), + refreshToken: text('refresh_token'), + idToken: text('id_token'), + accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp_ms' }), + refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp_ms' }), + scope: text('scope'), + password: text('password'), + createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), +}); + +export const sqliteAuthVerifications = sqliteTable('auth_verifications', { + id: text('id').primaryKey(), + identifier: text('identifier').notNull(), + value: text('value').notNull(), + expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(), + createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), +}); + +export const sqliteAuthRateLimits = sqliteTable('auth_rate_limits', { + id: text('id').primaryKey(), + key: text('key'), + count: integer('count'), + lastRequest: integer('last_request'), +}); + +// ==================== PostgreSQL ==================== + +export const pgAuthUsers = pgTable('users', { + id: uuid('id').primaryKey(), + name: varchar('name', { length: 255 }).notNull(), + email: varchar('email', { length: 255 }).notNull().unique(), + emailVerified: pgBoolean('email_verified').notNull().default(false), + image: pgText('image'), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull(), + // admin plugin fields + role: varchar('role', { length: 20 }).notNull().default('user'), + banned: pgBoolean('banned').notNull().default(false), + banReason: pgText('ban_reason'), + banExpires: timestamp('ban_expires'), + // application additionalFields + phone: varchar('phone', { length: 50 }), + languagePreference: varchar('language_preference', { length: 10 }), + rucNumber: varchar('ruc_number', { length: 15 }), + isClaimed: pgIntBool('is_claimed').notNull(), + accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'), +}); + +export const pgAuthSessions = pgTable('auth_sessions', { + id: uuid('id').primaryKey(), + userId: uuid('user_id') + .notNull() + .references(() => pgAuthUsers.id, { onDelete: 'cascade' }), + token: varchar('token', { length: 255 }).notNull().unique(), + expiresAt: timestamp('expires_at').notNull(), + ipAddress: varchar('ip_address', { length: 45 }), + userAgent: pgText('user_agent'), + // admin plugin (impersonation) + impersonatedBy: uuid('impersonated_by'), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull(), +}); + +export const pgAuthAccounts = pgTable('auth_accounts', { + id: uuid('id').primaryKey(), + userId: uuid('user_id') + .notNull() + .references(() => pgAuthUsers.id, { onDelete: 'cascade' }), + accountId: varchar('account_id', { length: 255 }).notNull(), + providerId: varchar('provider_id', { length: 100 }).notNull(), + accessToken: pgText('access_token'), + refreshToken: pgText('refresh_token'), + idToken: pgText('id_token'), + accessTokenExpiresAt: timestamp('access_token_expires_at'), + refreshTokenExpiresAt: timestamp('refresh_token_expires_at'), + scope: pgText('scope'), + password: pgText('password'), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull(), +}); + +export const pgAuthVerifications = pgTable('auth_verifications', { + id: uuid('id').primaryKey(), + identifier: varchar('identifier', { length: 255 }).notNull(), + value: pgText('value').notNull(), + expiresAt: timestamp('expires_at').notNull(), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull(), +}); + +export const pgAuthRateLimits = pgTable('auth_rate_limits', { + id: varchar('id', { length: 64 }).primaryKey(), + key: varchar('key', { length: 255 }), + count: bigint('count', { mode: 'number' }), + lastRequest: bigint('last_request', { mode: 'number' }), +}); + +// ==================== Runtime-switched exports ==================== + +export const authUsers = dbType === 'postgres' ? pgAuthUsers : sqliteAuthUsers; +export const authSessions = dbType === 'postgres' ? pgAuthSessions : sqliteAuthSessions; +export const authAccounts = dbType === 'postgres' ? pgAuthAccounts : sqliteAuthAccounts; +export const authVerifications = dbType === 'postgres' ? pgAuthVerifications : sqliteAuthVerifications; +export const authRateLimits = dbType === 'postgres' ? pgAuthRateLimits : sqliteAuthRateLimits; diff --git a/backend/src/db/migrate.test.ts b/backend/src/db/migrate.test.ts new file mode 100644 index 0000000..e5bbf09 --- /dev/null +++ b/backend/src/db/migrate.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { execFileSync } from 'child_process'; +import { mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import Database from 'better-sqlite3'; + +// Migration idempotency for the Better Auth backfill: seed legacy-shaped user +// rows, run migrate repeatedly, and assert the backfill is correct and never +// duplicates. + +const dir = mkdtempSync(join(tmpdir(), 'ba-migrate-test-')); +const dbPath = join(dir, 'migrate.db'); + +function runMigrate() { + execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { + env: { + ...process.env, + DB_TYPE: 'sqlite', + DATABASE_URL: dbPath, + REDIS_URL: '', + }, + stdio: 'pipe', + }); +} + +let db: Database.Database; + +beforeAll(() => { + // First run creates the schema + runMigrate(); + db = new Database(dbPath); + + // Seed legacy-shaped users (pre-Better-Auth): password lives on users, + // google_id links Google, '' marks guest accounts, suspended via status. + const now = new Date().toISOString(); + const insert = db.prepare( + `INSERT INTO users (id, email, password, name, role, is_claimed, google_id, account_status, token_version, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)` + ); + insert.run('legacy-argon', 'argon@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake', 'Argon', 'user', 1, null, 'active', now, now); + insert.run('legacy-bcrypt', 'bcrypt@old.py', '$2a$10$fakebcryptfakebcryptfakebc', 'Bcrypt', 'admin', 1, null, 'active', now, now); + insert.run('legacy-guest', 'guest@old.py', '', 'Guest', 'user', 1, null, 'active', now, now); + insert.run('legacy-google', 'google@old.py', null, 'Google', 'user', 1, 'google-sub-123', 'active', now, now); + insert.run('legacy-both', 'both@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake2', 'Both', 'user', 1, 'google-sub-456', 'active', now, now); + insert.run('legacy-suspended', 'suspended@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake3', 'Bad', 'user', 1, null, 'suspended', now, now); + + // Second run performs the backfill against the seeded rows + runMigrate(); +}, 240_000); + +describe('Better Auth migration backfill', () => { + it('creates credential accounts for users with real passwords only', () => { + const rows = db + .prepare("SELECT user_id, password FROM auth_accounts WHERE provider_id = 'credential' ORDER BY user_id") + .all() as any[]; + const byUser = new Map(rows.map((r) => [r.user_id, r.password])); + + expect(byUser.get('legacy-argon')).toContain('$argon2id$'); + expect(byUser.get('legacy-bcrypt')).toContain('$2a$'); + expect(byUser.get('legacy-both')).toContain('$argon2id$'); + expect(byUser.get('legacy-suspended')).toBeTruthy(); + // Guests ('' password) and Google-only users get no credential account + expect(byUser.has('legacy-guest')).toBe(false); + expect(byUser.has('legacy-google')).toBe(false); + }); + + it('creates google accounts from google_id', () => { + const rows = db + .prepare("SELECT user_id, account_id FROM auth_accounts WHERE provider_id = 'google' ORDER BY user_id") + .all() as any[]; + expect(rows).toEqual([ + { user_id: 'legacy-both', account_id: 'google-sub-456' }, + { user_id: 'legacy-google', account_id: 'google-sub-123' }, + ]); + }); + + it('marks claimed legacy users email-verified, guests not', () => { + const verified = (email: string) => + (db.prepare('SELECT email_verified FROM users WHERE email = ?').get(email) as any).email_verified; + expect(verified('argon@old.py')).toBe(1); + expect(verified('google@old.py')).toBe(1); + expect(verified('guest@old.py')).toBe(0); + }); + + it('mirrors suspended accounts to banned', () => { + const row = db.prepare('SELECT banned, ban_reason FROM users WHERE email = ?').get('suspended@old.py') as any; + expect(row.banned).toBe(1); + expect(row.ban_reason).toContain('suspended'); + const active = db.prepare('SELECT banned FROM users WHERE email = ?').get('argon@old.py') as any; + expect(active.banned).toBe(0); + }); + + it('is idempotent: a third run adds nothing', () => { + const count = () => (db.prepare('SELECT COUNT(*) AS n FROM auth_accounts').get() as any).n; + const before = count(); + runMigrate(); + expect(count()).toBe(before); + }, 60_000); +}); diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index bab2ed9..344fa30 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -544,6 +544,81 @@ async function migrate() { updated_by TEXT REFERENCES users(id) ) `); + + // ==================== Better Auth ==================== + // Better Auth core + admin plugin columns on the existing users table + try { + await (db as any).run(sql`ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).run(sql`ALTER TABLE users ADD COLUMN image TEXT`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).run(sql`ALTER TABLE users ADD COLUMN banned INTEGER NOT NULL DEFAULT 0`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).run(sql`ALTER TABLE users ADD COLUMN ban_reason TEXT`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).run(sql`ALTER TABLE users ADD COLUMN ban_expires INTEGER`); + } catch (e) { /* column may already exist */ } + + // Better Auth sessions (replaces the legacy user_sessions table). + // Timestamps are integer epoch-milliseconds (Drizzle timestamp_ms mode). + await (db as any).run(sql` + CREATE TABLE IF NOT EXISTS auth_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + expires_at INTEGER NOT NULL, + ip_address TEXT, + user_agent TEXT, + impersonated_by TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `); + + // Better Auth accounts: credential (password hash) and OAuth provider links + await (db as any).run(sql` + CREATE TABLE IF NOT EXISTS auth_accounts ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + account_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + access_token TEXT, + refresh_token TEXT, + id_token TEXT, + access_token_expires_at INTEGER, + refresh_token_expires_at INTEGER, + scope TEXT, + password TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `); + + // Better Auth verification values (magic links, password reset tokens) + await (db as any).run(sql` + CREATE TABLE IF NOT EXISTS auth_verifications ( + id TEXT PRIMARY KEY, + identifier TEXT NOT NULL, + value TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `); + + // Better Auth rate limiting (used when Redis is not configured) + await (db as any).run(sql` + CREATE TABLE IF NOT EXISTS auth_rate_limits ( + id TEXT PRIMARY KEY, + key TEXT, + count INTEGER, + last_request INTEGER + ) + `); } else { // PostgreSQL migrations await (db as any).execute(sql` @@ -1044,6 +1119,80 @@ async function migrate() { updated_by UUID REFERENCES users(id) ) `); + + // ==================== Better Auth ==================== + // Better Auth core + admin plugin columns on the existing users table + try { + await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS image TEXT`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS banned BOOLEAN NOT NULL DEFAULT FALSE`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS ban_reason TEXT`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS ban_expires TIMESTAMP`); + } catch (e) { /* column may already exist */ } + + // Better Auth sessions (replaces the legacy user_sessions table) + await (db as any).execute(sql` + CREATE TABLE IF NOT EXISTS auth_sessions ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token VARCHAR(255) NOT NULL UNIQUE, + expires_at TIMESTAMP NOT NULL, + ip_address VARCHAR(45), + user_agent TEXT, + impersonated_by UUID, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ) + `); + + // Better Auth accounts: credential (password hash) and OAuth provider links + await (db as any).execute(sql` + CREATE TABLE IF NOT EXISTS auth_accounts ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + account_id VARCHAR(255) NOT NULL, + provider_id VARCHAR(100) NOT NULL, + access_token TEXT, + refresh_token TEXT, + id_token TEXT, + access_token_expires_at TIMESTAMP, + refresh_token_expires_at TIMESTAMP, + scope TEXT, + password TEXT, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ) + `); + + // Better Auth verification values (magic links, password reset tokens) + await (db as any).execute(sql` + CREATE TABLE IF NOT EXISTS auth_verifications ( + id UUID PRIMARY KEY, + identifier VARCHAR(255) NOT NULL, + value TEXT NOT NULL, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ) + `); + + // Better Auth rate limiting (used when Redis is not configured) + await (db as any).execute(sql` + CREATE TABLE IF NOT EXISTS auth_rate_limits ( + id VARCHAR(64) PRIMARY KEY, + key VARCHAR(255), + count BIGINT, + last_request BIGINT + ) + `); } // Indexes on foreign-key / hot-filter columns (CREATE INDEX IF NOT EXISTS works on both engines) @@ -1056,6 +1205,11 @@ async function migrate() { `CREATE INDEX IF NOT EXISTS payments_status_idx ON payments(status)`, `CREATE INDEX IF NOT EXISTS email_logs_event_id_idx ON email_logs(event_id)`, `CREATE INDEX IF NOT EXISTS magic_link_tokens_token_idx ON magic_link_tokens(token)`, + `CREATE INDEX IF NOT EXISTS auth_sessions_user_id_idx ON auth_sessions(user_id)`, + `CREATE INDEX IF NOT EXISTS auth_accounts_user_id_idx ON auth_accounts(user_id)`, + `CREATE UNIQUE INDEX IF NOT EXISTS auth_accounts_provider_account_idx ON auth_accounts(provider_id, account_id)`, + `CREATE INDEX IF NOT EXISTS auth_verifications_identifier_idx ON auth_verifications(identifier)`, + `CREATE INDEX IF NOT EXISTS auth_rate_limits_key_idx ON auth_rate_limits(key)`, ]; for (const stmt of indexStatements) { try { @@ -1067,6 +1221,81 @@ async function migrate() { } catch (e) { /* index may already exist */ } } + // ==================== Better Auth data backfill ==================== + // Idempotent: every statement is guarded so re-running migrate is safe, and + // legacy users are distinguished from Better-Auth-created users by having + // users.password / users.google_id set (Better Auth never writes either). + if (dbType === 'sqlite') { + // Legacy password hashes -> credential accounts (argon2 and bcrypt hashes + // both stay valid via the custom password verifier in lib/betterAuth.ts) + await (db as any).run(sql` + INSERT INTO auth_accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) + SELECT lower(hex(randomblob(16))), u.id, u.id, 'credential', u.password, + CAST(strftime('%s','now') AS INTEGER) * 1000, CAST(strftime('%s','now') AS INTEGER) * 1000 + FROM users u + WHERE u.password IS NOT NULL AND u.password != '' + AND NOT EXISTS ( + SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'credential' + ) + `); + + // Legacy Google links -> google provider accounts + await (db as any).run(sql` + INSERT INTO auth_accounts (id, user_id, account_id, provider_id, created_at, updated_at) + SELECT lower(hex(randomblob(16))), u.id, u.google_id, 'google', + CAST(strftime('%s','now') AS INTEGER) * 1000, CAST(strftime('%s','now') AS INTEGER) * 1000 + FROM users u + WHERE u.google_id IS NOT NULL AND u.google_id != '' + AND NOT EXISTS ( + SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'google' + ) + `); + + // Claimed legacy accounts proved their email (register/claim link/Google) + await (db as any).run(sql` + UPDATE users SET email_verified = 1 + WHERE email_verified = 0 AND is_claimed = 1 + AND ((password IS NOT NULL AND password != '') OR google_id IS NOT NULL) + `); + + // Suspended -> banned (admin plugin field); users.ts keeps them in sync + await (db as any).run(sql` + UPDATE users SET banned = 1, ban_reason = 'migrated: account suspended' + WHERE account_status = 'suspended' AND banned = 0 + `); + } else { + await (db as any).execute(sql` + INSERT INTO auth_accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) + SELECT gen_random_uuid(), u.id, u.id::text, 'credential', u.password, NOW(), NOW() + FROM users u + WHERE u.password IS NOT NULL AND u.password != '' + AND NOT EXISTS ( + SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'credential' + ) + `); + + await (db as any).execute(sql` + INSERT INTO auth_accounts (id, user_id, account_id, provider_id, created_at, updated_at) + SELECT gen_random_uuid(), u.id, u.google_id, 'google', NOW(), NOW() + FROM users u + WHERE u.google_id IS NOT NULL AND u.google_id != '' + AND NOT EXISTS ( + SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'google' + ) + `); + + await (db as any).execute(sql` + UPDATE users SET email_verified = TRUE + WHERE email_verified = FALSE AND is_claimed = 1 + AND ((password IS NOT NULL AND password != '') OR google_id IS NOT NULL) + `); + + await (db as any).execute(sql` + UPDATE users SET banned = TRUE, ban_reason = 'migrated: account suspended' + WHERE account_status = 'suspended' AND banned = FALSE + `); + } + // Backfill slugs for any events that don't have one yet (shared across DB types). // Ordered by creation so duplicate titles get deterministic -2, -3 suffixes. const allEvents = await dbAll<{ id: string; title: string; slug: string | null }>( diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index fc964f6..cfdda72 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -1,5 +1,5 @@ import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core'; -import { pgTable, uuid, varchar, text as pgText, timestamp, decimal, integer as pgInteger } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, varchar, text as pgText, timestamp, decimal, integer as pgInteger, boolean as pgBoolean } from 'drizzle-orm/pg-core'; // Type to determine which schema to use const dbType = process.env.DB_TYPE || 'sqlite'; @@ -20,6 +20,12 @@ export const sqliteUsers = sqliteTable('users', { accountStatus: text('account_status', { enum: ['active', 'unclaimed', 'suspended'] }).notNull().default('active'), // Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset) tokenVersion: integer('token_version').notNull().default(0), + // Better Auth core + admin plugin fields (auth-schema.ts maps the same columns) + emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false), + image: text('image'), + banned: integer('banned', { mode: 'boolean' }).notNull().default(false), + banReason: text('ban_reason'), + banExpires: integer('ban_expires', { mode: 'timestamp_ms' }), createdAt: text('created_at').notNull(), updatedAt: text('updated_at').notNull(), }); @@ -380,6 +386,12 @@ export const pgUsers = pgTable('users', { accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'), // Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset) tokenVersion: pgInteger('token_version').notNull().default(0), + // Better Auth core + admin plugin fields (auth-schema.ts maps the same columns) + emailVerified: pgBoolean('email_verified').notNull().default(false), + image: pgText('image'), + banned: pgBoolean('banned').notNull().default(false), + banReason: pgText('ban_reason'), + banExpires: timestamp('ban_expires'), createdAt: timestamp('created_at').notNull(), updatedAt: timestamp('updated_at').notNull(), }); diff --git a/backend/src/index.ts b/backend/src/index.ts index 2c8ed7c..c3b05d9 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -7,7 +7,9 @@ import { logger } from 'hono/logger'; import { swaggerUI } from '@hono/swagger-ui'; import { serveStatic } from '@hono/node-server/serve-static'; -import authRoutes from './routes/auth.js'; +import { auth } from './lib/betterAuth.js'; +import authExtRoutes from './routes/authExt.js'; +import { getClientIp } from './lib/rateLimit.js'; import eventsRoutes from './routes/events.js'; import ticketsRoutes from './routes/tickets.js'; import usersRoutes from './routes/users.js'; @@ -57,7 +59,7 @@ app.use( if (!origin) return frontendUrl; return allowedOrigins.has(origin) ? origin : null; }, - // We use bearer tokens, but keeping credentials=true matches nginx config. + // Session cookies must be allowed on cross-origin API calls (api.* vhost). credentials: true, }) ); @@ -110,11 +112,15 @@ const openApiSpec = { ], paths: { // ==================== Auth Endpoints ==================== - '/api/auth/register': { + // Authentication is handled by Better Auth, mounted at /api/auth/*. + // Sessions are httpOnly cookies (spanglish.session_token); the endpoints + // below are the subset the frontend uses. See https://better-auth.com/docs + // for the full endpoint reference. + '/api/auth/sign-up/email': { post: { tags: ['Auth'], - summary: 'Register a new user', - description: 'Create a new user account. First registered user becomes admin. Password must be at least 10 characters.', + summary: 'Register a new user (Better Auth)', + description: 'Create a user account and start a cookie session. First registered user becomes admin. Password policy: 10-128 chars, upper+lower+digit-or-symbol, common passwords rejected.', requestBody: { required: true, content: { @@ -124,8 +130,8 @@ const openApiSpec = { required: ['email', 'password', 'name'], properties: { email: { type: 'string', format: 'email' }, - password: { type: 'string', minLength: 10, description: 'Minimum 10 characters' }, - name: { type: 'string', minLength: 2 }, + password: { type: 'string', minLength: 10 }, + name: { type: 'string' }, phone: { type: 'string' }, languagePreference: { type: 'string', enum: ['en', 'es'] }, }, @@ -134,16 +140,17 @@ const openApiSpec = { }, }, responses: { - 201: { description: 'User created successfully' }, - 400: { description: 'Email already registered or validation error' }, + 200: { description: 'User created; session cookie set' }, + 422: { description: 'Email already registered' }, + 400: { description: 'Validation error' }, }, }, }, - '/api/auth/login': { + '/api/auth/sign-in/email': { post: { tags: ['Auth'], - summary: 'Login with email and password', - description: 'Authenticate user with email and password. Rate limited to 5 attempts per 15 minutes.', + summary: 'Login with email and password (Better Auth)', + description: 'Starts a cookie session. Per-email lockout: 5 failures / 15 min. Per-IP rate limited.', requestBody: { required: true, content: { @@ -160,42 +167,46 @@ const openApiSpec = { }, }, responses: { - 200: { description: 'Login successful, returns JWT token' }, + 200: { description: 'Login successful; session cookie set' }, 401: { description: 'Invalid credentials' }, - 429: { description: 'Too many login attempts' }, + 429: { description: 'Too many attempts' }, }, }, }, - '/api/auth/google': { + '/api/auth/sign-in/social': { post: { tags: ['Auth'], - summary: 'Login or register with Google', - description: 'Authenticate using Google OAuth. Creates account if user does not exist.', + summary: 'Login or register with Google (Better Auth)', + description: 'Sign in with a Google ID token (Google Identity Services credential). Links to an existing account by verified email.', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', - required: ['credential'], + required: ['provider'], properties: { - credential: { type: 'string', description: 'Google ID token' }, + provider: { type: 'string', enum: ['google'] }, + idToken: { + type: 'object', + properties: { token: { type: 'string', description: 'Google ID token' } }, + }, }, }, }, }, }, responses: { - 200: { description: 'Login successful' }, - 400: { description: 'Invalid Google token' }, + 200: { description: 'Login successful; session cookie set' }, + 401: { description: 'Invalid Google token' }, }, }, }, - '/api/auth/magic-link/request': { + '/api/auth/sign-in/magic-link': { post: { tags: ['Auth'], - summary: 'Request magic link login', - description: 'Send a one-time login link to email. Link expires in 10 minutes.', + summary: 'Request magic link login (Better Auth)', + description: 'Emails a one-time login link (10 min TTL, single use, hashed at rest). Does not create accounts.', requestBody: { required: true, content: { @@ -205,46 +216,33 @@ const openApiSpec = { required: ['email'], properties: { email: { type: 'string', format: 'email' }, + callbackURL: { type: 'string' }, }, }, }, }, }, - responses: { - 200: { description: 'Magic link sent (if account exists)' }, - }, + responses: { 200: { description: 'Magic link sent (if account exists)' } }, }, }, '/api/auth/magic-link/verify': { - post: { + get: { tags: ['Auth'], - summary: 'Verify magic link token', - description: 'Verify the magic link token and login user.', - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: ['token'], - properties: { - token: { type: 'string' }, - }, - }, - }, - }, - }, + summary: 'Verify magic link token (Better Auth)', + parameters: [ + { name: 'token', in: 'query', required: true, schema: { type: 'string' } }, + ], responses: { - 200: { description: 'Login successful' }, + 200: { description: 'Login successful; session cookie set' }, 400: { description: 'Invalid or expired token' }, }, }, }, - '/api/auth/password-reset/request': { + '/api/auth/request-password-reset': { post: { tags: ['Auth'], - summary: 'Request password reset', - description: 'Send a password reset link to email. Link expires in 30 minutes.', + summary: 'Request password reset (Better Auth)', + description: 'Emails a reset link. Token expires in 30 minutes.', requestBody: { required: true, content: { @@ -254,31 +252,30 @@ const openApiSpec = { required: ['email'], properties: { email: { type: 'string', format: 'email' }, + redirectTo: { type: 'string' }, }, }, }, }, }, - responses: { - 200: { description: 'Reset link sent (if account exists)' }, - }, + responses: { 200: { description: 'Reset link sent (if account exists)' } }, }, }, - '/api/auth/password-reset/confirm': { + '/api/auth/reset-password': { post: { tags: ['Auth'], - summary: 'Confirm password reset', - description: 'Reset password using the token from email.', + summary: 'Reset password with token (Better Auth)', + description: 'Sets a new password and revokes all existing sessions.', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', - required: ['token', 'password'], + required: ['newPassword', 'token'], properties: { + newPassword: { type: 'string', minLength: 10 }, token: { type: 'string' }, - password: { type: 'string', minLength: 10 }, }, }, }, @@ -290,62 +287,10 @@ const openApiSpec = { }, }, }, - '/api/auth/claim-account/request': { - post: { - tags: ['Auth'], - summary: 'Request account claim link', - description: 'For unclaimed accounts created during booking. Link expires in 24 hours.', - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: ['email'], - properties: { - email: { type: 'string', format: 'email' }, - }, - }, - }, - }, - }, - responses: { - 200: { description: 'Claim link sent (if unclaimed account exists)' }, - }, - }, - }, - '/api/auth/claim-account/confirm': { - post: { - tags: ['Auth'], - summary: 'Confirm account claim', - description: 'Claim an unclaimed account by setting password or linking Google.', - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: ['token'], - properties: { - token: { type: 'string' }, - password: { type: 'string', minLength: 10, description: 'Required if not linking Google' }, - googleId: { type: 'string', description: 'Google ID for OAuth linking' }, - }, - }, - }, - }, - }, - responses: { - 200: { description: 'Account claimed successfully' }, - 400: { description: 'Invalid token or missing credentials' }, - }, - }, - }, '/api/auth/change-password': { post: { tags: ['Auth'], - summary: 'Change password', - description: 'Change password for authenticated user.', + summary: 'Change password (Better Auth)', security: [{ bearerAuth: [] }], requestBody: { required: true, @@ -357,6 +302,7 @@ const openApiSpec = { properties: { currentPassword: { type: 'string' }, newPassword: { type: 'string', minLength: 10 }, + revokeOtherSessions: { type: 'boolean' }, }, }, }, @@ -369,26 +315,66 @@ const openApiSpec = { }, }, }, - '/api/auth/me': { + '/api/auth/get-session': { get: { tags: ['Auth'], - summary: 'Get current user', - description: 'Get the currently authenticated user profile.', + summary: 'Get current session and user (Better Auth)', security: [{ bearerAuth: [] }], responses: { - 200: { description: 'Current user data' }, - 401: { description: 'Unauthorized' }, + 200: { description: '{ session, user } or null when not authenticated' }, }, }, }, - '/api/auth/logout': { + '/api/auth/sign-out': { post: { tags: ['Auth'], - summary: 'Logout', - description: 'Logout current user (client-side token removal).', - responses: { - 200: { description: 'Logged out' }, + summary: 'Logout (Better Auth)', + description: 'Revokes the current session and clears the session cookie.', + security: [{ bearerAuth: [] }], + responses: { 200: { description: 'Logged out' } }, + }, + }, + '/api/auth/list-sessions': { + get: { + tags: ['Auth'], + summary: 'List active sessions (Better Auth)', + security: [{ bearerAuth: [] }], + responses: { 200: { description: 'Active sessions for the current user' } }, + }, + }, + '/api/auth-ext/claim-account': { + post: { + tags: ['Auth'], + summary: 'Claim a guest-created account', + description: 'Completes the progressive-account claim: requires a session established via the claim magic link, sets the password, and activates the account.', + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['password'], + properties: { password: { type: 'string', minLength: 10 } }, + }, + }, + }, }, + responses: { + 200: { description: 'Account claimed successfully' }, + 400: { description: 'Already claimed or validation error' }, + 401: { description: 'No session (claim link required)' }, + }, + }, + }, + '/api/auth-ext/claim-eligibility': { + get: { + tags: ['Auth'], + summary: 'Check whether an email has an unclaimed account', + parameters: [ + { name: 'email', in: 'query', required: true, schema: { type: 'string', format: 'email' } }, + ], + responses: { 200: { description: '{ canClaim: boolean }' } }, }, }, @@ -1762,10 +1748,10 @@ const openApiSpec = { components: { securitySchemes: { bearerAuth: { - type: 'http', - scheme: 'bearer', - bearerFormat: 'JWT', - description: 'JWT token obtained from login endpoint', + type: 'apiKey', + in: 'cookie', + name: 'spanglish.session_token', + description: 'Better Auth httpOnly session cookie (set by sign-in; __Secure- prefixed in production)', }, }, schemas: { @@ -1899,7 +1885,29 @@ app.get('/health', (c) => { }); // API Routes -app.route('/api/auth', authRoutes); +// Better Auth handles all /api/auth/* endpoints (sign-in/up/out, magic link, +// password reset, Google, session management). CORS above runs first. +// +// Better Auth only sees the Request (no TCP peer address), so its per-IP rate +// limiting is fed the socket-anchored client IP resolved by getClientIp via a +// private header. The inbound value is always discarded — a client cannot +// choose its own rate-limit bucket. +app.on(['POST', 'GET'], '/api/auth/*', (c) => { + const headers = new Headers(c.req.raw.headers); + headers.delete('x-client-ip'); + const clientIp = getClientIp(c); + if (clientIp && clientIp !== 'unknown') { + headers.set('x-client-ip', clientIp); + } + return auth.handler( + new Request(c.req.raw, { + headers, + // Node's fetch requires duplex for requests carrying a body stream + ...(c.req.raw.body ? { duplex: 'half' as const } : {}), + } as RequestInit) + ); +}); +app.route('/api/auth-ext', authExtRoutes); app.route('/api/events', eventsRoutes); app.route('/api/tickets', ticketsRoutes); app.route('/api/users', usersRoutes); diff --git a/backend/src/lib/auth.ts b/backend/src/lib/auth.ts index 61c965a..b1f726a 100644 --- a/backend/src/lib/auth.ts +++ b/backend/src/lib/auth.ts @@ -1,366 +1,127 @@ -import * as jose from 'jose'; -import * as argon2 from 'argon2'; -import bcrypt from 'bcryptjs'; -import crypto from 'crypto'; import { Context } from 'hono'; -import { db, dbGet, dbAll, users, magicLinkTokens, userSessions } from '../db/index.js'; -import { eq, and, gt, sql, isNull } from 'drizzle-orm'; -import { generateId, getNow, toDbDate } from './utils.js'; +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'; -const DEFAULT_DEV_JWT_SECRET = 'your-super-secret-key-change-in-production'; -const rawJwtSecret = process.env.JWT_SECRET; +// 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. -// Never allow the insecure default in production: forgeable tokens = full account takeover. -if (process.env.NODE_ENV === 'production' && (!rawJwtSecret || rawJwtSecret === DEFAULT_DEV_JWT_SECRET)) { - throw new Error('JWT_SECRET must be set to a strong, unique value in production. Refusing to start with the default secret.'); -} -if (!rawJwtSecret) { - console.warn('[auth] JWT_SECRET is not set; using an insecure development default. Set JWT_SECRET in production.'); -} +// Re-exported for routes that hash/validate passwords outside Better Auth +export { hashPassword, verifyPassword, validatePassword } from './passwordPolicy.js'; -const JWT_SECRET = new TextEncoder().encode(rawJwtSecret || DEFAULT_DEV_JWT_SECRET); -const JWT_ISSUER = 'spanglish'; -const JWT_AUDIENCE = 'spanglish-app'; - -export interface JWTPayload { - sub: string; +export interface AuthUser { + id: string; email: string; + name: string; + phone: string | null; role: string; - tokenVersion?: number; - iat: number; - exp: number; + 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; } -// Password hashing with Argon2 (spec requirement) -export async function hashPassword(password: string): Promise { - return argon2.hash(password, { - type: argon2.argon2id, - memoryCost: 65536, // 64 MB - timeCost: 3, - parallelism: 4, - }); -} - -export async function verifyPassword(password: string, hash: string): Promise { - // Support both bcrypt (legacy) and argon2 hashes for migration - if (hash.startsWith('$argon2')) { - return argon2.verify(hash, password); - } - // Legacy bcrypt support - return bcrypt.compare(password, hash); -} - -// Generate secure random token for magic links -export function generateSecureToken(): string { - return crypto.randomBytes(32).toString('hex'); -} - -// Create magic link token -export async function createMagicLinkToken( - userId: string, - type: 'login' | 'reset_password' | 'claim_account' | 'email_verification', - expiresInMinutes: number = 10 -): Promise { - const token = generateSecureToken(); - const now = getNow(); - const expiresAt = toDbDate(new Date(Date.now() + expiresInMinutes * 60 * 1000)); - - await (db as any).insert(magicLinkTokens).values({ - id: generateId(), - userId, - token, - type, - expiresAt, - createdAt: now, - }); - - return token; -} - -// Verify and consume magic link token -export async function verifyMagicLinkToken( - token: string, - type: 'login' | 'reset_password' | 'claim_account' | 'email_verification' -): Promise<{ valid: boolean; userId?: string; error?: string }> { - const now = getNow(); - - const tokenRecord = await dbGet( - (db as any) - .select() - .from(magicLinkTokens) - .where( - and( - eq((magicLinkTokens as any).token, token), - eq((magicLinkTokens as any).type, type) - ) - ) - ); - - // Use a single generic error for all invalid states to avoid leaking token state - const genericError = 'Invalid or expired token'; - - if (!tokenRecord) { - return { valid: false, error: genericError }; - } - - if (tokenRecord.usedAt) { - return { valid: false, error: genericError }; - } - - if (new Date(tokenRecord.expiresAt) < new Date()) { - return { valid: false, error: genericError }; - } - - // Atomically consume the token: only the request that flips used_at from NULL wins. - // This prevents a double-spend race where two concurrent requests both pass the - // read-time "not used" check above. - const result: any = await (db as any) - .update(magicLinkTokens) - .set({ usedAt: now }) - .where(and( - eq((magicLinkTokens as any).id, tokenRecord.id), - isNull((magicLinkTokens as any).usedAt) - )); - - const affected = result?.changes ?? result?.rowCount ?? 0; - if (affected === 0) { - return { valid: false, error: genericError }; - } - - return { valid: true, userId: tokenRecord.userId }; -} - -// Create user session -export async function createUserSession( - userId: string, - userAgent?: string, - ipAddress?: string -): Promise { - const sessionToken = generateSecureToken(); - const now = getNow(); - const expiresAt = toDbDate(new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)); // 30 days - - await (db as any).insert(userSessions).values({ - id: generateId(), - userId, - token: sessionToken, - userAgent: userAgent || null, - ipAddress: ipAddress || null, - lastActiveAt: now, - expiresAt, - createdAt: now, - }); - - return sessionToken; -} - -// Get user's active sessions -export async function getUserSessions(userId: string) { - const now = getNow(); - - return dbAll( - (db as any) - .select() - .from(userSessions) - .where( - and( - eq((userSessions as any).userId, userId), - gt((userSessions as any).expiresAt, now) - ) - ) - ); -} - -// Invalidate a specific session -export async function invalidateSession(sessionId: string, userId: string): Promise { - const result = await (db as any) - .delete(userSessions) - .where( - and( - eq((userSessions as any).id, sessionId), - eq((userSessions as any).userId, userId) - ) - ); - - return true; -} - -// Invalidate all user sessions (logout everywhere) -export async function invalidateAllUserSessions(userId: string): Promise { - await (db as any) - .delete(userSessions) - .where(eq((userSessions as any).userId, userId)); -} - -// Small blocklist of common/weak passwords (and obvious app-specific ones). -// Compared case-insensitively after stripping non-alphanumerics so that e.g. -// "P@ssw0rd!" still matches "password". -const COMMON_PASSWORDS = new Set([ - 'password', 'passw0rd', '123456', '1234567', '12345678', '123456789', '1234567890', - 'qwerty', 'qwertyuiop', 'letmein', 'welcome', 'admin', 'administrator', 'iloveyou', - 'monkey', 'dragon', 'sunshine', 'princess', 'football', 'baseball', 'abc123', - 'spanglish', 'changeme', 'secret', 'master', 'login', 'access', -]); - -// Password policy: 10-128 chars, requires a mix of character types, and rejects -// common/weak passwords. Centralized so register/reset/change all share it. -export function validatePassword(password: string): { valid: boolean; error?: string } { - if (password.length < 10) { - return { valid: false, error: 'Password must be at least 10 characters long' }; - } - if (password.length > 128) { - return { valid: false, error: 'Password must be at most 128 characters long' }; - } - - const hasLower = /[a-z]/.test(password); - const hasUpper = /[A-Z]/.test(password); - const hasDigit = /\d/.test(password); - const hasSymbol = /[^A-Za-z0-9]/.test(password); - - // Require lowercase, uppercase, and at least one digit or symbol. - if (!hasLower || !hasUpper || !(hasDigit || hasSymbol)) { - return { - valid: false, - error: 'Password must include uppercase and lowercase letters and at least one number or symbol', - }; - } - - const normalized = password.toLowerCase().replace(/[^a-z0-9]/g, ''); - if (COMMON_PASSWORDS.has(normalized)) { - return { valid: false, error: 'Password is too common. Please choose a less guessable password.' }; - } - - return { valid: true }; -} - -export async function createToken(userId: string, email: string, role: string, tokenVersion: number = 0): Promise { - const token = await new jose.SignJWT({ sub: userId, email, role, tokenVersion }) - .setProtectedHeader({ alg: 'HS256' }) - .setIssuedAt() - .setIssuer(JWT_ISSUER) - .setAudience(JWT_AUDIENCE) - .setExpirationTime('1d') - .sign(JWT_SECRET); - - return token; -} - -// Invalidate all previously issued JWTs for a user (logout-everywhere, password change/reset). -export async function bumpTokenVersion(userId: string): Promise { - await (db as any) - .update(users) - .set({ tokenVersion: sql`${(users as any).tokenVersion} + 1` }) - .where(eq((users as any).id, userId)); -} - -export async function createRefreshToken(userId: string): Promise { - const token = await new jose.SignJWT({ sub: userId, type: 'refresh' }) - .setProtectedHeader({ alg: 'HS256' }) - .setIssuedAt() - .setIssuer(JWT_ISSUER) - .setExpirationTime('30d') - .sign(JWT_SECRET); - - return token; -} - -export async function verifyToken(token: string): Promise { +/** + * 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 { try { - const { payload } = await jose.jwtVerify(token, JWT_SECRET, { - issuer: JWT_ISSUER, - audience: JWT_AUDIENCE, - }); - return payload as unknown as JWTPayload; + 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 async function getAuthUser(c: Context): Promise { - const authHeader = c.req.header('Authorization'); - if (!authHeader?.startsWith('Bearer ')) { - return null; - } - - const token = authHeader.slice(7); - const payload = await verifyToken(token); - - if (!payload) { - return null; - } - - // Never load the password hash into request context — it is only needed for - // explicit password-verification routes that query it separately. - const user = await dbGet( - (db as any) - .select({ - id: (users as any).id, - email: (users as any).email, - name: (users as any).name, - phone: (users as any).phone, - role: (users as any).role, - languagePreference: (users as any).languagePreference, - isClaimed: (users as any).isClaimed, - googleId: (users as any).googleId, - rucNumber: (users as any).rucNumber, - accountStatus: (users as any).accountStatus, - tokenVersion: (users as any).tokenVersion, - createdAt: (users as any).createdAt, - updatedAt: (users as any).updatedAt, - }) - .from(users) - .where(eq((users as any).id, payload.sub)) - ); - - if (!user) { - return null; - } - - // Reject tokens issued before a logout-everywhere / password change - if ((payload.tokenVersion ?? 0) !== (user.tokenVersion ?? 0)) { - return null; - } - - // Suspended/unclaimed accounts must not retain API access via an old JWT - if (user.accountStatus && user.accountStatus !== 'active') { - return null; - } - - return user; -} - export function requireAuth(roles?: string[]) { return async (c: Context, next: () => Promise) => { 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(); }; } -export async function isFirstUser(): Promise { - const result = await dbAll( - (db as any).select().from(users).limit(1) - ); - return !result || result.length === 0; -} - -/** Fetch only the password hash column (never expose via getAuthUser). */ +/** + * 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 { const row = await dbGet( (db as any) - .select({ password: (users as any).password }) - .from(users) - .where(eq((users as any).id, userId)) + .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 { + const row = await dbGet( + (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; +} diff --git a/backend/src/lib/betterAuth.integration.test.ts b/backend/src/lib/betterAuth.integration.test.ts new file mode 100644 index 0000000..a976a00 --- /dev/null +++ b/backend/src/lib/betterAuth.integration.test.ts @@ -0,0 +1,293 @@ +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { execFileSync } from 'child_process'; +import { mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +// Environment must be pinned BEFORE the db/betterAuth singletons are imported +// (dotenv never overrides pre-set values). +const dir = mkdtempSync(join(tmpdir(), 'ba-test-')); +const dbPath = join(dir, 'test.db'); +process.env.DB_TYPE = 'sqlite'; +process.env.DATABASE_URL = dbPath; +process.env.FRONTEND_URL = 'http://localhost:3002'; +process.env.BETTER_AUTH_SECRET = 'integration-test-secret-0123456789abcdef'; +delete process.env.REDIS_URL; // memory lockout/rate-limit backends +delete process.env.GOOGLE_CLIENT_ID; + +// Capture outgoing auth emails (magic links, password resets) +const sentEmails: Array<{ to: string; subject: string; html: string }> = []; +vi.mock('./email.js', () => ({ + sendEmail: vi.fn(async (opts: any) => { + sentEmails.push(opts); + }), + emailService: {}, + default: {}, +})); + +let auth: (typeof import('./betterAuth.js'))['auth']; +let db: any; +let sqlite: any; + +function lastEmailTo(email: string) { + const found = [...sentEmails].reverse().find((e) => e.to === email); + expect(found, `expected an email sent to ${email}`).toBeTruthy(); + return found!; +} + +function extractToken(html: string, param = 'token'): string { + const match = html.match(new RegExp(`[?&]${param}=([^"&\\s]+)`)); + expect(match, `expected a ${param} in the email link`).toBeTruthy(); + return decodeURIComponent(match![1]); +} + +function cookieHeaders(setCookie: string | null): Headers { + const sessionPart = (setCookie || '') + .split(/,(?=[^ ;]+=)/) + .map((c) => c.split(';')[0].trim()) + .filter((c) => c.includes('session_token')) + .join('; '); + return new Headers({ cookie: sessionPart }); +} + +beforeAll(() => { + execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { + env: { ...process.env }, + stdio: 'pipe', + }); + return (async () => { + ({ auth } = await import('./betterAuth.js')); + ({ db } = await import('../db/index.js')); + const Database = (await import('better-sqlite3')).default; + sqlite = new Database(dbPath); + })(); +}, 120_000); + +describe('Better Auth integration', () => { + it('makes the first registered user an admin, later users regular', async () => { + const first = await auth.api.signUpEmail({ + body: { email: 'admin@test.py', password: 'FirstAdmin1!x', name: 'Admin' }, + }); + expect((first.user as any).id).toBeTruthy(); + + const row = sqlite.prepare('SELECT role, is_claimed, account_status FROM users WHERE email = ?').get('admin@test.py'); + expect(row.role).toBe('admin'); + expect(row.account_status).toBe('active'); + + await auth.api.signUpEmail({ + body: { email: 'user@test.py', password: 'SecondUser1!x', name: 'User' }, + }); + const row2 = sqlite.prepare('SELECT role FROM users WHERE email = ?').get('user@test.py'); + expect(row2.role).toBe('user'); + }); + + it('stores credential passwords as argon2id in auth_accounts, not users', async () => { + const acct = sqlite + .prepare("SELECT a.password FROM auth_accounts a JOIN users u ON u.id = a.user_id WHERE u.email = ? AND a.provider_id = 'credential'") + .get('admin@test.py'); + expect(acct.password.startsWith('$argon2id$')).toBe(true); + const user = sqlite.prepare('SELECT password FROM users WHERE email = ?').get('admin@test.py'); + expect(user.password).toBeNull(); + }); + + it('rejects passwords that violate the policy', async () => { + // Too short + await expect( + auth.api.signUpEmail({ body: { email: 'weak1@test.py', password: 'Short1!', name: 'W' } }) + ).rejects.toThrow(/at least 10 characters/); + + // Long enough but no character mix (app policy hook) + await expect( + auth.api.signUpEmail({ body: { email: 'weak2@test.py', password: 'alllowercasepw', name: 'W' } }) + ).rejects.toThrow(/uppercase and lowercase/); + + // Common password normalized (policy blocklist) + await expect( + auth.api.signUpEmail({ body: { email: 'weak3@test.py', password: 'Spanglish!', name: 'W' } }) + ).rejects.toThrow(/too common/); + + expect(sqlite.prepare("SELECT COUNT(*) AS n FROM users WHERE email LIKE 'weak%'").get().n).toBe(0); + }); + + it('locks an email after 5 failed sign-ins', async () => { + await auth.api.signUpEmail({ + body: { email: 'lockout@test.py', password: 'LockoutPass1!', name: 'L' }, + }); + for (let i = 0; i < 5; i++) { + await expect( + auth.api.signInEmail({ body: { email: 'lockout@test.py', password: 'WrongPass1!x' } }) + ).rejects.toThrow(); + } + // Correct password now also refused: locked + await expect( + auth.api.signInEmail({ body: { email: 'lockout@test.py', password: 'LockoutPass1!' } }) + ).rejects.toThrow(/Too many login attempts/); + }); + + it('refuses sign-in for banned (suspended) users and kills nothing else', async () => { + await auth.api.signUpEmail({ + body: { email: 'banned@test.py', password: 'BannedPass1!x', name: 'B' }, + }); + sqlite.prepare("UPDATE users SET banned = 1, account_status = 'suspended' WHERE email = ?").run('banned@test.py'); + await expect( + auth.api.signInEmail({ body: { email: 'banned@test.py', password: 'BannedPass1!x' } }) + ).rejects.toThrow(/suspended|banned/i); + }); + + it('verifies legacy bcrypt hashes and upgrades them to argon2 on sign-in', async () => { + const bcrypt = (await import('bcryptjs')).default; + const legacyHash = bcrypt.hashSync('LegacyBcrypt1!', 10); + const su = await auth.api.signUpEmail({ + body: { email: 'legacy@test.py', password: 'TempPass123!x', name: 'Legacy' }, + }); + sqlite + .prepare("UPDATE auth_accounts SET password = ? WHERE user_id = ? AND provider_id = 'credential'") + .run(legacyHash, (su.user as any).id); + + const si = await auth.api.signInEmail({ + body: { email: 'legacy@test.py', password: 'LegacyBcrypt1!' }, + }); + expect(si.user.email).toBe('legacy@test.py'); + + // Upgrade happens in the after-hook; poll briefly for it + let upgraded = ''; + for (let i = 0; i < 20 && !upgraded.startsWith('$argon2'); i++) { + await new Promise((r) => setTimeout(r, 100)); + upgraded = sqlite + .prepare("SELECT password FROM auth_accounts WHERE user_id = ? AND provider_id = 'credential'") + .get((su.user as any).id).password; + } + expect(upgraded.startsWith('$argon2id$')).toBe(true); + + // And the upgraded hash still verifies + const again = await auth.api.signInEmail({ + body: { email: 'legacy@test.py', password: 'LegacyBcrypt1!' }, + }); + expect(again.user.email).toBe('legacy@test.py'); + }); + + it('magic link signs in existing users but never creates accounts', async () => { + await auth.api.signUpEmail({ + body: { email: 'magic@test.py', password: 'MagicPass12!x', name: 'M' }, + }); + await auth.api.signInMagicLink({ + body: { email: 'magic@test.py', callbackURL: '/dashboard' }, + headers: new Headers(), + }); + const email = lastEmailTo('magic@test.py'); + // Emails link to the frontend page, not the raw API endpoint + expect(email.html).toContain('http://localhost:3002/auth/magic-link?token='); + const token = extractToken(email.html); + + const verified = await auth.api.magicLinkVerify({ + query: { token }, + headers: new Headers(), + }); + expect((verified as any).user?.email ?? (verified as any).session?.userId).toBeTruthy(); + + // Unknown email: enumeration-safe success (an email may still go out), + // but verification can never create an account (disableSignUp) + const ghost = await auth.api.signInMagicLink({ + body: { email: 'ghost@test.py' }, + headers: new Headers(), + }); + expect((ghost as any).status).toBe(true); + const ghostEmail = sentEmails.filter((e) => e.to === 'ghost@test.py').pop(); + if (ghostEmail) { + const ghostToken = extractToken(ghostEmail.html); + await expect( + auth.api.magicLinkVerify({ query: { token: ghostToken }, headers: new Headers() }) + ).rejects.toThrow(); + } + expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('ghost@test.py').n).toBe(0); + }); + + it('completes the guest claim path: magic link session + setPassword', async () => { + // Simulate tickets.ts guest creation: user row, no credential account + const guestId = 'guest-claim-user-000001'; + const now = new Date().toISOString(); + sqlite + .prepare( + `INSERT INTO users (id, email, password, name, role, is_claimed, account_status, email_verified, banned, token_version, created_at, updated_at) + VALUES (?, ?, NULL, 'Guest', 'user', 0, 'unclaimed', 0, 0, 0, ?, ?)` + ) + .run(guestId, 'guest@test.py', now, now); + + await auth.api.signInMagicLink({ + body: { email: 'guest@test.py', callbackURL: '/auth/claim-account' }, + headers: new Headers(), + }); + const token = extractToken(lastEmailTo('guest@test.py').html); + const verified = await auth.api.magicLinkVerify({ + query: { token }, + returnHeaders: true, + headers: new Headers(), + }); + const headers = cookieHeaders(verified.headers.get('set-cookie')); + + // The session works even while unclaimed (the claim endpoint depends on this) + const session = await auth.api.getSession({ headers }); + expect((session?.user as any)?.accountStatus).toBe('unclaimed'); + + // Set the password (what /api/auth-ext/claim-account does) + await auth.api.setPassword({ body: { newPassword: 'ClaimedPass1!x' }, headers }); + const acct = sqlite + .prepare("SELECT password FROM auth_accounts WHERE user_id = ? AND provider_id = 'credential'") + .get(guestId); + expect(acct.password.startsWith('$argon2id$')).toBe(true); + + // The claim email uses the claim template with the frontend link + const claimEmail = lastEmailTo('guest@test.py'); + expect(claimEmail.subject).toContain('Claim'); + expect(claimEmail.html).toContain('callbackURL=%2Fauth%2Fclaim-account'); + }); + + it('password reset revokes existing sessions and applies the new password', async () => { + const su = await auth.api.signUpEmail({ + body: { email: 'reset@test.py', password: 'BeforeReset1!x', name: 'R' }, + }); + const userId = (su.user as any).id; + // A live session from sign-in + await auth.api.signInEmail({ body: { email: 'reset@test.py', password: 'BeforeReset1!x' } }); + expect( + sqlite.prepare('SELECT COUNT(*) AS n FROM auth_sessions WHERE user_id = ?').get(userId).n + ).toBeGreaterThan(0); + + await auth.api.requestPasswordReset({ + body: { email: 'reset@test.py', redirectTo: '/auth/reset-password' }, + }); + const email = lastEmailTo('reset@test.py'); + // Reset URLs are either .../reset-password/{token}?... or ...?token={token} + const html = email.html; + const pathMatch = html.match(/reset-password\/([^?"&\s]+)/); + const token = pathMatch ? decodeURIComponent(pathMatch[1]) : extractToken(html); + + await auth.api.resetPassword({ body: { newPassword: 'AfterReset1!x', token } }); + + // revokeSessionsOnPasswordReset: true + expect( + sqlite.prepare('SELECT COUNT(*) AS n FROM auth_sessions WHERE user_id = ?').get(userId).n + ).toBe(0); + + await expect( + auth.api.signInEmail({ body: { email: 'reset@test.py', password: 'BeforeReset1!x' } }) + ).rejects.toThrow(); + const after = await auth.api.signInEmail({ + body: { email: 'reset@test.py', password: 'AfterReset1!x' }, + }); + expect(after.user.email).toBe('reset@test.py'); + }); + + it('sessions are stored in auth_sessions with 7-day expiry', async () => { + const si = await auth.api.signInEmail({ + body: { email: 'admin@test.py', password: 'FirstAdmin1!x' }, + }); + const row = sqlite + .prepare('SELECT expires_at FROM auth_sessions WHERE token = ?') + .get((si as any).token); + expect(row).toBeTruthy(); + const days = (row.expires_at - Date.now()) / (1000 * 60 * 60 * 24); + expect(days).toBeGreaterThan(6.5); + expect(days).toBeLessThan(7.5); + }); +}); diff --git a/backend/src/lib/betterAuth.ts b/backend/src/lib/betterAuth.ts new file mode 100644 index 0000000..7042d92 --- /dev/null +++ b/backend/src/lib/betterAuth.ts @@ -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([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 { + return dbGet( + (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: ` +

Reset Your Password

+

Click the link below to reset your password. This link expires in 30 minutes.

+

Reset Password

+

Or copy this link: ${url}

+

If you didn't request this, you can safely ignore this email.

+ `, + }); + } 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((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 + ? ` +

Claim Your Account

+

An account was created for you during booking. Click below to set up your login credentials. This link expires in 10 minutes.

+

Claim Account

+

Or copy this link: ${link}

+

If you didn't request this, you can safely ignore this email.

+ ` + : ` +

Login to Spanglish

+

Click the link below to log in. This link expires in 10 minutes.

+

Log In

+

Or copy this link: ${link}

+

If you didn't request this, you can safely ignore this email.

+ `, + }); + } 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; diff --git a/backend/src/lib/passwordPolicy.ts b/backend/src/lib/passwordPolicy.ts new file mode 100644 index 0000000..202a512 --- /dev/null +++ b/backend/src/lib/passwordPolicy.ts @@ -0,0 +1,62 @@ +import * as argon2 from 'argon2'; +import bcrypt from 'bcryptjs'; + +// Password hashing with Argon2 (spec requirement) +export async function hashPassword(password: string): Promise { + return argon2.hash(password, { + type: argon2.argon2id, + memoryCost: 65536, // 64 MB + timeCost: 3, + parallelism: 4, + }); +} + +export async function verifyPassword(password: string, hash: string): Promise { + // Support both bcrypt (legacy) and argon2 hashes for migration + if (hash.startsWith('$argon2')) { + return argon2.verify(hash, password); + } + // Legacy bcrypt support + return bcrypt.compare(password, hash); +} + +// Small blocklist of common/weak passwords (and obvious app-specific ones). +// Compared case-insensitively after stripping non-alphanumerics so that e.g. +// "P@ssw0rd!" still matches "password". +const COMMON_PASSWORDS = new Set([ + 'password', 'passw0rd', '123456', '1234567', '12345678', '123456789', '1234567890', + 'qwerty', 'qwertyuiop', 'letmein', 'welcome', 'admin', 'administrator', 'iloveyou', + 'monkey', 'dragon', 'sunshine', 'princess', 'football', 'baseball', 'abc123', + 'spanglish', 'changeme', 'secret', 'master', 'login', 'access', +]); + +// Password policy: 10-128 chars, requires a mix of character types, and rejects +// common/weak passwords. Centralized so register/reset/change all share it. +export function validatePassword(password: string): { valid: boolean; error?: string } { + if (password.length < 10) { + return { valid: false, error: 'Password must be at least 10 characters long' }; + } + if (password.length > 128) { + return { valid: false, error: 'Password must be at most 128 characters long' }; + } + + const hasLower = /[a-z]/.test(password); + const hasUpper = /[A-Z]/.test(password); + const hasDigit = /\d/.test(password); + const hasSymbol = /[^A-Za-z0-9]/.test(password); + + // Require lowercase, uppercase, and at least one digit or symbol. + if (!hasLower || !hasUpper || !(hasDigit || hasSymbol)) { + return { + valid: false, + error: 'Password must include uppercase and lowercase letters and at least one number or symbol', + }; + } + + const normalized = password.toLowerCase().replace(/[^a-z0-9]/g, ''); + if (COMMON_PASSWORDS.has(normalized)) { + return { valid: false, error: 'Password is too common. Please choose a less guessable password.' }; + } + + return { valid: true }; +} diff --git a/backend/src/lib/rateLimit.test.ts b/backend/src/lib/rateLimit.test.ts new file mode 100644 index 0000000..0b3348b --- /dev/null +++ b/backend/src/lib/rateLimit.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { getClientIp, isTrustedProxyIp } from './rateLimit.js'; + +// Minimal Hono-Context stand-in: headers + the node-server env with the +// socket peer address. +function fakeContext(opts: { peer?: string; headers?: Record }) { + const headers = new Map( + Object.entries(opts.headers || {}).map(([k, v]) => [k.toLowerCase(), v]) + ); + return { + req: { header: (name: string) => headers.get(name.toLowerCase()) }, + env: opts.peer ? { incoming: { socket: { remoteAddress: opts.peer } } } : {}, + } as any; +} + +describe('isTrustedProxyIp', () => { + it('trusts loopback and private ranges, including IPv4-mapped IPv6', () => { + expect(isTrustedProxyIp('127.0.0.1')).toBe(true); + expect(isTrustedProxyIp('::1')).toBe(true); + expect(isTrustedProxyIp('::ffff:127.0.0.1')).toBe(true); + expect(isTrustedProxyIp('10.1.2.3')).toBe(true); + expect(isTrustedProxyIp('172.18.0.5')).toBe(true); + expect(isTrustedProxyIp('192.168.1.1')).toBe(true); + }); + + it('does not trust public addresses or near-miss ranges', () => { + expect(isTrustedProxyIp('203.0.113.7')).toBe(false); + expect(isTrustedProxyIp('172.15.0.1')).toBe(false); // outside 172.16/12 + expect(isTrustedProxyIp('172.32.0.1')).toBe(false); + expect(isTrustedProxyIp('1270.0.0.1')).toBe(false); + expect(isTrustedProxyIp('')).toBe(false); + }); +}); + +describe('getClientIp', () => { + it('prefers X-Real-IP when the peer is a trusted proxy', () => { + const c = fakeContext({ + peer: '127.0.0.1', + headers: { 'x-real-ip': '203.0.113.7', 'x-forwarded-for': '9.9.9.9' }, + }); + expect(getClientIp(c)).toBe('203.0.113.7'); + }); + + it('walks X-Forwarded-For from the right past our own proxy hops', () => { + // spoofed prefix, then the real client appended by nginx, then the Next + // proxy hop — the rightmost untrusted entry wins + const c = fakeContext({ + peer: '127.0.0.1', + headers: { 'x-forwarded-for': '9.9.9.9, 203.0.113.7, 127.0.0.1' }, + }); + expect(getClientIp(c)).toBe('203.0.113.7'); + }); + + it('ignores forwarded headers entirely when the peer is untrusted', () => { + // A client hitting the API directly cannot pick its own bucket + const c = fakeContext({ + peer: '198.51.100.4', + headers: { 'x-forwarded-for': '9.9.9.9', 'x-real-ip': '8.8.8.8' }, + }); + expect(getClientIp(c)).toBe('198.51.100.4'); + }); + + it('falls back to the socket address for local traffic with no headers', () => { + expect(getClientIp(fakeContext({ peer: '127.0.0.1' }))).toBe('127.0.0.1'); + expect(getClientIp(fakeContext({ peer: '::ffff:127.0.0.1' }))).toBe('127.0.0.1'); + }); + + it('falls back to the socket address when every forwarded hop is internal', () => { + const c = fakeContext({ + peer: '127.0.0.1', + headers: { 'x-forwarded-for': '127.0.0.1' }, + }); + expect(getClientIp(c)).toBe('127.0.0.1'); + }); + + it('rejects junk header values instead of using them as bucket keys', () => { + const c = fakeContext({ + peer: '127.0.0.1', + headers: { 'x-forwarded-for': 'not-an-ip; DROP TABLE users' }, + }); + expect(getClientIp(c)).toBe('127.0.0.1'); + }); + + it('returns "unknown" without a socket address or trusted headers', () => { + expect(getClientIp(fakeContext({}))).toBe('unknown'); + }); +}); diff --git a/backend/src/lib/rateLimit.ts b/backend/src/lib/rateLimit.ts index de2f9a9..b1d597e 100644 --- a/backend/src/lib/rateLimit.ts +++ b/backend/src/lib/rateLimit.ts @@ -10,11 +10,68 @@ import { getRateLimiter } from './stores/rateLimiter.js'; * (horizontal scaling). See lib/stores/rateLimiter.ts. */ -/** Best-effort client IP extraction (honours common reverse-proxy headers). */ +// Peers allowed to speak for the client via X-Real-IP / X-Forwarded-For: +// loopback (nginx on the same host, the Next.js proxy) and RFC1918 ranges +// (the docker-compose scale deployment, where nginx is another container). +// Extend with TRUSTED_PROXIES (comma-separated IP prefixes, e.g. "172.20."). +const DEFAULT_TRUSTED_PROXY_PREFIXES = ['127.', '10.', '192.168.', '::1']; + +function trustedProxyPrefixes(): string[] { + const extra = (process.env.TRUSTED_PROXIES || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + return [...DEFAULT_TRUSTED_PROXY_PREFIXES, ...extra]; +} + +/** Strip the IPv4-mapped IPv6 prefix so "::ffff:127.0.0.1" matches "127.". */ +function normalizeIp(ip: string | undefined | null): string { + const trimmed = (ip || '').trim(); + return trimmed.toLowerCase().startsWith('::ffff:') ? trimmed.slice(7) : trimmed; +} + +export function isTrustedProxyIp(ip: string): boolean { + const normalized = normalizeIp(ip); + if (!normalized) return false; + if (/^172\.(1[6-9]|2[0-9]|3[01])\./.test(normalized)) return true; // 172.16.0.0/12 + return trustedProxyPrefixes().some((prefix) => normalized === prefix || normalized.startsWith(prefix)); +} + +// Rough shape check so a junk header value can't become a rate-limit key. +function looksLikeIp(value: string): boolean { + return value.length > 0 && value.length <= 45 && /^[0-9a-fA-F.:]+$/.test(value); +} + +/** + * Spoof-resistant client IP resolution. + * + * The TCP peer address (via @hono/node-server's env.incoming) anchors the + * trust decision: forwarded headers are only honoured when the direct peer is + * one of our own proxies. X-Real-IP is preferred because nginx overwrites it + * at the edge (deploy/*.conf); X-Forwarded-For is append-only, so it is + * walked from the right past our proxy hops — the leftmost entries are + * client-controlled and never trusted on their own. + */ export function getClientIp(c: Context): string { - const forwarded = c.req.header('x-forwarded-for'); - if (forwarded) return forwarded.split(',')[0].trim(); - return c.req.header('x-real-ip') || 'unknown'; + const socketAddr = normalizeIp((c.env as any)?.incoming?.socket?.remoteAddress); + + if (socketAddr && isTrustedProxyIp(socketAddr)) { + const realIp = normalizeIp(c.req.header('x-real-ip')); + if (realIp && looksLikeIp(realIp)) return realIp; + + const forwarded = c.req.header('x-forwarded-for'); + if (forwarded) { + const chain = forwarded.split(',').map((s) => normalizeIp(s)).filter(Boolean); + for (let i = chain.length - 1; i >= 0; i--) { + if (!isTrustedProxyIp(chain[i])) { + return looksLikeIp(chain[i]) ? chain[i] : socketAddr; + } + } + // Every hop is one of ours: a genuinely local/internal client. + } + } + + return socketAddr || 'unknown'; } /** diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts deleted file mode 100644 index 2528602..0000000 --- a/backend/src/routes/auth.ts +++ /dev/null @@ -1,684 +0,0 @@ -import { Hono } from 'hono'; -import { zValidator } from '@hono/zod-validator'; -import { z } from 'zod'; -import { db, dbGet, users, magicLinkTokens, User } from '../db/index.js'; -import { eq } from 'drizzle-orm'; -import { - hashPassword, - verifyPassword, - createToken, - createRefreshToken, - isFirstUser, - getAuthUser, - validatePassword, - createMagicLinkToken, - verifyMagicLinkToken, - invalidateAllUserSessions, - bumpTokenVersion, - requireAuth, - getUserPasswordHash, -} from '../lib/auth.js'; -import { generateId, getNow, toDbBool } from '../lib/utils.js'; -import { sendEmail } from '../lib/email.js'; -import { rateLimitMiddleware } from '../lib/rateLimit.js'; -import { getLoginLockout } from '../lib/stores/loginLockout.js'; - -// Per-IP rate limit for sensitive auth endpoints (registration, login, and all -// email-dispatching flows) to curb credential stuffing and email flooding. -const authRateLimit = rateLimitMiddleware({ max: 20, windowMs: 15 * 60 * 1000, prefix: 'auth' }); - -// User type that includes all fields (some added in schema updates) -type AuthUser = User & { - isClaimed: boolean; - googleId: string | null; - rucNumber: string | null; - accountStatus: string; -}; - -const auth = new Hono(); - -const registerSchema = z.object({ - email: z.string().email(), - password: z.string().min(10, 'Password must be at least 10 characters'), - name: z.string().min(2), - phone: z.string().optional(), - languagePreference: z.enum(['en', 'es']).optional(), -}); - -const loginSchema = z.object({ - email: z.string().email(), - password: z.string(), -}); - -const magicLinkRequestSchema = z.object({ - email: z.string().email(), -}); - -const magicLinkVerifySchema = z.object({ - token: z.string(), -}); - -const passwordResetRequestSchema = z.object({ - email: z.string().email(), -}); - -const passwordResetSchema = z.object({ - token: z.string(), - password: z.string().min(10, 'Password must be at least 10 characters'), -}); - -const claimAccountSchema = z.object({ - token: z.string(), - password: z.string().min(10, 'Password must be at least 10 characters'), -}); - -const changePasswordSchema = z.object({ - currentPassword: z.string(), - newPassword: z.string().min(10, 'Password must be at least 10 characters'), -}); - -const googleAuthSchema = z.object({ - credential: z.string(), // Google ID token -}); - -// Register -auth.post('/register', authRateLimit, zValidator('json', registerSchema), async (c) => { - const data = c.req.valid('json'); - - // Validate password strength - const passwordValidation = validatePassword(data.password); - if (!passwordValidation.valid) { - return c.json({ error: passwordValidation.error }, 400); - } - - // Check if email exists - const existing = await dbGet( - (db as any).select().from(users).where(eq((users as any).email, data.email)) - ); - if (existing) { - // If user exists but is unclaimed, allow claiming - if (!existing.isClaimed || existing.accountStatus === 'unclaimed') { - return c.json({ - error: 'Email already registered', - canClaim: true, - message: 'This email has an unclaimed account. Please check your email for the claim link or request a new one.' - }, 400); - } - return c.json({ error: 'Email already registered' }, 400); - } - - // Check if first user (becomes admin) - const firstUser = await isFirstUser(); - - const hashedPassword = await hashPassword(data.password); - const now = getNow(); - const id = generateId(); - - const newUser = { - id, - email: data.email, - password: hashedPassword, - name: data.name, - phone: data.phone || null, - role: firstUser ? 'admin' : 'user', - languagePreference: data.languagePreference || null, - isClaimed: toDbBool(true), - googleId: null, - rucNumber: null, - accountStatus: 'active', - createdAt: now, - updatedAt: now, - }; - - await (db as any).insert(users).values(newUser); - - const token = await createToken(id, data.email, newUser.role, 0); - const refreshToken = await createRefreshToken(id); - - return c.json({ - user: { - id, - email: data.email, - name: data.name, - role: newUser.role, - isClaimed: true, - }, - token, - refreshToken, - message: firstUser ? 'Admin account created successfully' : 'Account created successfully', - }, 201); -}); - -// Login with email/password -auth.post('/login', authRateLimit, zValidator('json', loginSchema), async (c) => { - const data = c.req.valid('json'); - - // Per-email lockout (shared across instances when Redis is configured). - const lockout = await getLoginLockout().isLocked(data.email); - if (lockout.locked) { - return c.json({ - error: 'Too many login attempts. Please try again later.', - retryAfter: lockout.retryAfter - }, 429); - } - - const user = await dbGet( - (db as any).select().from(users).where(eq((users as any).email, data.email)) - ); - if (!user) { - await getLoginLockout().recordFailure(data.email); - return c.json({ error: 'Invalid credentials' }, 401); - } - - // Check if account is suspended - if (user.accountStatus === 'suspended') { - return c.json({ error: 'Account is suspended. Please contact support.' }, 403); - } - - // Check if user has a password set - if (!user.password) { - return c.json({ - error: 'No password set for this account', - needsClaim: !user.isClaimed, - message: user.isClaimed - ? 'Please use Google login or request a password reset.' - : 'Please claim your account first.' - }, 400); - } - - const validPassword = await verifyPassword(data.password, user.password); - if (!validPassword) { - await getLoginLockout().recordFailure(data.email); - return c.json({ error: 'Invalid credentials' }, 401); - } - - // Clear failed attempts on successful login - await getLoginLockout().clear(data.email); - - // Transparently upgrade legacy bcrypt hashes to argon2 now that we have the - // plaintext and have verified it. Best-effort: a failure here must not block - // the login. - if (!String(user.password).startsWith('$argon2')) { - try { - const upgradedHash = await hashPassword(data.password); - await (db as any) - .update(users) - .set({ password: upgradedHash }) - .where(eq((users as any).id, user.id)); - } catch (err: any) { - console.error('[auth] Failed to upgrade legacy password hash:', err?.message || err); - } - } - - const token = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0); - const refreshToken = await createRefreshToken(user.id); - - return c.json({ - user: { - id: user.id, - email: user.email, - name: user.name, - role: user.role, - isClaimed: user.isClaimed, - phone: user.phone, - rucNumber: user.rucNumber, - languagePreference: user.languagePreference, - }, - token, - refreshToken, - }); -}); - -// Request magic link login -auth.post('/magic-link/request', authRateLimit, zValidator('json', magicLinkRequestSchema), async (c) => { - const { email } = c.req.valid('json'); - - const user = await dbGet( - (db as any).select().from(users).where(eq((users as any).email, email)) - ); - - if (!user) { - // Don't reveal if email exists - return c.json({ message: 'If an account exists with this email, a login link has been sent.' }); - } - - if (user.accountStatus === 'suspended') { - return c.json({ message: 'If an account exists with this email, a login link has been sent.' }); - } - - // Create magic link token (expires in 10 minutes) - const token = await createMagicLinkToken(user.id, 'login', 10); - const magicLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/magic-link?token=${token}`; - - // Send email - try { - await sendEmail({ - to: email, - subject: 'Your Spanglish Login Link', - html: ` -

Login to Spanglish

-

Click the link below to log in. This link expires in 10 minutes.

-

Log In

-

Or copy this link: ${magicLink}

-

If you didn't request this, you can safely ignore this email.

- `, - }); - } catch (error) { - console.error('Failed to send magic link email:', error); - } - - return c.json({ message: 'If an account exists with this email, a login link has been sent.' }); -}); - -// Verify magic link and login -auth.post('/magic-link/verify', authRateLimit, zValidator('json', magicLinkVerifySchema), async (c) => { - const { token } = c.req.valid('json'); - - const verification = await verifyMagicLinkToken(token, 'login'); - - if (!verification.valid) { - return c.json({ error: verification.error }, 400); - } - - const user = await dbGet( - (db as any).select().from(users).where(eq((users as any).id, verification.userId)) - ); - - if (!user || user.accountStatus === 'suspended') { - return c.json({ error: 'Invalid token' }, 400); - } - - const authToken = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0); - const refreshToken = await createRefreshToken(user.id); - - return c.json({ - user: { - id: user.id, - email: user.email, - name: user.name, - role: user.role, - isClaimed: user.isClaimed, - phone: user.phone, - rucNumber: user.rucNumber, - languagePreference: user.languagePreference, - }, - token: authToken, - refreshToken, - }); -}); - -// Request password reset -auth.post('/password-reset/request', authRateLimit, zValidator('json', passwordResetRequestSchema), async (c) => { - const { email } = c.req.valid('json'); - - const user = await dbGet( - (db as any).select().from(users).where(eq((users as any).email, email)) - ); - - if (!user) { - // Don't reveal if email exists - return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' }); - } - - if (user.accountStatus === 'suspended') { - return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' }); - } - - // Create reset token (expires in 30 minutes) - const token = await createMagicLinkToken(user.id, 'reset_password', 30); - const resetLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/reset-password?token=${token}`; - - // Send email - try { - await sendEmail({ - to: email, - subject: 'Reset Your Spanglish Password', - html: ` -

Reset Your Password

-

Click the link below to reset your password. This link expires in 30 minutes.

-

Reset Password

-

Or copy this link: ${resetLink}

-

If you didn't request this, you can safely ignore this email.

- `, - }); - } catch (error) { - console.error('Failed to send password reset email:', error); - } - - return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' }); -}); - -// Reset password -auth.post('/password-reset/confirm', authRateLimit, zValidator('json', passwordResetSchema), async (c) => { - const { token, password } = c.req.valid('json'); - - // Validate password strength - const passwordValidation = validatePassword(password); - if (!passwordValidation.valid) { - return c.json({ error: passwordValidation.error }, 400); - } - - const verification = await verifyMagicLinkToken(token, 'reset_password'); - - if (!verification.valid) { - return c.json({ error: verification.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, verification.userId)); - - // Invalidate all existing sessions/JWTs for security - await invalidateAllUserSessions(verification.userId!); - await bumpTokenVersion(verification.userId!); - - return c.json({ message: 'Password reset successfully. Please log in with your new password.' }); -}); - -// Claim unclaimed account -auth.post('/claim-account/request', authRateLimit, zValidator('json', magicLinkRequestSchema), async (c) => { - const { email } = c.req.valid('json'); - - const user = await dbGet( - (db as any).select().from(users).where(eq((users as any).email, email)) - ); - - if (!user) { - return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' }); - } - - if (user.isClaimed && user.accountStatus !== 'unclaimed') { - return c.json({ error: 'Account is already claimed' }, 400); - } - - // Create claim token (expires in 1 hour) - const token = await createMagicLinkToken(user.id, 'claim_account', 60); - const claimLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/claim-account?token=${token}`; - - // Send email - try { - await sendEmail({ - to: email, - subject: 'Claim Your Spanglish Account', - html: ` -

Claim Your Account

-

An account was created for you during booking. Click below to set up your login credentials.

-

Claim Account

-

Or copy this link: ${claimLink}

-

This link expires in 1 hour.

- `, - }); - } catch (error) { - console.error('Failed to send claim account email:', error); - } - - return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' }); -}); - -// Complete account claim -auth.post('/claim-account/confirm', authRateLimit, zValidator('json', claimAccountSchema), async (c) => { - const { token, password } = c.req.valid('json'); - - const verification = await verifyMagicLinkToken(token, 'claim_account'); - - if (!verification.valid) { - return c.json({ error: verification.error }, 400); - } - - const passwordValidation = validatePassword(password); - if (!passwordValidation.valid) { - return c.json({ error: passwordValidation.error }, 400); - } - - const now = getNow(); - // Only set a password here. Linking a Google account requires a verified Google - // ID token via /google; we never trust a client-supplied googleId. - const updates: Record = { - isClaimed: toDbBool(true), - accountStatus: 'active', - password: await hashPassword(password), - updatedAt: now, - }; - - await (db as any) - .update(users) - .set(updates) - .where(eq((users as any).id, verification.userId)); - - const user = await dbGet( - (db as any).select().from(users).where(eq((users as any).id, verification.userId)) - ); - - const authToken = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0); - const refreshToken = await createRefreshToken(user.id); - - return c.json({ - user: { - id: user.id, - email: user.email, - name: user.name, - role: user.role, - isClaimed: user.isClaimed, - phone: user.phone, - rucNumber: user.rucNumber, - languagePreference: user.languagePreference, - }, - token: authToken, - refreshToken, - message: 'Account claimed successfully!', - }); -}); - -// Google OAuth login/register -auth.post('/google', authRateLimit, zValidator('json', googleAuthSchema), async (c) => { - const { credential } = c.req.valid('json'); - - try { - // Verify the Google ID token. Google's tokeninfo endpoint validates the - // signature and expiry server-side; we additionally enforce the audience so a - // token minted for a different OAuth client cannot be replayed against us. - const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(credential)}`); - - if (!response.ok) { - return c.json({ error: 'Invalid Google token' }, 400); - } - - const googleData = await response.json() as { - sub: string; - email: string; - name: string; - email_verified: string; - aud?: string; - exp?: string; - }; - - // email_verified can be returned as boolean true or string "true" - if (String(googleData.email_verified) !== 'true') { - return c.json({ error: 'Google email not verified' }, 400); - } - - // Enforce audience when a client ID is configured (closes token-confusion attacks) - const expectedAud = process.env.GOOGLE_CLIENT_ID; - if (expectedAud) { - if (googleData.aud !== expectedAud) { - return c.json({ error: 'Invalid Google token audience' }, 400); - } - } else { - console.warn('[auth] GOOGLE_CLIENT_ID is not set; skipping audience verification for Google login.'); - } - - // Reject expired tokens (defense-in-depth; tokeninfo also rejects them) - if (googleData.exp && Number(googleData.exp) * 1000 < Date.now()) { - return c.json({ error: 'Google token expired' }, 400); - } - - const { sub: googleId, email, name } = googleData; - - // Check if user exists by email or google_id - let user = await dbGet( - (db as any).select().from(users).where(eq((users as any).email, email)) - ); - - if (!user) { - // Check by google_id - user = await dbGet( - (db as any).select().from(users).where(eq((users as any).googleId, googleId)) - ); - } - - const now = getNow(); - - if (user) { - // User exists - link Google account if not already linked - if (user.accountStatus === 'suspended') { - return c.json({ error: 'Account is suspended. Please contact support.' }, 403); - } - - if (!user.googleId) { - await (db as any) - .update(users) - .set({ - googleId, - isClaimed: toDbBool(true), - accountStatus: 'active', - updatedAt: now, - }) - .where(eq((users as any).id, user.id)); - } - - // Refresh user data - user = await dbGet( - (db as any).select().from(users).where(eq((users as any).id, user.id)) - ); - } else { - // Create new user - const firstUser = await isFirstUser(); - const id = generateId(); - - const newUser = { - id, - email, - password: null, - name, - phone: null, - role: firstUser ? 'admin' : 'user', - languagePreference: null, - isClaimed: toDbBool(true), - googleId, - rucNumber: null, - accountStatus: 'active', - createdAt: now, - updatedAt: now, - }; - - await (db as any).insert(users).values(newUser); - user = newUser; - } - - const authToken = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0); - const refreshToken = await createRefreshToken(user.id); - - return c.json({ - user: { - id: user.id, - email: user.email, - name: user.name, - role: user.role, - isClaimed: user.isClaimed, - phone: user.phone, - rucNumber: user.rucNumber, - languagePreference: user.languagePreference, - }, - token: authToken, - refreshToken, - }); - } catch (error) { - console.error('Google auth error:', error); - return c.json({ error: 'Failed to authenticate with Google' }, 500); - } -}); - -// Get current user -auth.get('/me', async (c) => { - const user = await getAuthUser(c); - - if (!user) { - return c.json({ error: 'Unauthorized' }, 401); - } - - return c.json({ - user: { - id: user.id, - email: user.email, - name: user.name, - role: user.role, - phone: user.phone, - isClaimed: user.isClaimed, - rucNumber: user.rucNumber, - languagePreference: user.languagePreference, - accountStatus: user.accountStatus, - createdAt: user.createdAt, - }, - }); -}); - -// Change password (authenticated users) -auth.post('/change-password', requireAuth(), zValidator('json', changePasswordSchema), async (c) => { - const user = (c as any).get('user') as AuthUser; - const { currentPassword, newPassword } = c.req.valid('json'); - - // Validate new password - const passwordValidation = validatePassword(newPassword); - if (!passwordValidation.valid) { - return c.json({ error: passwordValidation.error }, 400); - } - - // Verify current password if user has one - const existingHash = await getUserPasswordHash(user.id); - if (existingHash) { - const validPassword = await verifyPassword(currentPassword, existingHash); - if (!validPassword) { - return c.json({ error: 'Current password is incorrect' }, 400); - } - } - - const hashedPassword = await hashPassword(newPassword); - const now = getNow(); - - await (db as any) - .update(users) - .set({ - password: hashedPassword, - updatedAt: now, - }) - .where(eq((users as any).id, user.id)); - - // Invalidate all previously issued JWTs so a stolen old token can't outlive the change, - // then hand the current client a fresh token so it stays logged in on this device. - await bumpTokenVersion(user.id); - const refreshedUser = await dbGet( - (db as any).select().from(users).where(eq((users as any).id, user.id)) - ); - const newToken = await createToken(user.id, user.email, user.role, refreshedUser?.tokenVersion ?? 0); - - return c.json({ message: 'Password changed successfully', token: newToken }); -}); - -// Logout - invalidate all previously issued JWTs for this user (logout everywhere) -auth.post('/logout', async (c) => { - const user = await getAuthUser(c); - if (user) { - await invalidateAllUserSessions(user.id); - await bumpTokenVersion(user.id); - } - return c.json({ message: 'Logged out successfully' }); -}); - -export default auth; diff --git a/backend/src/routes/authExt.ts b/backend/src/routes/authExt.ts new file mode 100644 index 0000000..f834017 --- /dev/null +++ b/backend/src/routes/authExt.ts @@ -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( + (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; diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts index 4e2ec47..7ca73ee 100644 --- a/backend/src/routes/dashboard.ts +++ b/backend/src/routes/dashboard.ts @@ -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( + (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( - (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' }); }); diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index ff2c0fd..776dbfa 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, eventPaymentOverrides, siteSettings, isSqlite } from '../db/index.js'; import { eq, and, or, sql, inArray } from 'drizzle-orm'; import { requireAuth, getAuthUser } from '../lib/auth.js'; -import { generateId, generateTicketCode, getNow, toDbDate, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js'; +import { generateId, generateTicketCode, getNow, toDbDate, toDbBool, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js'; import { createInvoice, isLNbitsConfigured, LNBITS_INVOICE_EXPIRY_SECONDS } from '../lib/lnbits.js'; import { rateLimitMiddleware } from '../lib/rateLimit.js'; import emailService from '../lib/email.js'; @@ -164,12 +164,15 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { user = { id: userId, email: data.email, - password: '', // No password for guest bookings + password: null, // No password for guest bookings; set on claim (Better Auth credential account) name: fullName, phone: data.phone || null, role: 'user', languagePreference: null, rucNumber, + isClaimed: toDbBool(false), + accountStatus: 'unclaimed', + emailVerified: false, createdAt: now, updatedAt: now, }; @@ -1429,11 +1432,14 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff']) user = { id: userId, email: attendeeEmail, - password: '', + password: null, name: adminFullName, phone: data.phone || null, role: 'user', languagePreference: null, + isClaimed: toDbBool(false), + accountStatus: 'unclaimed', + emailVerified: false, createdAt: now, updatedAt: now, }; @@ -1573,11 +1579,14 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z user = { id: userId, email: attendeeEmail, - password: '', + password: null, name: fullName, phone: data.phone || null, role: 'user', languagePreference: null, + isClaimed: toDbBool(false), + accountStatus: 'unclaimed', + emailVerified: false, createdAt: now, updatedAt: now, }; diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index 5c21728..c23e73a 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { db, dbGet, dbAll, users, tickets, events, payments, magicLinkTokens, userSessions, invoices, auditLogs, emailLogs, paymentOptions, legalPages, siteSettings } from '../db/index.js'; import { eq, desc, sql, and, gte, lte } from 'drizzle-orm'; import { requireAuth } from '../lib/auth.js'; +import { authSessions } from '../db/auth-schema.js'; import { getNow, toDbDate } from '../lib/utils.js'; interface UserContext { @@ -175,11 +176,28 @@ usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing', return c.json({ error: 'User not found' }, 404); } + // Keep the Better Auth admin `banned` flag in sync with accountStatus so + // sign-in is blocked at the auth layer too, and kill live sessions on + // suspension so it takes effect immediately (sessions are DB-validated on + // every request by both the backend and the photo API). + const statusMirror: Record = {}; + if (data.accountStatus === 'suspended') { + statusMirror.banned = true; + statusMirror.banReason = 'Suspended by admin'; + } else if (data.accountStatus) { + statusMirror.banned = false; + statusMirror.banReason = null; + } + await (db as any) .update(users) - .set({ ...data, updatedAt: getNow() }) + .set({ ...data, ...statusMirror, updatedAt: getNow() }) .where(eq((users as any).id, id)); - + + if (data.accountStatus === 'suspended') { + await (db as any).delete(authSessions).where(eq((authSessions as any).userId, id)); + } + const updated = await dbGet( (db as any) .select({ diff --git a/backend/tsconfig.json b/backend/tsconfig.json index d483629..e523eeb 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -8,7 +8,7 @@ "skipLibCheck": true, "outDir": "./dist", "rootDir": "./src", - "declaration": true, + "declaration": false, "resolveJsonModule": true }, "include": ["src/**/*"], diff --git a/frontend/package.json b/frontend/package.json index 54c9c83..3863ce3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "@tiptap/pm": "^3.18.0", "@tiptap/react": "^3.18.0", "@tiptap/starter-kit": "^3.18.0", + "better-auth": "1.6.25", "clsx": "^2.1.1", "html5-qrcode": "^2.3.8", "next": "^14.2.4", diff --git a/frontend/src/app/(public)/auth/claim-account/page.tsx b/frontend/src/app/(public)/auth/claim-account/page.tsx index 1a9debf..15989aa 100644 --- a/frontend/src/app/(public)/auth/claim-account/page.tsx +++ b/frontend/src/app/(public)/auth/claim-account/page.tsx @@ -1,7 +1,7 @@ 'use client'; -import { useState, Suspense } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; +import { useEffect, useState, Suspense } from 'react'; +import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { useLanguage } from '@/context/LanguageContext'; import { useAuth } from '@/context/AuthContext'; @@ -9,29 +9,48 @@ import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; import { authApi } from '@/lib/api'; +import { authClient } from '@/lib/auth-client'; import toast from 'react-hot-toast'; +/** + * Progressive-account claim. The claim email contains a magic link that signs + * the user in (via /auth/magic-link) and redirects here; this page then asks + * for a password and completes the claim against /api/auth-ext/claim-account. + */ function ClaimAccountContent() { const router = useRouter(); - const searchParams = useSearchParams(); const { locale: language } = useLanguage(); - const { setAuthData } = useAuth(); + const { refreshUser } = useAuth(); const [loading, setLoading] = useState(false); + const [checking, setChecking] = useState(true); + const [hasSession, setHasSession] = useState(false); + const [alreadyClaimed, setAlreadyClaimed] = useState(false); const [formData, setFormData] = useState({ password: '', confirmPassword: '', }); - const token = searchParams.get('token'); + useEffect(() => { + let cancelled = false; + authClient + .getSession() + .then(({ data }) => { + if (cancelled) return; + const user: any = data?.user; + setHasSession(!!user); + setAlreadyClaimed(!!user && user.isClaimed && user.accountStatus === 'active'); + }) + .finally(() => { + if (!cancelled) setChecking(false); + }); + return () => { + cancelled = true; + }; + }, []); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!token) { - toast.error(language === 'es' ? 'Token no válido' : 'Invalid token'); - return; - } - if (formData.password !== formData.confirmPassword) { toast.error(language === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match'); return; @@ -49,8 +68,8 @@ function ClaimAccountContent() { setLoading(true); try { - const result = await authApi.confirmClaimAccount(token, { password: formData.password }); - setAuthData({ user: result.user, token: result.token }); + await authApi.confirmClaimAccount(formData.password); + await refreshUser(); toast.success(language === 'es' ? '¡Cuenta activada!' : 'Account activated!'); router.push('/dashboard'); } catch (error: any) { @@ -60,7 +79,21 @@ function ClaimAccountContent() { } }; - if (!token) { + if (checking) { + return ( +
+
+
+ ); + } + + if (alreadyClaimed) { + // Signed-in and already active: nothing to claim + router.push('/dashboard'); + return null; + } + + if (!hasSession) { return (
@@ -76,8 +109,8 @@ function ClaimAccountContent() {

{language === 'es' - ? 'Este enlace de activación no es válido o ha expirado.' - : 'This activation link is invalid or has expired.'} + ? 'Este enlace de activación no es válido o ha expirado. Solicita uno nuevo con "Enlace por Email" en la página de inicio de sesión.' + : 'This activation link is invalid or has expired. Request a new one using "Email Link" on the login page.'}

diff --git a/frontend/src/app/(public)/auth/magic-link/page.tsx b/frontend/src/app/(public)/auth/magic-link/page.tsx index a068cd2..28c4508 100644 --- a/frontend/src/app/(public)/auth/magic-link/page.tsx +++ b/frontend/src/app/(public)/auth/magic-link/page.tsx @@ -6,6 +6,7 @@ import { useLanguage } from '@/context/LanguageContext'; import { useAuth } from '@/context/AuthContext'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; +import { safeInternalPath } from '@/lib/safeRedirect'; import toast from 'react-hot-toast'; function MagicLinkContent() { @@ -18,11 +19,12 @@ function MagicLinkContent() { const verificationAttempted = useRef(false); const token = searchParams.get('token'); + const callbackURL = safeInternalPath(searchParams.get('callbackURL'), '/dashboard'); useEffect(() => { // Prevent duplicate verification attempts (React StrictMode double-invokes effects) if (verificationAttempted.current) return; - + if (token) { verificationAttempted.current = true; verifyToken(); @@ -34,11 +36,17 @@ function MagicLinkContent() { const verifyToken = async () => { try { - await loginWithMagicLink(token!); + const user = await loginWithMagicLink(token!); setStatus('success'); toast.success(language === 'es' ? '¡Bienvenido!' : 'Welcome!'); + // Unclaimed accounts must finish the claim (set a password) before the + // rest of the API will accept their session. + const destination = + user && (user.isClaimed === false || user.accountStatus === 'unclaimed') + ? '/auth/claim-account' + : callbackURL; setTimeout(() => { - router.push('/dashboard'); + router.push(destination); }, 1500); } catch (err: any) { setStatus('error'); diff --git a/frontend/src/app/(public)/dashboard/components/AccountTab.tsx b/frontend/src/app/(public)/dashboard/components/AccountTab.tsx index 011d48c..a6a4a14 100644 --- a/frontend/src/app/(public)/dashboard/components/AccountTab.tsx +++ b/frontend/src/app/(public)/dashboard/components/AccountTab.tsx @@ -25,7 +25,7 @@ interface AccountTabProps { */ export default function AccountTab({ onUpdate }: AccountTabProps) { const { locale } = useLanguage(); - const { user, updateUser, logout } = useAuth(); + const { user, updateUser } = useAuth(); const [profile, setProfile] = useState(null); const [sessions, setSessions] = useState([]); @@ -187,15 +187,17 @@ export default function AccountTab({ onUpdate }: AccountTabProps) { if ( !confirm( locale === 'es' - ? '¿Cerrar todas las sesiones? Serás desconectado.' - : 'Log out of all sessions? You will be logged out.' + ? '¿Cerrar todas las otras sesiones? Esta sesión permanecerá activa.' + : 'Log out of all other sessions? This session stays signed in.' ) ) return; try { await dashboardApi.revokeAllSessions(); - toast.success(locale === 'es' ? 'Todas las sesiones cerradas' : 'All sessions revoked'); - logout(); + toast.success( + locale === 'es' ? 'Todas las otras sesiones cerradas' : 'All other sessions revoked' + ); + loadData(); } catch (error) { toast.error(locale === 'es' ? 'Error' : 'Failed'); } @@ -477,7 +479,7 @@ export default function AccountTab({ onUpdate }: AccountTabProps) {

) : (
- {sessions.map((session, index) => ( + {sessions.map((session) => (
- {index === 0 ? ( + {session.current ? ( {locale === 'es' ? 'Esta sesión' : 'This session'} diff --git a/frontend/src/app/(public)/dashboard/page.tsx b/frontend/src/app/(public)/dashboard/page.tsx index 255130a..e181758 100644 --- a/frontend/src/app/(public)/dashboard/page.tsx +++ b/frontend/src/app/(public)/dashboard/page.tsx @@ -23,7 +23,7 @@ type Tab = 'overview' | 'tickets' | 'payments' | 'account'; export default function DashboardPage() { const router = useRouter(); const { locale } = useLanguage(); - const { user, isLoading: authLoading, token } = useAuth(); + const { user, isLoading: authLoading } = useAuth(); const [activeTab, setActiveTab] = useState('overview'); const [nextEvent, setNextEvent] = useState(null); @@ -36,11 +36,13 @@ export default function DashboardPage() { router.push('/login'); return; } - if (user && token) { + // Auth rides on the httpOnly session cookie; once the user has resolved + // the API calls are authenticated automatically. + if (user) { loadDashboardData(); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [user, authLoading, token]); + }, [user, authLoading]); const loadDashboardData = async () => { setLoading(true); diff --git a/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx b/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx index c7fd5ee..101025e 100644 --- a/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx +++ b/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx @@ -48,7 +48,7 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien // viewer's own auth token attached. useEffect(() => { if (initial) return; - if (authLoading) return; // wait so the Bearer token is available + if (authLoading) return; // wait until the session state has resolved let cancelled = false; setLoading(true); const fetcher = eventSlug @@ -354,6 +354,45 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien /> )}
+ + {/* Call to action: send attendees to their dashboard, everyone else to + the next event. Auth state comes from the same useAuth() the gate + pages use. */} +
+
+

+ {user + ? es + ? '¿Listo para lo que sigue?' + : 'Ready for what’s next?' + : es + ? '¿Te gustó lo que viste?' + : 'Liked what you saw?'} +

+

+ {user + ? es + ? 'Revisa tus entradas y próximos eventos en tu panel.' + : 'Check your tickets and upcoming events from your dashboard.' + : es + ? 'Únete a nuestro próximo evento y sé parte de las próximas fotos.' + : 'Join our next event and be part of the next set of photos.'} +

+
+ + + +
+
+
); } diff --git a/frontend/src/app/admin/emails/page.tsx b/frontend/src/app/admin/emails/page.tsx index b002db3..7eb148b 100644 --- a/frontend/src/app/admin/emails/page.tsx +++ b/frontend/src/app/admin/emails/page.tsx @@ -107,11 +107,7 @@ export default function AdminEmailsPage() { const loadEvents = async () => { try { - const res = await fetch('/api/events', { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`, - }, - }); + const res = await fetch('/api/events', { credentials: 'same-origin' }); if (res.ok) { const data = await res.json(); setEvents(data.events || []); @@ -169,9 +165,7 @@ export default function AdminEmailsPage() { try { const res = await fetch(`/api/events/${composeForm.eventId}/attendees`, { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`, - }, + credentials: 'same-origin', }); if (res.ok) { const data = await res.json(); diff --git a/frontend/src/app/admin/gallery/page.tsx b/frontend/src/app/admin/gallery/page.tsx index ab96b7d..65cf2bf 100644 --- a/frontend/src/app/admin/gallery/page.tsx +++ b/frontend/src/app/admin/gallery/page.tsx @@ -35,11 +35,7 @@ export default function AdminGalleryPage() { const loadMedia = async () => { try { // We need to call the media API - let's add it if it doesn't exist - const res = await fetch('/api/media', { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`, - }, - }); + const res = await fetch('/api/media', { credentials: 'same-origin' }); if (res.ok) { const data = await res.json(); setMedia(data.media || []); diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 3769260..602c5c6 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -1,8 +1,8 @@ 'use client'; import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react'; - -const API_BASE = process.env.NEXT_PUBLIC_API_URL || ''; +import { authClient } from '@/lib/auth-client'; +import { fetchApi } from '@/lib/api/client'; interface User { id: string; @@ -18,18 +18,19 @@ interface User { interface AuthContextType { user: User | null; + /** Always null: auth moved to httpOnly session cookies (Better Auth). Kept for compat. */ token: string | null; isLoading: boolean; isAdmin: boolean; hasAdminAccess: boolean; login: (email: string, password: string) => Promise; loginWithGoogle: (credential: string) => Promise; - loginWithMagicLink: (token: string) => Promise; + loginWithMagicLink: (token: string) => Promise; register: (data: RegisterData) => Promise; logout: () => void; updateUser: (user: User) => void; - setAuthData: (data: { user: User; token: string }) => void; - refreshUser: () => Promise; + setAuthData: (data: { user: User; token?: string }) => void; + refreshUser: () => Promise; } interface RegisterData { @@ -42,178 +43,139 @@ interface RegisterData { const AuthContext = createContext(undefined); -const TOKEN_KEY = 'spanglish-token'; -const USER_KEY = 'spanglish-user'; -const AUTH_COOKIE = 'spanglish-auth'; +// Legacy storage from the pre-Better-Auth JWT era; cleared once on mount. +const LEGACY_TOKEN_KEY = 'spanglish-token'; +const LEGACY_USER_KEY = 'spanglish-user'; +const LEGACY_AUTH_COOKIE = 'spanglish-auth'; -function setAuthCookie() { - if (typeof document === 'undefined') return; - document.cookie = `${AUTH_COOKIE}=1; path=/; max-age=${60 * 60 * 24}; SameSite=Lax`; +function mapSessionUser(sessionUser: any): User { + return { + id: sessionUser.id, + email: sessionUser.email, + name: sessionUser.name, + role: sessionUser.role ?? 'user', + phone: sessionUser.phone ?? undefined, + languagePreference: sessionUser.languagePreference ?? undefined, + isClaimed: Boolean(sessionUser.isClaimed ?? true), + rucNumber: sessionUser.rucNumber ?? undefined, + accountStatus: sessionUser.accountStatus ?? 'active', + }; } -function clearAuthCookie() { - if (typeof document === 'undefined') return; - document.cookie = `${AUTH_COOKIE}=; path=/; max-age=0; SameSite=Lax`; +function messageFrom(error: { message?: string; code?: string; status?: number } | null, fallback: string): string { + return error?.message || fallback; } export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); - const [token, setToken] = useState(null); const [isLoading, setIsLoading] = useState(true); - const refreshUser = useCallback(async () => { - const currentToken = localStorage.getItem(TOKEN_KEY); - if (!currentToken) return; - + const refreshUser = useCallback(async (): Promise => { try { - const res = await fetch(`${API_BASE}/api/auth/me`, { - headers: { - 'Authorization': `Bearer ${currentToken}`, - 'Content-Type': 'application/json', - }, - }); - - if (res.ok) { - const data = await res.json(); - setUser(data.user); - localStorage.setItem(USER_KEY, JSON.stringify(data.user)); - setAuthCookie(); - } else if (res.status === 401) { - // Token is invalid, clear auth state - setToken(null); - setUser(null); - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(USER_KEY); - clearAuthCookie(); + const { data } = await authClient.getSession(); + if (data?.user) { + const mapped = mapSessionUser(data.user); + setUser(mapped); + return mapped; } + setUser(null); + return null; } catch (error) { - // Network error, keep using cached data + // Network error: keep current state console.error('Failed to refresh user data:', error); + return null; } }, []); useEffect(() => { - // Load auth state from localStorage - const savedToken = localStorage.getItem(TOKEN_KEY); - const savedUser = localStorage.getItem(USER_KEY); - - if (savedToken && savedUser) { - // Guard against corrupt/tampered localStorage so the whole app doesn't crash. - let parsedUser: User | null = null; - try { - parsedUser = JSON.parse(savedUser); - } catch { - parsedUser = null; - } - - if (parsedUser) { - setToken(savedToken); - setUser(parsedUser); - // Refresh user data from server to get latest role/permissions (source of truth) - refreshUser().finally(() => setIsLoading(false)); - } else { - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(USER_KEY); - setIsLoading(false); - } - } else { - setIsLoading(false); + // One-time cleanup of legacy JWT-era storage + try { + localStorage.removeItem(LEGACY_TOKEN_KEY); + localStorage.removeItem(LEGACY_USER_KEY); + document.cookie = `${LEGACY_AUTH_COOKIE}=; path=/; max-age=0; SameSite=Lax`; + } catch { + /* SSR / storage unavailable */ } + + refreshUser().finally(() => setIsLoading(false)); }, [refreshUser]); - const setAuthData = useCallback((data: { user: User; token: string }) => { - setToken(data.token); - setUser(data.user); - localStorage.setItem(TOKEN_KEY, data.token); - localStorage.setItem(USER_KEY, JSON.stringify(data.user)); - setAuthCookie(); - }, []); - const login = async (email: string, password: string) => { - const res = await fetch(`${API_BASE}/api/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }), - }); - - if (!res.ok) { - const error = await res.json(); - throw new Error(error.error || 'Login failed'); + const { error } = await authClient.signIn.email({ email, password }); + if (error) { + throw new Error(messageFrom(error, 'Login failed')); } - - const data = await res.json(); - setAuthData(data); + await refreshUser(); }; const loginWithGoogle = async (credential: string) => { - const res = await fetch(`${API_BASE}/api/auth/google`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ credential }), + const { error } = await authClient.signIn.social({ + provider: 'google', + idToken: { token: credential }, }); - - if (!res.ok) { - const error = await res.json(); - throw new Error(error.error || 'Google login failed'); + if (error) { + throw new Error(messageFrom(error, 'Google login failed')); } - - const data = await res.json(); - setAuthData(data); + await refreshUser(); }; - const loginWithMagicLink = async (magicToken: string) => { - const res = await fetch(`${API_BASE}/api/auth/magic-link/verify`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token: magicToken }), - }); - - if (!res.ok) { - const error = await res.json(); - throw new Error(error.error || 'Magic link login failed'); + const loginWithMagicLink = async (magicToken: string): Promise => { + const { error } = await authClient.magicLink.verify({ query: { token: magicToken } }); + if (error) { + throw new Error(messageFrom(error, 'Invalid or expired link')); } - - const data = await res.json(); - setAuthData(data); + return refreshUser(); }; const register = async (registerData: RegisterData) => { - const res = await fetch(`${API_BASE}/api/auth/register`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(registerData), - }); - - if (!res.ok) { - const error = await res.json(); - throw new Error(error.error || 'Registration failed'); + const { error } = await authClient.signUp.email({ + email: registerData.email, + password: registerData.password, + name: registerData.name, + phone: registerData.phone || undefined, + languagePreference: registerData.languagePreference || undefined, + } as any); + if (error) { + // Existing-but-unclaimed accounts (created during guest booking) should + // point the user to the claim flow instead of a bare "already exists". + if (error.code === 'USER_ALREADY_EXISTS') { + try { + const { canClaim } = await fetchApi<{ canClaim: boolean }>( + `/api/auth-ext/claim-eligibility?email=${encodeURIComponent(registerData.email)}` + ); + if (canClaim) { + const err = new Error( + 'This email has an unclaimed account from a previous booking. Use "Email Link" on the login page to claim it.' + ); + (err as any).canClaim = true; + throw err; + } + } catch (e: any) { + if (e?.canClaim) throw e; + /* eligibility check failed: fall through to generic message */ + } + } + throw new Error(messageFrom(error, 'Registration failed')); } - - const data = await res.json(); - setAuthData(data); + await refreshUser(); }; const logout = useCallback(() => { - // Best-effort server-side invalidation (bumps token version so the JWT can't be reused). - const currentToken = localStorage.getItem(TOKEN_KEY); - if (currentToken) { - fetch(`${API_BASE}/api/auth/logout`, { - method: 'POST', - headers: { 'Authorization': `Bearer ${currentToken}` }, - }).catch(() => { - // Ignore network errors; local state is cleared regardless. - }); - } - setToken(null); + // Best-effort server-side revocation; local state clears regardless. + authClient.signOut().catch(() => { + /* ignore network errors */ + }); setUser(null); - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(USER_KEY); - clearAuthCookie(); }, []); const updateUser = useCallback((updatedUser: User) => { setUser(updatedUser); - localStorage.setItem(USER_KEY, JSON.stringify(updatedUser)); + }, []); + + // Compat shim for callers that used to push {user, token} after custom auth + // flows; the session cookie is already set by then, so only state updates. + const setAuthData = useCallback((data: { user: User; token?: string }) => { + setUser(data.user); }, []); const isAdmin = user?.role === 'admin' || user?.role === 'organizer'; @@ -221,16 +183,16 @@ export function AuthProvider({ children }: { children: ReactNode }) { return ( - fetchApi<{ message: string }>('/api/auth/magic-link/request', { - method: 'POST', - body: JSON.stringify({ email }), - }), + // Magic link login. Enumeration-safe UX parity: unknown emails resolve with + // the same generic message (magic links never create accounts server-side); + // only rate limiting surfaces as an error. + requestMagicLink: async (email: string, callbackURL: string = '/dashboard') => { + const { error } = await authClient.signIn.magicLink({ email, callbackURL }); + if (error && error.status === 429) { + throwIfError(error, 'Too many requests. Please try again later.'); + } + return { message: 'If an account exists with this email, a login link has been sent.' }; + }, - verifyMagicLink: (token: string) => - fetchApi<{ user: User; token: string; refreshToken: string }>('/api/auth/magic-link/verify', { - method: 'POST', - body: JSON.stringify({ token }), - }), + verifyMagicLink: async (token: string) => { + const { data, error } = await authClient.magicLink.verify({ query: { token } }); + throwIfError(error, 'Invalid or expired token'); + return data; + }, - // Password reset - requestPasswordReset: (email: string) => - fetchApi<{ message: string }>('/api/auth/password-reset/request', { - method: 'POST', - body: JSON.stringify({ email }), - }), + // Password reset (Better Auth is enumeration-safe here by default) + requestPasswordReset: async (email: string) => { + const { error } = await authClient.requestPasswordReset({ + email, + redirectTo: '/auth/reset-password', + }); + throwIfError(error, 'Failed to request password reset'); + return { message: 'If an account exists with this email, a password reset link has been sent.' }; + }, - confirmPasswordReset: (token: string, password: string) => - fetchApi<{ message: string }>('/api/auth/password-reset/confirm', { - method: 'POST', - body: JSON.stringify({ token, password }), - }), + confirmPasswordReset: async (token: string, password: string) => { + const { error } = await authClient.resetPassword({ newPassword: password, token }); + throwIfError(error, 'Invalid or expired token'); + return { message: 'Password reset successfully. Please log in with your new password.' }; + }, - // Account claiming + // Account claiming: a magic link that lands on the claim page, where the + // session-holding user sets a password via /api/auth-ext/claim-account. requestClaimAccount: (email: string) => - fetchApi<{ message: string }>('/api/auth/claim-account/request', { + authApi.requestMagicLink(email, '/auth/claim-account'), + + confirmClaimAccount: (password: string) => + fetchApi<{ user: User; message: string }>('/api/auth-ext/claim-account', { method: 'POST', - body: JSON.stringify({ email }), + body: JSON.stringify({ password }), }), - confirmClaimAccount: (token: string, data: { password?: string; googleId?: string }) => - fetchApi<{ user: User; token: string; refreshToken: string; message: string }>( - '/api/auth/claim-account/confirm', - { - method: 'POST', - body: JSON.stringify({ token, ...data }), - } + claimEligibility: (email: string) => + fetchApi<{ canClaim: boolean }>( + `/api/auth-ext/claim-eligibility?email=${encodeURIComponent(email)}` ), - // Google OAuth - googleAuth: (credential: string) => - fetchApi<{ user: User; token: string; refreshToken: string }>('/api/auth/google', { - method: 'POST', - body: JSON.stringify({ credential }), - }), + // Google Identity Services credential (ID token) sign-in + googleAuth: async (credential: string) => { + const { data, error } = await authClient.signIn.social({ + provider: 'google', + idToken: { token: credential }, + }); + throwIfError(error, 'Google login failed'); + return data; + }, - // Change password - changePassword: (currentPassword: string, newPassword: string) => - fetchApi<{ message: string }>('/api/auth/change-password', { - method: 'POST', - body: JSON.stringify({ currentPassword, newPassword }), - }), + // Change password; other sessions are revoked so a stolen session can't + // outlive the change (this device stays signed in). + changePassword: async (currentPassword: string, newPassword: string) => { + const { error } = await authClient.changePassword({ + currentPassword, + newPassword, + revokeOtherSessions: true, + }); + throwIfError(error, 'Failed to change password'); + return { message: 'Password changed successfully' }; + }, // Get current user - me: () => fetchApi<{ user: User }>('/api/auth/me'), + me: async (): Promise<{ user: User | null }> => { + const { data, error } = await authClient.getSession(); + throwIfError(error, 'Failed to load session'); + return { user: (data?.user as unknown as User) ?? null }; + }, }; diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 5df222a..e0d6ef7 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -1,30 +1,25 @@ export const API_BASE = process.env.NEXT_PUBLIC_API_URL || ''; +// Auth rides on the Better Auth httpOnly session cookie. With API_BASE unset +// (same-origin via Next rewrites) 'same-origin' sends it; a cross-origin +// API_BASE needs 'include' plus CORS credentials on the backend. +const CREDENTIALS: RequestCredentials = API_BASE ? 'include' : 'same-origin'; + export interface ApiError { error: string; } -/** Read the stored auth token (browser only). */ -export function getToken(): string | null { - return typeof window !== 'undefined' ? localStorage.getItem('spanglish-token') : null; -} - export async function fetchApi( endpoint: string, options: RequestInit = {} ): Promise { - const token = getToken(); - const headers: HeadersInit = { 'Content-Type': 'application/json', ...options.headers, }; - if (token) { - (headers as Record)['Authorization'] = `Bearer ${token}`; - } - const res = await fetch(`${API_BASE}${endpoint}`, { + credentials: CREDENTIALS, ...options, headers, }); @@ -53,11 +48,7 @@ export async function fetchBlob( endpoint: string, fallbackFilename: string ): Promise<{ blob: Blob; filename: string }> { - const token = getToken(); - const headers: Record = {}; - if (token) headers['Authorization'] = `Bearer ${token}`; - - const res = await fetch(`${API_BASE}${endpoint}`, { headers }); + const res = await fetch(`${API_BASE}${endpoint}`, { credentials: CREDENTIALS }); if (!res.ok) { const errorData = await res.json().catch(() => ({ error: 'Export failed' })); throw new Error(errorData.error || 'Export failed'); diff --git a/frontend/src/lib/api/media.ts b/frontend/src/lib/api/media.ts index 7d14e1c..afbdf36 100644 --- a/frontend/src/lib/api/media.ts +++ b/frontend/src/lib/api/media.ts @@ -1,4 +1,4 @@ -import { fetchApi, API_BASE, getToken } from './client'; +import { fetchApi, API_BASE } from './client'; import type { Media } from './types'; export const mediaApi = { @@ -11,16 +11,15 @@ export const mediaApi = { }, upload: async (file: File, relatedId?: string, relatedType?: string) => { - const token = getToken(); - const formData = new FormData(); formData.append('file', file); if (relatedId) formData.append('relatedId', relatedId); if (relatedType) formData.append('relatedType', relatedType); + // Auth rides on the session cookie const res = await fetch(`${API_BASE}/api/media/upload`, { method: 'POST', - headers: token ? { 'Authorization': `Bearer ${token}` } : {}, + credentials: API_BASE ? 'include' : 'same-origin', body: formData, }); diff --git a/frontend/src/lib/api/photos.ts b/frontend/src/lib/api/photos.ts index 9fc7da3..f2ce238 100644 --- a/frontend/src/lib/api/photos.ts +++ b/frontend/src/lib/api/photos.ts @@ -1,4 +1,4 @@ -import { fetchApi, API_BASE, getToken } from './client'; +import { fetchApi, API_BASE } from './client'; // Client for the standalone photo-api Go service (photo-api/), reachable // under /api/photos via the Next rewrite (dev) or nginx (prod). @@ -94,13 +94,13 @@ export const photosApi = { }), uploadPhoto: async (galleryId: string, file: File) => { - const token = getToken(); const formData = new FormData(); formData.append('files', file); + // Auth rides on the session cookie (sent same-origin automatically) const res = await fetch(`${API_BASE}/api/photos/galleries/${galleryId}/photos`, { method: 'POST', - headers: token ? { Authorization: `Bearer ${token}` } : {}, + credentials: API_BASE ? 'include' : 'same-origin', body: formData, }); if (!res.ok) { @@ -122,8 +122,9 @@ export const photosApi = { new Promise<{ photos: Photo[] }>((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', `${API_BASE}/api/photos/galleries/${galleryId}/photos`); - const token = getToken(); - if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`); + // Auth rides on the session cookie; XHR sends same-origin cookies by + // default, withCredentials is only needed for a cross-origin API_BASE. + if (API_BASE) xhr.withCredentials = true; xhr.upload.onprogress = (e) => { if (e.lengthComputable && e.total > 0) onProgress(e.loaded / e.total); }; diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 6955a31..aa2346c 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -439,6 +439,9 @@ export interface UserSession { ipAddress?: string; lastActiveAt: string; createdAt: string; + expiresAt?: string; + /** True for the session backing the current request. */ + current?: boolean; } export interface DashboardSummary { diff --git a/frontend/src/lib/auth-client.ts b/frontend/src/lib/auth-client.ts new file mode 100644 index 0000000..659acb3 --- /dev/null +++ b/frontend/src/lib/auth-client.ts @@ -0,0 +1,22 @@ +import { createAuthClient } from 'better-auth/react'; +import { magicLinkClient, adminClient, inferAdditionalFields } from 'better-auth/client/plugins'; + +// Better Auth client. Sessions are httpOnly cookies set by the backend; with +// NEXT_PUBLIC_API_URL unset everything is same-origin through the Next.js +// rewrites (see next.config.js), so cookies flow automatically. +export const authClient = createAuthClient({ + baseURL: process.env.NEXT_PUBLIC_API_URL || '', + plugins: [ + magicLinkClient(), + adminClient(), + inferAdditionalFields({ + user: { + phone: { type: 'string', required: false }, + languagePreference: { type: 'string', required: false }, + rucNumber: { type: 'string', required: false }, + isClaimed: { type: 'boolean', required: false }, + accountStatus: { type: 'string', required: false }, + }, + }), + ], +}); diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index 5ada6b7..9526850 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -3,16 +3,24 @@ import { NextRequest, NextResponse } from 'next/server'; /** * Defense-in-depth guard for authenticated areas. * - * Auth tokens live in localStorage (not readable here), so we rely on a lightweight - * `spanglish-auth` cookie set alongside login. The API remains the authoritative gate; - * this only keeps unauthenticated visitors from loading the admin/dashboard JS shell. + * Auth is a Better Auth httpOnly session cookie, which IS visible to this + * server-side middleware (unlike client JS). The API remains the authoritative + * gate — cookie presence is not validated here; this only keeps clearly + * unauthenticated visitors from loading the admin/dashboard JS shell. */ +const SESSION_COOKIES = [ + '__Secure-spanglish.session_token', // production (useSecureCookies) + 'spanglish.session_token', // development +]; + export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; if (pathname.startsWith('/admin') || pathname.startsWith('/dashboard')) { - const hasAuthCookie = request.cookies.get('spanglish-auth')?.value === '1'; - if (!hasAuthCookie) { + const hasSessionCookie = SESSION_COOKIES.some( + (name) => !!request.cookies.get(name)?.value + ); + if (!hasSessionCookie) { const loginUrl = new URL('/login', request.url); loginUrl.searchParams.set('redirect', pathname); return NextResponse.redirect(loginUrl); diff --git a/package.json b/package.json index 66cbe08..a8a0825 100644 --- a/package.json +++ b/package.json @@ -32,5 +32,13 @@ }, "dependencies": { "next": "^16.2.10" + }, + "allowScripts": { + "esbuild@0.18.20": true, + "esbuild@0.25.12": true, + "esbuild@0.27.2": true, + "better-sqlite3@12.11.1": true, + "sharp@0.34.5": true, + "argon2@0.44.0": true } } diff --git a/photo-api/.env.example b/photo-api/.env.example index e1ea7b8..f312e0e 100644 --- a/photo-api/.env.example +++ b/photo-api/.env.example @@ -16,8 +16,14 @@ DATABASE_URL=postgresql://spanglish:password@localhost:5432/spanglish_db # DB_TYPE=sqlite # DATABASE_URL=../backend/data/spanglish.db -# MUST be identical to JWT_SECRET in backend/.env — photo-api validates the -# same HS256 tokens the backend issues. +# Secret for gallery image view tokens (HMAC). Any strong random value; does +# not need to match any backend secret. Rotating it invalidates outstanding +# image URLs for at most ~1 hour (they are re-minted per page load). +PHOTO_VIEW_SECRET=8b69468724b730dddecba3d04717cf44e6dc5b808773e7e60156d38875f0f6cd + +# DEPRECATED: fallback for PHOTO_VIEW_SECRET during the Better Auth migration +# (user auth now validates Better Auth sessions from the shared database, not +# JWTs). Set PHOTO_VIEW_SECRET and remove this. JWT_SECRET= # Public site origin, used to build share links and allow dev CORS diff --git a/photo-api/cmd/photo-api/main.go b/photo-api/cmd/photo-api/main.go index 549a1ee..406563c 100644 --- a/photo-api/cmd/photo-api/main.go +++ b/photo-api/cmd/photo-api/main.go @@ -85,7 +85,7 @@ func main() { server := &http.Server{ Addr: fmt.Sprintf(":%d", cfg.Port), - Handler: httpapi.New(cfg, db, st, auth.NewVerifier(cfg.JWTSecret, db), wrk).Handler(), + Handler: httpapi.New(cfg, db, st, auth.NewVerifier(db), wrk).Handler(), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, } diff --git a/photo-api/go.mod b/photo-api/go.mod index b25826d..d5ac8b3 100644 --- a/photo-api/go.mod +++ b/photo-api/go.mod @@ -7,7 +7,6 @@ require ( github.com/aws/aws-sdk-go-v2/config v1.32.31 github.com/aws/aws-sdk-go-v2/credentials v1.19.30 github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0 - github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.10.0 github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd diff --git a/photo-api/go.sum b/photo-api/go.sum index aadd85a..2372dc0 100644 --- a/photo-api/go.sum +++ b/photo-api/go.sum @@ -39,8 +39,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= -github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= diff --git a/photo-api/internal/auth/auth.go b/photo-api/internal/auth/auth.go index ccea98a..4115e5e 100644 --- a/photo-api/internal/auth/auth.go +++ b/photo-api/internal/auth/auth.go @@ -1,27 +1,31 @@ -// Package auth validates the JWTs minted by backend/src/lib/auth.ts: -// HS256 with the shared JWT_SECRET, issuer "spanglish", audience -// "spanglish-app", plus the same DB-backed tokenVersion / account_status -// revocation check the backend's getAuthUser performs. +// Package auth validates the Better Auth sessions issued by the backend. +// The session cookie value is "." (HMAC-signed with the +// backend's BETTER_AUTH_SECRET); this service resolves the token part against +// the shared auth_sessions table, which is the authoritative validity check +// (the token carries 32+ characters of entropy, and a DB hit is required +// anyway for expiry/ban/role, so revocations apply here instantly). package auth import ( "errors" "net/http" + "net/url" "strings" - - "github.com/golang-jwt/jwt/v5" + "time" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" ) -const ( - issuer = "spanglish" - audience = "spanglish-app" -) +// Cookie names set by the backend: advanced.cookiePrefix "spanglish", with +// the __Secure- prefix added in production (advanced.useSecureCookies). +var sessionCookieNames = []string{ + "__Secure-spanglish.session_token", + "spanglish.session_token", +} var ( - ErrNoToken = errors.New("no bearer token") - ErrInvalidToken = errors.New("invalid token") + ErrNoToken = errors.New("no session token") + ErrInvalidToken = errors.New("invalid session") ) // AdminRoles mirrors backend/src/routes/media.ts: photo administration is @@ -43,57 +47,61 @@ func (u User) IsAdmin() bool { return false } -type claims struct { - Email string `json:"email"` - Role string `json:"role"` - TokenVersion *int `json:"tokenVersion"` - jwt.RegisteredClaims -} - type Verifier struct { - secret []byte - db *store.DB + db *store.DB } -func NewVerifier(secret string, db *store.DB) *Verifier { - return &Verifier{secret: []byte(secret), db: db} +func NewVerifier(db *store.DB) *Verifier { + return &Verifier{db: db} } -// FromRequest returns the authenticated user, ErrNoToken when no -// Authorization header is present, or ErrInvalidToken for anything bad. +// FromRequest returns the authenticated user, ErrNoToken when no session +// cookie or bearer token is present, or ErrInvalidToken for anything bad. func (v *Verifier) FromRequest(r *http.Request) (User, error) { - header := r.Header.Get("Authorization") - if header == "" { + token := sessionTokenFromRequest(r) + if token == "" { return User{}, ErrNoToken } - raw, ok := strings.CutPrefix(header, "Bearer ") - if !ok { - return User{}, ErrInvalidToken - } - var c claims - _, err := jwt.ParseWithClaims(raw, &c, func(*jwt.Token) (any, error) { return v.secret, nil }, - jwt.WithValidMethods([]string{"HS256"}), - jwt.WithIssuer(issuer), - jwt.WithAudience(audience), - jwt.WithExpirationRequired(), - ) - if err != nil || c.Subject == "" { - return User{}, ErrInvalidToken - } - - // DB-backed revocation, same semantics as backend getAuthUser - // (backend/src/lib/auth.ts:320-327). - ua, err := v.db.GetUserAuth(r.Context(), c.Subject) + su, err := v.db.GetSessionUser(r.Context(), token) if err != nil { return User{}, ErrInvalidToken } - if ua.AccountStatus != "active" { + if !su.ExpiresAt.After(time.Now()) { return User{}, ErrInvalidToken } - if c.TokenVersion != nil && *c.TokenVersion != ua.TokenVersion { + // Same semantics as backend getAuthUser: suspended (banned) or unclaimed + // accounts must not retain API access via a live session. + if su.Banned || su.AccountStatus != "active" { return User{}, ErrInvalidToken } - // Role from the DB, not the token, so demotions apply immediately. - return User{ID: ua.ID, Email: c.Email, Role: ua.Role}, nil + // Role comes from the users row, so demotions apply immediately. + return User{ID: su.ID, Email: su.Email, Role: su.Role}, nil +} + +// sessionTokenFromRequest extracts the raw session token from the Better Auth +// cookie, or from an Authorization: Bearer header (tests and CLI tooling). +func sessionTokenFromRequest(r *http.Request) string { + for _, name := range sessionCookieNames { + if c, err := r.Cookie(name); err == nil && c.Value != "" { + return tokenPart(c.Value) + } + } + if raw, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok && raw != "" { + return tokenPart(raw) + } + return "" +} + +// tokenPart strips the URL-encoding and HMAC signature from a cookie value: +// "token.base64sig" -> "token". The token itself is alphanumeric, so the +// first '.' always separates it from the signature. +func tokenPart(v string) string { + if dec, err := url.QueryUnescape(v); err == nil { + v = dec + } + if i := strings.IndexByte(v, '.'); i >= 0 { + return v[:i] + } + return v } diff --git a/photo-api/internal/config/config.go b/photo-api/internal/config/config.go index 0fa0f03..42a7f5e 100644 --- a/photo-api/internal/config/config.go +++ b/photo-api/internal/config/config.go @@ -15,7 +15,10 @@ type Config struct { Port int DBType string // "postgres" | "sqlite", same convention as backend DB_TYPE DatabaseURL string - JWTSecret string + // ViewTokenSecret signs the hour-bucketed gallery view tokens + // (viewtoken.go). PHOTO_VIEW_SECRET, falling back to the legacy + // JWT_SECRET during the Better Auth migration. + ViewTokenSecret string StoragePath string S3Endpoint string @@ -44,7 +47,7 @@ func Load() (Config, error) { Port: envInt("PORT", 3003), DBType: strings.ToLower(env("DB_TYPE", "sqlite")), DatabaseURL: env("DATABASE_URL", ""), - JWTSecret: env("JWT_SECRET", ""), + ViewTokenSecret: env("PHOTO_VIEW_SECRET", env("JWT_SECRET", "")), StoragePath: env("STORAGE_PATH", "./data/photos"), S3Endpoint: env("S3_ENDPOINT", ""), S3Region: env("S3_REGION", "auto"), @@ -64,8 +67,8 @@ func Load() (Config, error) { if cfg.DatabaseURL == "" { return cfg, fmt.Errorf("DATABASE_URL is required") } - if cfg.JWTSecret == "" { - return cfg, fmt.Errorf("JWT_SECRET is required (must match backend/.env)") + if cfg.ViewTokenSecret == "" { + return cfg, fmt.Errorf("PHOTO_VIEW_SECRET is required (or legacy JWT_SECRET as fallback)") } return cfg, nil } diff --git a/photo-api/internal/httpapi/access.go b/photo-api/internal/httpapi/access.go index 20caed5..2d9ec53 100644 --- a/photo-api/internal/httpapi/access.go +++ b/photo-api/internal/httpapi/access.go @@ -27,7 +27,7 @@ func (s *Server) authorize(r *http.Request, g store.Gallery, user *auth.User, to // expires; it is only ever issued after a successful authorize, and it // is what lets requests (which cannot carry a Bearer header) // through for non-public galleries. - if token != "" && verifyViewToken([]byte(s.cfg.JWTSecret), g.ID, token) { + if token != "" && verifyViewToken([]byte(s.cfg.ViewTokenSecret), g.ID, token) { return nil } switch g.Visibility { diff --git a/photo-api/internal/httpapi/api_test.go b/photo-api/internal/httpapi/api_test.go index 59a4a81..e27e006 100644 --- a/photo-api/internal/httpapi/api_test.go +++ b/photo-api/internal/httpapi/api_test.go @@ -17,8 +17,6 @@ import ( "testing" "time" - "github.com/golang-jwt/jwt/v5" - "git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/config" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage" @@ -43,6 +41,7 @@ type testEnv struct { handler http.Handler db *store.DB worker *worker.Worker + pg bool } // setup migrates a scratch DB (SQLite by default; Postgres when @@ -55,7 +54,7 @@ func setup(t *testing.T) *testEnv { Port: 0, DBType: "sqlite", DatabaseURL: filepath.Join(dir, "test.db"), - JWTSecret: testSecret, + ViewTokenSecret: testSecret, StoragePath: filepath.Join(dir, "photos"), MaxUploadMB: 5, WorkerConcurrency: 1, @@ -72,7 +71,7 @@ func setup(t *testing.T) *testEnv { t.Cleanup(func() { db.Close() }) if cfg.DBType == "postgres" { // The scratch Postgres DB persists across tests; start clean. - if _, err := db.Exec(`DROP TABLE IF EXISTS photos_photos, photos_galleries, photos_schema_migrations, users, events, tickets, payments CASCADE`); err != nil { + if _, err := db.Exec(`DROP TABLE IF EXISTS photos_photos, photos_galleries, photos_schema_migrations, users, events, tickets, payments, auth_sessions CASCADE`); err != nil { t.Fatal(err) } } @@ -81,20 +80,27 @@ func setup(t *testing.T) *testEnv { } idType := "text" + expiresType := "integer" // sqlite: Better Auth stores epoch-ms integers + boolType := "integer" + falseLit := "0" if cfg.DBType == "postgres" { idType = "uuid" + expiresType = "timestamp" + boolType = "boolean" + falseLit = "FALSE" } seed := []string{ - `CREATE TABLE users (id ` + idType + ` PRIMARY KEY, email text, name text, role text, token_version integer NOT NULL DEFAULT 0, account_status text NOT NULL DEFAULT 'active')`, + `CREATE TABLE users (id ` + idType + ` PRIMARY KEY, email text, name text, role text, account_status text NOT NULL DEFAULT 'active', banned ` + boolType + ` NOT NULL DEFAULT ` + falseLit + `)`, + `CREATE TABLE auth_sessions (id text PRIMARY KEY, user_id ` + idType + `, token text NOT NULL UNIQUE, expires_at ` + expiresType + ` NOT NULL)`, `CREATE TABLE events (id ` + idType + ` PRIMARY KEY, slug text, title text, title_es text, start_datetime text, status text, price real)`, `CREATE TABLE tickets (id text PRIMARY KEY, user_id ` + idType + `, event_id ` + idType + `, status text)`, `CREATE TABLE payments (id text PRIMARY KEY, ticket_id text, status text)`, - `INSERT INTO users VALUES ('` + uAdmin + `','a@x.py','Admin','admin',0,'active')`, - `INSERT INTO users VALUES ('` + uMember + `','m@x.py','Member','user',0,'active')`, - `INSERT INTO users VALUES ('` + uBuyer + `','b@x.py','Buyer','user',0,'active')`, - `INSERT INTO users VALUES ('` + uPending + `','p@x.py','Pending','user',0,'active')`, - `INSERT INTO users VALUES ('` + uFree + `','f@x.py','Free','user',0,'active')`, + `INSERT INTO users VALUES ('` + uAdmin + `','a@x.py','Admin','admin','active',` + falseLit + `)`, + `INSERT INTO users VALUES ('` + uMember + `','m@x.py','Member','user','active',` + falseLit + `)`, + `INSERT INTO users VALUES ('` + uBuyer + `','b@x.py','Buyer','user','active',` + falseLit + `)`, + `INSERT INTO users VALUES ('` + uPending + `','p@x.py','Pending','user','active',` + falseLit + `)`, + `INSERT INTO users VALUES ('` + uFree + `','f@x.py','Free','user','active',` + falseLit + `)`, `INSERT INTO events VALUES ('` + evPaid + `','fiesta','Fiesta','Fiesta ES','2026-06-01T20:00:00.000Z','completed',50000)`, `INSERT INTO events VALUES ('` + evFree + `','gratis','Gratis','Gratis ES','2026-06-02T20:00:00.000Z','completed',0)`, @@ -116,23 +122,27 @@ func setup(t *testing.T) *testEnv { t.Fatal(err) } wrk := worker.New(db, st, nil, cfg.StoragePath, 1) - srv := New(cfg, db, st, auth.NewVerifier(cfg.JWTSecret, db), wrk) - return &testEnv{handler: srv.Handler(), db: db, worker: wrk} + srv := New(cfg, db, st, auth.NewVerifier(db), wrk) + return &testEnv{handler: srv.Handler(), db: db, worker: wrk, pg: cfg.DBType == "postgres"} } -func makeToken(t *testing.T, sub, email, role string) string { +// makeToken inserts a Better Auth session row for the user and returns a +// bearer value in the cookie's "token.signature" format (the verifier +// resolves the token part against auth_sessions; the signature is unused). +func (e *testEnv) makeToken(t *testing.T, sub string) string { t.Helper() - tv := 0 - claims := jwt.MapClaims{ - "sub": sub, "email": email, "role": role, "tokenVersion": tv, - "iss": "spanglish", "aud": "spanglish-app", - "iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(), + token := "tok-" + newID() + expires := time.Now().Add(time.Hour) + var expiresVal any = expires.UnixMilli() + if e.pg { + expiresVal = expires } - s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(testSecret)) - if err != nil { + if _, err := e.db.Exec(e.db.Rebind( + `INSERT INTO auth_sessions (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)`), + newID(), sub, token, expiresVal); err != nil { t.Fatal(err) } - return s + return token + ".testsig" } func (e *testEnv) request(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder { @@ -193,16 +203,61 @@ func TestAdminGate(t *testing.T) { if w := e.request(t, "POST", "/api/photos/galleries", "", body); w.Code != 401 { t.Fatalf("anon create: want 401, got %d", w.Code) } - member := makeToken(t, uMember, "m@x.py", "user") + member := e.makeToken(t, uMember) if w := e.request(t, "POST", "/api/photos/galleries", member, body); w.Code != 403 { t.Fatalf("member create: want 403, got %d", w.Code) } - unknown := makeToken(t, newID(), "g@x.py", "admin") + unknown := e.makeToken(t, newID()) if w := e.request(t, "POST", "/api/photos/galleries", unknown, body); w.Code != 401 { t.Fatalf("unknown-user token: want 401, got %d", w.Code) } } +func TestSessionValidation(t *testing.T) { + e := setup(t) + body := map[string]string{"title": "Test"} + + // Expired session -> 401 + expiredTok := "tok-" + newID() + var expiresVal any = time.Now().Add(-time.Hour).UnixMilli() + if e.pg { + expiresVal = time.Now().Add(-time.Hour) + } + if _, err := e.db.Exec(e.db.Rebind( + `INSERT INTO auth_sessions (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)`), + newID(), uAdmin, expiredTok, expiresVal); err != nil { + t.Fatal(err) + } + if w := e.request(t, "POST", "/api/photos/galleries", expiredTok+".sig", body); w.Code != 401 { + t.Fatalf("expired session: want 401, got %d", w.Code) + } + + // Banned (suspended) user -> 401 even with a live session + bannedTok := e.makeToken(t, uMember) + if _, err := e.db.Exec(e.db.Rebind( + `UPDATE users SET account_status = 'suspended' WHERE id = ?`), uMember); err != nil { + t.Fatal(err) + } + if w := e.request(t, "POST", "/api/photos/galleries", bannedTok, body); w.Code != 401 { + t.Fatalf("suspended user: want 401, got %d", w.Code) + } + if _, err := e.db.Exec(e.db.Rebind( + `UPDATE users SET account_status = 'active' WHERE id = ?`), uMember); err != nil { + t.Fatal(err) + } + + // Session via the browser cookie (URL-encoded token.signature) -> works + adminTok := e.makeToken(t, uAdmin) + req := httptest.NewRequest("POST", "/api/photos/galleries", bytes.NewReader([]byte(`{"title":"Via cookie"}`))) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: "spanglish.session_token", Value: strings.ReplaceAll(adminTok, ".", "%2E")}) + w := httptest.NewRecorder() + e.handler.ServeHTTP(w, req) + if w.Code != 200 && w.Code != 201 { + t.Fatalf("cookie auth: want 2xx, got %d: %s", w.Code, w.Body.String()) + } +} + type galleryResp struct { Gallery galleryJSON `json:"gallery"` Photos []photoJSON `json:"photos"` @@ -265,7 +320,7 @@ func processQueue(t *testing.T, e *testEnv, photoID string) store.Photo { func TestUploadProcessAndServe(t *testing.T) { e := setup(t) - admin := makeToken(t, uAdmin, "a@x.py", "admin") + admin := e.makeToken(t, uAdmin) g := createGallery(t, e, admin, map[string]any{"title": "Fiesta de Junio", "visibility": "public"}) if g.Slug != "fiesta-de-junio" { t.Fatalf("slug: %q", g.Slug) @@ -325,11 +380,11 @@ func TestUploadProcessAndServe(t *testing.T) { func TestAccessMatrix(t *testing.T) { e := setup(t) - admin := makeToken(t, uAdmin, "a@x.py", "admin") - member := makeToken(t, uMember, "m@x.py", "user") - buyer := makeToken(t, uBuyer, "b@x.py", "user") - pending := makeToken(t, uPending, "p@x.py", "user") - freeguy := makeToken(t, uFree, "f@x.py", "user") + admin := e.makeToken(t, uAdmin) + member := e.makeToken(t, uMember) + buyer := e.makeToken(t, uBuyer) + pending := e.makeToken(t, uPending) + freeguy := e.makeToken(t, uFree) get := func(slug, token, bearer string) int { path := "/api/photos/public/galleries/" + slug @@ -408,7 +463,7 @@ func TestAccessMatrix(t *testing.T) { func TestReorderAndVisibilityUpdate(t *testing.T) { e := setup(t) - admin := makeToken(t, uAdmin, "a@x.py", "admin") + admin := e.makeToken(t, uAdmin) g := createGallery(t, e, admin, map[string]any{"title": "Orden", "visibility": "public"}) jpg := testJPEG(t) p1 := uploadPhoto(t, e, admin, g.ID, jpg) @@ -447,7 +502,7 @@ func TestReorderAndVisibilityUpdate(t *testing.T) { // accepts anonymously. func TestViewTokens(t *testing.T) { e := setup(t) - admin := makeToken(t, uAdmin, "a@x.py", "admin") + admin := e.makeToken(t, uAdmin) g := createGallery(t, e, admin, map[string]any{"title": "Privada Con Fotos", "visibility": "private"}) p := uploadPhoto(t, e, admin, g.ID, testJPEG(t)) @@ -499,8 +554,8 @@ func TestViewTokens(t *testing.T) { func TestEventGalleryRoute(t *testing.T) { e := setup(t) - admin := makeToken(t, uAdmin, "a@x.py", "admin") - buyer := makeToken(t, uBuyer, "b@x.py", "user") + admin := e.makeToken(t, uAdmin) + buyer := e.makeToken(t, uBuyer) g := createGallery(t, e, admin, map[string]any{"title": "Del Evento", "visibility": "ticket", "eventId": evPaid}) @@ -546,7 +601,7 @@ func TestEventGalleryRoute(t *testing.T) { func TestSlugCollision(t *testing.T) { e := setup(t) - admin := makeToken(t, uAdmin, "a@x.py", "admin") + admin := e.makeToken(t, uAdmin) a := createGallery(t, e, admin, map[string]any{"title": "Misma Fiesta"}) b := createGallery(t, e, admin, map[string]any{"title": "Misma Fiesta"}) if a.Slug == b.Slug { diff --git a/photo-api/internal/httpapi/dto.go b/photo-api/internal/httpapi/dto.go index c66c7d0..9f40b31 100644 --- a/photo-api/internal/httpapi/dto.go +++ b/photo-api/internal/httpapi/dto.go @@ -67,7 +67,7 @@ func (s *Server) viewTokenFor(g store.Gallery) string { if g.Visibility == store.VisibilityPublic { return "" } - return mintViewToken([]byte(s.cfg.JWTSecret), g.ID) + return mintViewToken([]byte(s.cfg.ViewTokenSecret), g.ID) } func isoTime(t time.Time) string { diff --git a/photo-api/internal/store/access.go b/photo-api/internal/store/access.go index 9db6bd5..2018081 100644 --- a/photo-api/internal/store/access.go +++ b/photo-api/internal/store/access.go @@ -12,31 +12,67 @@ import ( "time" ) -type UserAuth struct { +type SessionUser struct { ID string + Email string Role string - TokenVersion int AccountStatus string + Banned bool + ExpiresAt time.Time } -func (db *DB) GetUserAuth(ctx context.Context, userID string) (UserAuth, error) { - row := db.QueryRowContext(ctx, db.Rebind( - "SELECT id, role, token_version, account_status FROM users WHERE id = ?"), userID) - var id, role, tv, status any - if err := row.Scan(&id, &role, &tv, &status); err != nil { +// GetSessionUser resolves a Better Auth session token (from the +// spanglish.session_token cookie) to its user. Sessions live in the shared +// auth_sessions table, written by the backend; the row's presence is the +// authoritative validity check, so revocations apply here instantly. +func (db *DB) GetSessionUser(ctx context.Context, token string) (SessionUser, error) { + row := db.QueryRowContext(ctx, db.Rebind(` + SELECT u.id, u.email, u.role, u.account_status, u.banned, s.expires_at + FROM auth_sessions s + JOIN users u ON u.id = s.user_id + WHERE s.token = ?`), token) + var id, email, role, status, banned, expires any + if err := row.Scan(&id, &email, &role, &status, &banned, &expires); err != nil { if errors.Is(err, sql.ErrNoRows) { - return UserAuth{}, ErrNotFound + return SessionUser{}, ErrNotFound } - return UserAuth{}, err + return SessionUser{}, err } - return UserAuth{ + return SessionUser{ ID: asString(id), + Email: asString(email), Role: asString(role), - TokenVersion: int(asInt(tv)), AccountStatus: asString(status), + Banned: asBool(banned), + ExpiresAt: asTimeOrMillis(expires), }, nil } +// asBool normalizes booleans across drivers (pg BOOLEAN, sqlite INTEGER 0/1). +func asBool(v any) bool { + switch x := v.(type) { + case bool: + return x + case int64, int, int32, float64: + return asInt(v) != 0 + case []byte: + return len(x) > 0 && x[0] != '0' && x[0] != 'f' && x[0] != 'F' + default: + return false + } +} + +// asTimeOrMillis parses a timestamp that is a pg TIMESTAMP on postgres but an +// epoch-milliseconds INTEGER on sqlite (Drizzle timestamp_ms mode). +func asTimeOrMillis(v any) time.Time { + switch v.(type) { + case int64, int, int32, float64: + return time.UnixMilli(asInt(v)).UTC() + default: + return asTime(v) + } +} + // HasPaidTicket implements the review decision: a confirmed/checked-in // ticket whose payment is 'paid', or any confirmed/checked-in ticket when // the event is free (price = 0). @@ -99,7 +135,8 @@ func (db *DB) getEventWhere(ctx context.Context, where string, arg any) (EventSu // from Drizzle-owned tables have been renamed or dropped. func (db *DB) CheckMainSchema(ctx context.Context) error { checks := []string{ - "SELECT id, role, token_version, account_status FROM users WHERE 1 = 0", + "SELECT id, email, role, account_status, banned FROM users WHERE 1 = 0", + "SELECT id, user_id, token, expires_at FROM auth_sessions WHERE 1 = 0", "SELECT id, slug, title, title_es, start_datetime, status, price FROM events WHERE 1 = 0", "SELECT id, user_id, event_id, status FROM tickets WHERE 1 = 0", "SELECT id, ticket_id, status FROM payments WHERE 1 = 0",