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>
151 lines
4.7 KiB
Go
151 lines
4.7 KiB
Go
package store
|
|
|
|
// Every query against Drizzle-owned tables (users, events, tickets,
|
|
// payments) lives in this file so the read-coupling surface stays small
|
|
// and auditable. Column semantics follow backend/src/db/schema.ts.
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
type SessionUser struct {
|
|
ID string
|
|
Email string
|
|
Role string
|
|
AccountStatus string
|
|
Banned bool
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// GetSessionUser resolves a Better Auth session token (from the
|
|
// spanglish.session_token cookie) to its user. Sessions live in the shared
|
|
// auth_sessions table, written by the backend; the row's presence is the
|
|
// authoritative validity check, so revocations apply here instantly.
|
|
func (db *DB) GetSessionUser(ctx context.Context, token string) (SessionUser, error) {
|
|
row := db.QueryRowContext(ctx, db.Rebind(`
|
|
SELECT u.id, u.email, u.role, u.account_status, u.banned, s.expires_at
|
|
FROM auth_sessions s
|
|
JOIN users u ON u.id = s.user_id
|
|
WHERE s.token = ?`), token)
|
|
var id, email, role, status, banned, expires any
|
|
if err := row.Scan(&id, &email, &role, &status, &banned, &expires); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return SessionUser{}, ErrNotFound
|
|
}
|
|
return SessionUser{}, err
|
|
}
|
|
return SessionUser{
|
|
ID: asString(id),
|
|
Email: asString(email),
|
|
Role: asString(role),
|
|
AccountStatus: asString(status),
|
|
Banned: asBool(banned),
|
|
ExpiresAt: asTimeOrMillis(expires),
|
|
}, nil
|
|
}
|
|
|
|
// asBool normalizes booleans across drivers (pg BOOLEAN, sqlite INTEGER 0/1).
|
|
func asBool(v any) bool {
|
|
switch x := v.(type) {
|
|
case bool:
|
|
return x
|
|
case int64, int, int32, float64:
|
|
return asInt(v) != 0
|
|
case []byte:
|
|
return len(x) > 0 && x[0] != '0' && x[0] != 'f' && x[0] != 'F'
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// asTimeOrMillis parses a timestamp that is a pg TIMESTAMP on postgres but an
|
|
// epoch-milliseconds INTEGER on sqlite (Drizzle timestamp_ms mode).
|
|
func asTimeOrMillis(v any) time.Time {
|
|
switch v.(type) {
|
|
case int64, int, int32, float64:
|
|
return time.UnixMilli(asInt(v)).UTC()
|
|
default:
|
|
return asTime(v)
|
|
}
|
|
}
|
|
|
|
// HasPaidTicket implements the review decision: a confirmed/checked-in
|
|
// ticket whose payment is 'paid', or any confirmed/checked-in ticket when
|
|
// the event is free (price = 0).
|
|
func (db *DB) HasPaidTicket(ctx context.Context, userID, eventID string) (bool, error) {
|
|
var v any
|
|
err := db.QueryRowContext(ctx, db.Rebind(`
|
|
SELECT 1
|
|
FROM tickets t
|
|
JOIN events e ON e.id = t.event_id
|
|
LEFT JOIN payments p ON p.ticket_id = t.id
|
|
WHERE t.user_id = ? AND t.event_id = ?
|
|
AND t.status IN ('confirmed','checked_in')
|
|
AND (p.status = 'paid' OR e.price = 0)
|
|
LIMIT 1`), userID, eventID).Scan(&v)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return false, nil
|
|
}
|
|
return err == nil, err
|
|
}
|
|
|
|
type EventSummary struct {
|
|
ID string
|
|
Slug string
|
|
Title string
|
|
TitleEs string
|
|
StartDatetime time.Time
|
|
Status string
|
|
}
|
|
|
|
func (db *DB) GetEventSummary(ctx context.Context, eventID string) (EventSummary, error) {
|
|
return db.getEventWhere(ctx, "id = ?", eventID)
|
|
}
|
|
|
|
// GetEventSummaryBySlug resolves the /events/{slug}/gallery public route.
|
|
func (db *DB) GetEventSummaryBySlug(ctx context.Context, slug string) (EventSummary, error) {
|
|
return db.getEventWhere(ctx, "slug = ?", slug)
|
|
}
|
|
|
|
func (db *DB) getEventWhere(ctx context.Context, where string, arg any) (EventSummary, error) {
|
|
row := db.QueryRowContext(ctx, db.Rebind(
|
|
"SELECT id, slug, title, title_es, start_datetime, status FROM events WHERE "+where), arg)
|
|
var id, slug, title, titleEs, start, status any
|
|
if err := row.Scan(&id, &slug, &title, &titleEs, &start, &status); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return EventSummary{}, ErrNotFound
|
|
}
|
|
return EventSummary{}, err
|
|
}
|
|
return EventSummary{
|
|
ID: asString(id),
|
|
Slug: asString(slug),
|
|
Title: asString(title),
|
|
TitleEs: asString(titleEs),
|
|
StartDatetime: asTime(start),
|
|
Status: asString(status),
|
|
}, nil
|
|
}
|
|
|
|
// CheckMainSchema fails fast at startup if the columns this service reads
|
|
// from Drizzle-owned tables have been renamed or dropped.
|
|
func (db *DB) CheckMainSchema(ctx context.Context) error {
|
|
checks := []string{
|
|
"SELECT id, email, role, account_status, banned FROM users WHERE 1 = 0",
|
|
"SELECT id, user_id, token, expires_at FROM auth_sessions WHERE 1 = 0",
|
|
"SELECT id, slug, title, title_es, start_datetime, status, price FROM events WHERE 1 = 0",
|
|
"SELECT id, user_id, event_id, status FROM tickets WHERE 1 = 0",
|
|
"SELECT id, ticket_id, status FROM payments WHERE 1 = 0",
|
|
}
|
|
for _, q := range checks {
|
|
if _, err := db.ExecContext(ctx, q); err != nil {
|
|
return fmt.Errorf("main app schema check failed (%s): %w", q, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|