Migrate authentication to Better Auth
Replace the hand-rolled JWT auth with Better Auth 1.6.25 httpOnly cookie sessions, validated against the database on every request so revocation, bans and role changes take effect immediately. Backend: - betterAuth.ts wires the Drizzle adapter, magic links, Google sign-in and the admin plugin; auth-schema.ts maps Better Auth's models onto the existing `users` table so user IDs and their foreign keys survive intact. - routes/auth.ts is gone; Better Auth serves the standard endpoints and authExt.ts carries the flows it doesn't cover. - auth.ts shrinks to session resolution and helpers; sessions/revocation in dashboard.ts now read and delete `auth_sessions` rows directly. - Schema adds the Better Auth core + admin columns (email_verified, image, banned, ban_reason, ban_expires), with migrations and tests. - rateLimit.ts resolves client IPs spoof-resistantly: proxy headers are only honoured from loopback/RFC1918 peers plus TRUSTED_PROXIES. - passwordPolicy.ts centralises password validation. - Bump drizzle-orm, drizzle-kit and better-sqlite3 to versions compatible with Better Auth. Frontend: - auth-client.ts plus a reworked AuthContext and api/client.ts move to cookie-based sessions; no more bearer tokens in requests or middleware. photo-api: - Validate Better Auth session cookies against the shared auth_sessions table instead of verifying JWTs; JWT_SECRET is no longer needed for user auth, and PHOTO_VIEW_SECRET now signs gallery view tokens. BETTER_AUTH_SECRET and BETTER_AUTH_URL are required in production; the deprecated JWT_SECRET stays only as the photo-api view-token fallback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4afa5d6fa0
commit
733d2459df
+21
-1
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 }>(
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
+132
-124
@@ -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);
|
||||
|
||||
+92
-331
@@ -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<string> {
|
||||
return argon2.hash(password, {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: 65536, // 64 MB
|
||||
timeCost: 3,
|
||||
parallelism: 4,
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
// 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<string> {
|
||||
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<any>(
|
||||
(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<string> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<JWTPayload | null> {
|
||||
/**
|
||||
* Resolve the authenticated user for a request from its Better Auth session
|
||||
* cookie, or null when there is no valid session. Suspended/unclaimed/banned
|
||||
* accounts never get API access even with a live session cookie.
|
||||
*/
|
||||
export async function getAuthUser(c: Context): Promise<AuthUser | null> {
|
||||
try {
|
||||
const { 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<any | null> {
|
||||
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<any>(
|
||||
(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<void>) => {
|
||||
const user = await getAuthUser(c);
|
||||
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
|
||||
if (roles && !roles.includes(user.role)) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
|
||||
c.set('user', user);
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
export async function isFirstUser(): Promise<boolean> {
|
||||
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<string | null> {
|
||||
const row = await dbGet<any>(
|
||||
(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<boolean> {
|
||||
const row = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ id: (authAccounts as any).id })
|
||||
.from(authAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq((authAccounts as any).userId, userId),
|
||||
eq((authAccounts as any).providerId, 'google')
|
||||
)
|
||||
)
|
||||
);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string>([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<any | null> {
|
||||
return dbGet<any>(
|
||||
(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: `
|
||||
<h2>Reset Your Password</h2>
|
||||
<p>Click the link below to reset your password. This link expires in 30 minutes.</p>
|
||||
<p><a href="${url}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Reset Password</a></p>
|
||||
<p>Or copy this link: ${url}</p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
`,
|
||||
});
|
||||
} 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<any>((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
|
||||
? `
|
||||
<h2>Claim Your Account</h2>
|
||||
<p>An account was created for you during booking. Click below to set up your login credentials. This link expires in 10 minutes.</p>
|
||||
<p><a href="${link}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Claim Account</a></p>
|
||||
<p>Or copy this link: ${link}</p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
`
|
||||
: `
|
||||
<h2>Login to Spanglish</h2>
|
||||
<p>Click the link below to log in. This link expires in 10 minutes.</p>
|
||||
<p><a href="${link}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Log In</a></p>
|
||||
<p>Or copy this link: ${link}</p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
`,
|
||||
});
|
||||
} 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;
|
||||
@@ -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<string> {
|
||||
return argon2.hash(password, {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: 65536, // 64 MB
|
||||
timeCost: 3,
|
||||
parallelism: 4,
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
// 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 };
|
||||
}
|
||||
@@ -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<string, string> }) {
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<any>(
|
||||
(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<any>(
|
||||
(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<any>(
|
||||
(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: `
|
||||
<h2>Login to Spanglish</h2>
|
||||
<p>Click the link below to log in. This link expires in 10 minutes.</p>
|
||||
<p><a href="${magicLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Log In</a></p>
|
||||
<p>Or copy this link: ${magicLink}</p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
`,
|
||||
});
|
||||
} 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<any>(
|
||||
(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<any>(
|
||||
(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: `
|
||||
<h2>Reset Your Password</h2>
|
||||
<p>Click the link below to reset your password. This link expires in 30 minutes.</p>
|
||||
<p><a href="${resetLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Reset Password</a></p>
|
||||
<p>Or copy this link: ${resetLink}</p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
`,
|
||||
});
|
||||
} 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<any>(
|
||||
(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: `
|
||||
<h2>Claim Your Account</h2>
|
||||
<p>An account was created for you during booking. Click below to set up your login credentials.</p>
|
||||
<p><a href="${claimLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Claim Account</a></p>
|
||||
<p>Or copy this link: ${claimLink}</p>
|
||||
<p>This link expires in 1 hour.</p>
|
||||
`,
|
||||
});
|
||||
} 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<string, any> = {
|
||||
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<any>(
|
||||
(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<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).email, email))
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
// Check by google_id
|
||||
user = await dbGet<any>(
|
||||
(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<any>(
|
||||
(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<any>(
|
||||
(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;
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { auth } from '../lib/betterAuth.js';
|
||||
import { validatePassword } from '../lib/passwordPolicy.js';
|
||||
import { db, dbGet, users } from '../db/index.js';
|
||||
import { getNow, toDbBool } from '../lib/utils.js';
|
||||
import { rateLimitMiddleware } from '../lib/rateLimit.js';
|
||||
|
||||
// Custom auth flows that Better Auth doesn't provide out of the box. Mounted
|
||||
// at /api/auth-ext to avoid colliding with Better Auth's /api/auth/* handler.
|
||||
const authExtRateLimit = rateLimitMiddleware({ max: 20, windowMs: 15 * 60 * 1000, prefix: 'auth-ext' });
|
||||
|
||||
const authExt = new Hono();
|
||||
|
||||
const claimAccountSchema = z.object({
|
||||
password: z.string().min(10, 'Password must be at least 10 characters'),
|
||||
});
|
||||
|
||||
// Complete a progressive-account claim. The user arrives here already holding
|
||||
// a session established by the claim magic link; this endpoint deliberately
|
||||
// accepts accountStatus 'unclaimed' sessions (requireAuth would reject them)
|
||||
// and is the ONLY endpoint that does.
|
||||
authExt.post('/claim-account', authExtRateLimit, zValidator('json', claimAccountSchema), async (c) => {
|
||||
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
||||
if (!session?.user) {
|
||||
return c.json({ error: 'Unauthorized. Please use the claim link from your email.' }, 401);
|
||||
}
|
||||
|
||||
const user = session.user as any;
|
||||
if (user.banned || user.accountStatus === 'suspended') {
|
||||
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
|
||||
}
|
||||
if (user.isClaimed && user.accountStatus === 'active') {
|
||||
return c.json({ error: 'Account is already claimed' }, 400);
|
||||
}
|
||||
|
||||
const { password } = c.req.valid('json');
|
||||
const passwordValidation = validatePassword(password);
|
||||
if (!passwordValidation.valid) {
|
||||
return c.json({ error: passwordValidation.error }, 400);
|
||||
}
|
||||
|
||||
// Creates the credential account with the argon2id hash from lib/betterAuth.ts
|
||||
await auth.api.setPassword({
|
||||
body: { newPassword: password },
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
// The magic link click proved email ownership
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set({
|
||||
isClaimed: toDbBool(true),
|
||||
accountStatus: 'active',
|
||||
emailVerified: true,
|
||||
updatedAt: getNow(),
|
||||
})
|
||||
.where(eq((users as any).id, user.id));
|
||||
|
||||
return c.json({
|
||||
message: 'Account claimed successfully!',
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
isClaimed: true,
|
||||
phone: user.phone ?? null,
|
||||
rucNumber: user.rucNumber ?? null,
|
||||
languagePreference: user.languagePreference ?? null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Whether an email belongs to an unclaimed account. Deliberate, rate-limited
|
||||
// exception to enumeration-safety, matching the legacy register/login UX that
|
||||
// surfaced "this account can be claimed".
|
||||
authExt.get('/claim-eligibility', authExtRateLimit, async (c) => {
|
||||
const email = c.req.query('email');
|
||||
if (!email || !z.string().email().safeParse(email).success) {
|
||||
return c.json({ canClaim: false });
|
||||
}
|
||||
|
||||
const user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).email, email))
|
||||
);
|
||||
|
||||
const canClaim = !!user && !user.banned && user.accountStatus !== 'suspended'
|
||||
&& (!user.isClaimed || user.accountStatus === 'unclaimed');
|
||||
|
||||
return c.json({ canClaim });
|
||||
});
|
||||
|
||||
export default authExt;
|
||||
@@ -1,18 +1,12 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, dbAll, users, tickets, payments, events, invoices, User } from '../db/index.js';
|
||||
import { db, dbGet, dbAll, users, tickets, payments, events, invoices } from '../db/index.js';
|
||||
import { eq, desc, and, gt, sql, inArray } from 'drizzle-orm';
|
||||
import { requireAuth, getUserSessions, invalidateSession, invalidateAllUserSessions, bumpTokenVersion, createToken, hashPassword, validatePassword, getUserPasswordHash } from '../lib/auth.js';
|
||||
import { generateId, getNow } from '../lib/utils.js';
|
||||
|
||||
// User type that includes all fields (some added in schema updates)
|
||||
type AuthUser = User & {
|
||||
isClaimed: boolean;
|
||||
googleId: string | null;
|
||||
rucNumber: string | null;
|
||||
accountStatus: string;
|
||||
};
|
||||
import { requireAuth, getUserPasswordHash, hasGoogleAccount, validatePassword, type AuthUser } from '../lib/auth.js';
|
||||
import { auth } from '../lib/betterAuth.js';
|
||||
import { authSessions, authAccounts } from '../db/auth-schema.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
|
||||
const dashboard = new Hono();
|
||||
|
||||
@@ -50,7 +44,7 @@ dashboard.get('/profile', async (c) => {
|
||||
isClaimed: user.isClaimed,
|
||||
accountStatus: user.accountStatus,
|
||||
hasPassword,
|
||||
hasGoogleLinked: !!user.googleId,
|
||||
hasGoogleLinked: await hasGoogleAccount(user.id),
|
||||
memberSince: user.createdAt,
|
||||
membershipDays,
|
||||
createdAt: user.createdAt,
|
||||
@@ -423,49 +417,77 @@ dashboard.get('/invoices', async (c) => {
|
||||
|
||||
// ==================== Security Routes ====================
|
||||
|
||||
// Get active sessions
|
||||
// Get active sessions (Better Auth session table; validated per-request so
|
||||
// this list is always live). Session tokens are never exposed to the client.
|
||||
dashboard.get('/sessions', async (c) => {
|
||||
const user = (c as any).get('user') as AuthUser;
|
||||
|
||||
const sessions = await getUserSessions(user.id);
|
||||
|
||||
|
||||
const sessions = await dbAll<any>(
|
||||
(db as any)
|
||||
.select({
|
||||
id: (authSessions as any).id,
|
||||
userAgent: (authSessions as any).userAgent,
|
||||
ipAddress: (authSessions as any).ipAddress,
|
||||
createdAt: (authSessions as any).createdAt,
|
||||
updatedAt: (authSessions as any).updatedAt,
|
||||
expiresAt: (authSessions as any).expiresAt,
|
||||
})
|
||||
.from(authSessions)
|
||||
.where(
|
||||
and(
|
||||
eq((authSessions as any).userId, user.id),
|
||||
gt((authSessions as any).expiresAt, new Date())
|
||||
)
|
||||
)
|
||||
.orderBy(desc((authSessions as any).updatedAt))
|
||||
);
|
||||
|
||||
return c.json({
|
||||
sessions: sessions.map((s: any) => ({
|
||||
id: s.id,
|
||||
userAgent: s.userAgent,
|
||||
ipAddress: s.ipAddress,
|
||||
lastActiveAt: s.lastActiveAt,
|
||||
lastActiveAt: s.updatedAt,
|
||||
createdAt: s.createdAt,
|
||||
expiresAt: s.expiresAt,
|
||||
current: s.id === user.sessionId,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
// Revoke a specific session
|
||||
// Revoke a specific session. Deleting the row is immediately effective:
|
||||
// sessions are validated against the table on every request (no cookie cache).
|
||||
dashboard.delete('/sessions/:id', async (c) => {
|
||||
const user = (c as any).get('user') as AuthUser;
|
||||
const sessionId = c.req.param('id');
|
||||
|
||||
await invalidateSession(sessionId, user.id);
|
||||
|
||||
|
||||
await (db as any)
|
||||
.delete(authSessions)
|
||||
.where(
|
||||
and(
|
||||
eq((authSessions as any).id, sessionId),
|
||||
eq((authSessions as any).userId, user.id)
|
||||
)
|
||||
);
|
||||
|
||||
return c.json({ message: 'Session revoked' });
|
||||
});
|
||||
|
||||
// Revoke all sessions (logout everywhere). Bumping the token version invalidates
|
||||
// every previously issued JWT for this user, which is the actual enforcement
|
||||
// mechanism (auth is stateless JWT, not DB-session based).
|
||||
// Revoke all other sessions (logout everywhere else); the current session
|
||||
// stays valid so this device remains signed in.
|
||||
dashboard.post('/sessions/revoke-all', async (c) => {
|
||||
const user = (c as any).get('user') as AuthUser;
|
||||
|
||||
await invalidateAllUserSessions(user.id);
|
||||
await bumpTokenVersion(user.id);
|
||||
|
||||
// Issue a fresh token so the current device stays signed in
|
||||
const refreshed = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).id, user.id))
|
||||
);
|
||||
const token = await createToken(user.id, user.email, user.role, refreshed?.tokenVersion ?? 0);
|
||||
|
||||
return c.json({ message: 'All other sessions revoked.', token });
|
||||
await (db as any)
|
||||
.delete(authSessions)
|
||||
.where(
|
||||
and(
|
||||
eq((authSessions as any).userId, user.id),
|
||||
sql`${(authSessions as any).id} != ${user.sessionId}`
|
||||
)
|
||||
);
|
||||
|
||||
return c.json({ message: 'All other sessions revoked.' });
|
||||
});
|
||||
|
||||
// Set password (for users without one)
|
||||
@@ -476,53 +498,57 @@ const setPasswordSchema = z.object({
|
||||
dashboard.post('/set-password', zValidator('json', setPasswordSchema), async (c) => {
|
||||
const user = (c as any).get('user') as AuthUser;
|
||||
const { password } = c.req.valid('json');
|
||||
|
||||
|
||||
// Check if user already has a password
|
||||
if (await getUserPasswordHash(user.id)) {
|
||||
return c.json({ error: 'Password already set. Use change password instead.' }, 400);
|
||||
}
|
||||
|
||||
|
||||
// setPassword is a server-only Better Auth endpoint, so the HTTP-layer
|
||||
// policy hook does not cover it — validate explicitly.
|
||||
const passwordValidation = validatePassword(password);
|
||||
if (!passwordValidation.valid) {
|
||||
return c.json({ error: passwordValidation.error }, 400);
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(password);
|
||||
const now = getNow();
|
||||
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set({
|
||||
password: hashedPassword,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((users as any).id, user.id));
|
||||
|
||||
|
||||
try {
|
||||
await auth.api.setPassword({
|
||||
body: { newPassword: password },
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
} catch (err: any) {
|
||||
return c.json({ error: err?.body?.message || 'Failed to set password' }, 400);
|
||||
}
|
||||
|
||||
return c.json({ message: 'Password set successfully' });
|
||||
});
|
||||
|
||||
// Unlink Google account (only if password is set)
|
||||
dashboard.post('/unlink-google', async (c) => {
|
||||
const user = (c as any).get('user') as AuthUser;
|
||||
|
||||
if (!user.googleId) {
|
||||
|
||||
if (!(await hasGoogleAccount(user.id))) {
|
||||
return c.json({ error: 'Google account not linked' }, 400);
|
||||
}
|
||||
|
||||
|
||||
if (!(await getUserPasswordHash(user.id))) {
|
||||
return c.json({ error: 'Cannot unlink Google without a password set' }, 400);
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
|
||||
|
||||
await (db as any)
|
||||
.delete(authAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq((authAccounts as any).userId, user.id),
|
||||
eq((authAccounts as any).providerId, 'google')
|
||||
)
|
||||
);
|
||||
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set({
|
||||
googleId: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.set({ updatedAt: getNow() })
|
||||
.where(eq((users as any).id, user.id));
|
||||
|
||||
|
||||
return c.json({ message: 'Google account unlinked' });
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<string, any> = {};
|
||||
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({
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"skipLibCheck": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declaration": false,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
|
||||
Reference in New Issue
Block a user