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>
108 lines
3.0 KiB
Go
108 lines
3.0 KiB
Go
// Package auth validates the Better Auth sessions issued by the backend.
|
|
// The session cookie value is "<token>.<signature>" (HMAC-signed with the
|
|
// backend's BETTER_AUTH_SECRET); this service resolves the token part against
|
|
// the shared auth_sessions table, which is the authoritative validity check
|
|
// (the token carries 32+ characters of entropy, and a DB hit is required
|
|
// anyway for expiry/ban/role, so revocations apply here instantly).
|
|
package auth
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
|
|
)
|
|
|
|
// Cookie names set by the backend: advanced.cookiePrefix "spanglish", with
|
|
// the __Secure- prefix added in production (advanced.useSecureCookies).
|
|
var sessionCookieNames = []string{
|
|
"__Secure-spanglish.session_token",
|
|
"spanglish.session_token",
|
|
}
|
|
|
|
var (
|
|
ErrNoToken = errors.New("no session token")
|
|
ErrInvalidToken = errors.New("invalid session")
|
|
)
|
|
|
|
// AdminRoles mirrors backend/src/routes/media.ts: photo administration is
|
|
// admin/organizer only (review decision #6).
|
|
var AdminRoles = []string{"admin", "organizer"}
|
|
|
|
type User struct {
|
|
ID string
|
|
Email string
|
|
Role string
|
|
}
|
|
|
|
func (u User) IsAdmin() bool {
|
|
for _, r := range AdminRoles {
|
|
if u.Role == r {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type Verifier struct {
|
|
db *store.DB
|
|
}
|
|
|
|
func NewVerifier(db *store.DB) *Verifier {
|
|
return &Verifier{db: db}
|
|
}
|
|
|
|
// FromRequest returns the authenticated user, ErrNoToken when no session
|
|
// cookie or bearer token is present, or ErrInvalidToken for anything bad.
|
|
func (v *Verifier) FromRequest(r *http.Request) (User, error) {
|
|
token := sessionTokenFromRequest(r)
|
|
if token == "" {
|
|
return User{}, ErrNoToken
|
|
}
|
|
|
|
su, err := v.db.GetSessionUser(r.Context(), token)
|
|
if err != nil {
|
|
return User{}, ErrInvalidToken
|
|
}
|
|
if !su.ExpiresAt.After(time.Now()) {
|
|
return User{}, ErrInvalidToken
|
|
}
|
|
// Same semantics as backend getAuthUser: suspended (banned) or unclaimed
|
|
// accounts must not retain API access via a live session.
|
|
if su.Banned || su.AccountStatus != "active" {
|
|
return User{}, ErrInvalidToken
|
|
}
|
|
// Role comes from the users row, so demotions apply immediately.
|
|
return User{ID: su.ID, Email: su.Email, Role: su.Role}, nil
|
|
}
|
|
|
|
// sessionTokenFromRequest extracts the raw session token from the Better Auth
|
|
// cookie, or from an Authorization: Bearer header (tests and CLI tooling).
|
|
func sessionTokenFromRequest(r *http.Request) string {
|
|
for _, name := range sessionCookieNames {
|
|
if c, err := r.Cookie(name); err == nil && c.Value != "" {
|
|
return tokenPart(c.Value)
|
|
}
|
|
}
|
|
if raw, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok && raw != "" {
|
|
return tokenPart(raw)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// tokenPart strips the URL-encoding and HMAC signature from a cookie value:
|
|
// "token.base64sig" -> "token". The token itself is alphanumeric, so the
|
|
// first '.' always separates it from the signature.
|
|
func tokenPart(v string) string {
|
|
if dec, err := url.QueryUnescape(v); err == nil {
|
|
v = dec
|
|
}
|
|
if i := strings.IndexByte(v, '.'); i >= 0 {
|
|
return v[:i]
|
|
}
|
|
return v
|
|
}
|