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
@@ -12,31 +12,67 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type UserAuth struct {
|
||||
type SessionUser struct {
|
||||
ID string
|
||||
Email string
|
||||
Role string
|
||||
TokenVersion int
|
||||
AccountStatus string
|
||||
Banned bool
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func (db *DB) GetUserAuth(ctx context.Context, userID string) (UserAuth, error) {
|
||||
row := db.QueryRowContext(ctx, db.Rebind(
|
||||
"SELECT id, role, token_version, account_status FROM users WHERE id = ?"), userID)
|
||||
var id, role, tv, status any
|
||||
if err := row.Scan(&id, &role, &tv, &status); err != nil {
|
||||
// GetSessionUser resolves a Better Auth session token (from the
|
||||
// spanglish.session_token cookie) to its user. Sessions live in the shared
|
||||
// auth_sessions table, written by the backend; the row's presence is the
|
||||
// authoritative validity check, so revocations apply here instantly.
|
||||
func (db *DB) GetSessionUser(ctx context.Context, token string) (SessionUser, error) {
|
||||
row := db.QueryRowContext(ctx, db.Rebind(`
|
||||
SELECT u.id, u.email, u.role, u.account_status, u.banned, s.expires_at
|
||||
FROM auth_sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token = ?`), token)
|
||||
var id, email, role, status, banned, expires any
|
||||
if err := row.Scan(&id, &email, &role, &status, &banned, &expires); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return UserAuth{}, ErrNotFound
|
||||
return SessionUser{}, ErrNotFound
|
||||
}
|
||||
return UserAuth{}, err
|
||||
return SessionUser{}, err
|
||||
}
|
||||
return UserAuth{
|
||||
return SessionUser{
|
||||
ID: asString(id),
|
||||
Email: asString(email),
|
||||
Role: asString(role),
|
||||
TokenVersion: int(asInt(tv)),
|
||||
AccountStatus: asString(status),
|
||||
Banned: asBool(banned),
|
||||
ExpiresAt: asTimeOrMillis(expires),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// asBool normalizes booleans across drivers (pg BOOLEAN, sqlite INTEGER 0/1).
|
||||
func asBool(v any) bool {
|
||||
switch x := v.(type) {
|
||||
case bool:
|
||||
return x
|
||||
case int64, int, int32, float64:
|
||||
return asInt(v) != 0
|
||||
case []byte:
|
||||
return len(x) > 0 && x[0] != '0' && x[0] != 'f' && x[0] != 'F'
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// asTimeOrMillis parses a timestamp that is a pg TIMESTAMP on postgres but an
|
||||
// epoch-milliseconds INTEGER on sqlite (Drizzle timestamp_ms mode).
|
||||
func asTimeOrMillis(v any) time.Time {
|
||||
switch v.(type) {
|
||||
case int64, int, int32, float64:
|
||||
return time.UnixMilli(asInt(v)).UTC()
|
||||
default:
|
||||
return asTime(v)
|
||||
}
|
||||
}
|
||||
|
||||
// HasPaidTicket implements the review decision: a confirmed/checked-in
|
||||
// ticket whose payment is 'paid', or any confirmed/checked-in ticket when
|
||||
// the event is free (price = 0).
|
||||
@@ -99,7 +135,8 @@ func (db *DB) getEventWhere(ctx context.Context, where string, arg any) (EventSu
|
||||
// from Drizzle-owned tables have been renamed or dropped.
|
||||
func (db *DB) CheckMainSchema(ctx context.Context) error {
|
||||
checks := []string{
|
||||
"SELECT id, role, token_version, account_status FROM users WHERE 1 = 0",
|
||||
"SELECT id, email, role, account_status, banned FROM users WHERE 1 = 0",
|
||||
"SELECT id, user_id, token, expires_at FROM auth_sessions WHERE 1 = 0",
|
||||
"SELECT id, slug, title, title_es, start_datetime, status, price FROM events WHERE 1 = 0",
|
||||
"SELECT id, user_id, event_id, status FROM tickets WHERE 1 = 0",
|
||||
"SELECT id, ticket_id, status FROM payments WHERE 1 = 0",
|
||||
|
||||
Reference in New Issue
Block a user