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
@@ -16,8 +16,14 @@ DATABASE_URL=postgresql://spanglish:password@localhost:5432/spanglish_db
|
||||
# DB_TYPE=sqlite
|
||||
# DATABASE_URL=../backend/data/spanglish.db
|
||||
|
||||
# MUST be identical to JWT_SECRET in backend/.env — photo-api validates the
|
||||
# same HS256 tokens the backend issues.
|
||||
# Secret for gallery image view tokens (HMAC). Any strong random value; does
|
||||
# not need to match any backend secret. Rotating it invalidates outstanding
|
||||
# image URLs for at most ~1 hour (they are re-minted per page load).
|
||||
PHOTO_VIEW_SECRET=8b69468724b730dddecba3d04717cf44e6dc5b808773e7e60156d38875f0f6cd
|
||||
|
||||
# DEPRECATED: fallback for PHOTO_VIEW_SECRET during the Better Auth migration
|
||||
# (user auth now validates Better Auth sessions from the shared database, not
|
||||
# JWTs). Set PHOTO_VIEW_SECRET and remove this.
|
||||
JWT_SECRET=
|
||||
|
||||
# Public site origin, used to build share links and allow dev CORS
|
||||
|
||||
@@ -85,7 +85,7 @@ func main() {
|
||||
|
||||
server := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||
Handler: httpapi.New(cfg, db, st, auth.NewVerifier(cfg.JWTSecret, db), wrk).Handler(),
|
||||
Handler: httpapi.New(cfg, db, st, auth.NewVerifier(db), wrk).Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.31
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.30
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
|
||||
|
||||
@@ -39,8 +39,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
// Package auth validates the JWTs minted by backend/src/lib/auth.ts:
|
||||
// HS256 with the shared JWT_SECRET, issuer "spanglish", audience
|
||||
// "spanglish-app", plus the same DB-backed tokenVersion / account_status
|
||||
// revocation check the backend's getAuthUser performs.
|
||||
// Package auth validates the Better Auth sessions issued by the backend.
|
||||
// The session cookie value is "<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"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"time"
|
||||
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
issuer = "spanglish"
|
||||
audience = "spanglish-app"
|
||||
)
|
||||
// Cookie names set by the backend: advanced.cookiePrefix "spanglish", with
|
||||
// the __Secure- prefix added in production (advanced.useSecureCookies).
|
||||
var sessionCookieNames = []string{
|
||||
"__Secure-spanglish.session_token",
|
||||
"spanglish.session_token",
|
||||
}
|
||||
|
||||
var (
|
||||
ErrNoToken = errors.New("no bearer token")
|
||||
ErrInvalidToken = errors.New("invalid token")
|
||||
ErrNoToken = errors.New("no session token")
|
||||
ErrInvalidToken = errors.New("invalid session")
|
||||
)
|
||||
|
||||
// AdminRoles mirrors backend/src/routes/media.ts: photo administration is
|
||||
@@ -43,57 +47,61 @@ func (u User) IsAdmin() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type claims struct {
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
TokenVersion *int `json:"tokenVersion"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type Verifier struct {
|
||||
secret []byte
|
||||
db *store.DB
|
||||
db *store.DB
|
||||
}
|
||||
|
||||
func NewVerifier(secret string, db *store.DB) *Verifier {
|
||||
return &Verifier{secret: []byte(secret), db: db}
|
||||
func NewVerifier(db *store.DB) *Verifier {
|
||||
return &Verifier{db: db}
|
||||
}
|
||||
|
||||
// FromRequest returns the authenticated user, ErrNoToken when no
|
||||
// Authorization header is present, or ErrInvalidToken for anything bad.
|
||||
// FromRequest returns the authenticated user, ErrNoToken when no session
|
||||
// cookie or bearer token is present, or ErrInvalidToken for anything bad.
|
||||
func (v *Verifier) FromRequest(r *http.Request) (User, error) {
|
||||
header := r.Header.Get("Authorization")
|
||||
if header == "" {
|
||||
token := sessionTokenFromRequest(r)
|
||||
if token == "" {
|
||||
return User{}, ErrNoToken
|
||||
}
|
||||
raw, ok := strings.CutPrefix(header, "Bearer ")
|
||||
if !ok {
|
||||
return User{}, ErrInvalidToken
|
||||
}
|
||||
|
||||
var c claims
|
||||
_, err := jwt.ParseWithClaims(raw, &c, func(*jwt.Token) (any, error) { return v.secret, nil },
|
||||
jwt.WithValidMethods([]string{"HS256"}),
|
||||
jwt.WithIssuer(issuer),
|
||||
jwt.WithAudience(audience),
|
||||
jwt.WithExpirationRequired(),
|
||||
)
|
||||
if err != nil || c.Subject == "" {
|
||||
return User{}, ErrInvalidToken
|
||||
}
|
||||
|
||||
// DB-backed revocation, same semantics as backend getAuthUser
|
||||
// (backend/src/lib/auth.ts:320-327).
|
||||
ua, err := v.db.GetUserAuth(r.Context(), c.Subject)
|
||||
su, err := v.db.GetSessionUser(r.Context(), token)
|
||||
if err != nil {
|
||||
return User{}, ErrInvalidToken
|
||||
}
|
||||
if ua.AccountStatus != "active" {
|
||||
if !su.ExpiresAt.After(time.Now()) {
|
||||
return User{}, ErrInvalidToken
|
||||
}
|
||||
if c.TokenVersion != nil && *c.TokenVersion != ua.TokenVersion {
|
||||
// Same semantics as backend getAuthUser: suspended (banned) or unclaimed
|
||||
// accounts must not retain API access via a live session.
|
||||
if su.Banned || su.AccountStatus != "active" {
|
||||
return User{}, ErrInvalidToken
|
||||
}
|
||||
// Role from the DB, not the token, so demotions apply immediately.
|
||||
return User{ID: ua.ID, Email: c.Email, Role: ua.Role}, nil
|
||||
// Role comes from the users row, so demotions apply immediately.
|
||||
return User{ID: su.ID, Email: su.Email, Role: su.Role}, nil
|
||||
}
|
||||
|
||||
// sessionTokenFromRequest extracts the raw session token from the Better Auth
|
||||
// cookie, or from an Authorization: Bearer header (tests and CLI tooling).
|
||||
func sessionTokenFromRequest(r *http.Request) string {
|
||||
for _, name := range sessionCookieNames {
|
||||
if c, err := r.Cookie(name); err == nil && c.Value != "" {
|
||||
return tokenPart(c.Value)
|
||||
}
|
||||
}
|
||||
if raw, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok && raw != "" {
|
||||
return tokenPart(raw)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// tokenPart strips the URL-encoding and HMAC signature from a cookie value:
|
||||
// "token.base64sig" -> "token". The token itself is alphanumeric, so the
|
||||
// first '.' always separates it from the signature.
|
||||
func tokenPart(v string) string {
|
||||
if dec, err := url.QueryUnescape(v); err == nil {
|
||||
v = dec
|
||||
}
|
||||
if i := strings.IndexByte(v, '.'); i >= 0 {
|
||||
return v[:i]
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@ type Config struct {
|
||||
Port int
|
||||
DBType string // "postgres" | "sqlite", same convention as backend DB_TYPE
|
||||
DatabaseURL string
|
||||
JWTSecret string
|
||||
// ViewTokenSecret signs the hour-bucketed gallery view tokens
|
||||
// (viewtoken.go). PHOTO_VIEW_SECRET, falling back to the legacy
|
||||
// JWT_SECRET during the Better Auth migration.
|
||||
ViewTokenSecret string
|
||||
|
||||
StoragePath string
|
||||
S3Endpoint string
|
||||
@@ -44,7 +47,7 @@ func Load() (Config, error) {
|
||||
Port: envInt("PORT", 3003),
|
||||
DBType: strings.ToLower(env("DB_TYPE", "sqlite")),
|
||||
DatabaseURL: env("DATABASE_URL", ""),
|
||||
JWTSecret: env("JWT_SECRET", ""),
|
||||
ViewTokenSecret: env("PHOTO_VIEW_SECRET", env("JWT_SECRET", "")),
|
||||
StoragePath: env("STORAGE_PATH", "./data/photos"),
|
||||
S3Endpoint: env("S3_ENDPOINT", ""),
|
||||
S3Region: env("S3_REGION", "auto"),
|
||||
@@ -64,8 +67,8 @@ func Load() (Config, error) {
|
||||
if cfg.DatabaseURL == "" {
|
||||
return cfg, fmt.Errorf("DATABASE_URL is required")
|
||||
}
|
||||
if cfg.JWTSecret == "" {
|
||||
return cfg, fmt.Errorf("JWT_SECRET is required (must match backend/.env)")
|
||||
if cfg.ViewTokenSecret == "" {
|
||||
return cfg, fmt.Errorf("PHOTO_VIEW_SECRET is required (or legacy JWT_SECRET as fallback)")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ func (s *Server) authorize(r *http.Request, g store.Gallery, user *auth.User, to
|
||||
// expires; it is only ever issued after a successful authorize, and it
|
||||
// is what lets <img> requests (which cannot carry a Bearer header)
|
||||
// through for non-public galleries.
|
||||
if token != "" && verifyViewToken([]byte(s.cfg.JWTSecret), g.ID, token) {
|
||||
if token != "" && verifyViewToken([]byte(s.cfg.ViewTokenSecret), g.ID, token) {
|
||||
return nil
|
||||
}
|
||||
switch g.Visibility {
|
||||
|
||||
@@ -17,8 +17,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/config"
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
|
||||
@@ -43,6 +41,7 @@ type testEnv struct {
|
||||
handler http.Handler
|
||||
db *store.DB
|
||||
worker *worker.Worker
|
||||
pg bool
|
||||
}
|
||||
|
||||
// setup migrates a scratch DB (SQLite by default; Postgres when
|
||||
@@ -55,7 +54,7 @@ func setup(t *testing.T) *testEnv {
|
||||
Port: 0,
|
||||
DBType: "sqlite",
|
||||
DatabaseURL: filepath.Join(dir, "test.db"),
|
||||
JWTSecret: testSecret,
|
||||
ViewTokenSecret: testSecret,
|
||||
StoragePath: filepath.Join(dir, "photos"),
|
||||
MaxUploadMB: 5,
|
||||
WorkerConcurrency: 1,
|
||||
@@ -72,7 +71,7 @@ func setup(t *testing.T) *testEnv {
|
||||
t.Cleanup(func() { db.Close() })
|
||||
if cfg.DBType == "postgres" {
|
||||
// The scratch Postgres DB persists across tests; start clean.
|
||||
if _, err := db.Exec(`DROP TABLE IF EXISTS photos_photos, photos_galleries, photos_schema_migrations, users, events, tickets, payments CASCADE`); err != nil {
|
||||
if _, err := db.Exec(`DROP TABLE IF EXISTS photos_photos, photos_galleries, photos_schema_migrations, users, events, tickets, payments, auth_sessions CASCADE`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -81,20 +80,27 @@ func setup(t *testing.T) *testEnv {
|
||||
}
|
||||
|
||||
idType := "text"
|
||||
expiresType := "integer" // sqlite: Better Auth stores epoch-ms integers
|
||||
boolType := "integer"
|
||||
falseLit := "0"
|
||||
if cfg.DBType == "postgres" {
|
||||
idType = "uuid"
|
||||
expiresType = "timestamp"
|
||||
boolType = "boolean"
|
||||
falseLit = "FALSE"
|
||||
}
|
||||
seed := []string{
|
||||
`CREATE TABLE users (id ` + idType + ` PRIMARY KEY, email text, name text, role text, token_version integer NOT NULL DEFAULT 0, account_status text NOT NULL DEFAULT 'active')`,
|
||||
`CREATE TABLE users (id ` + idType + ` PRIMARY KEY, email text, name text, role text, account_status text NOT NULL DEFAULT 'active', banned ` + boolType + ` NOT NULL DEFAULT ` + falseLit + `)`,
|
||||
`CREATE TABLE auth_sessions (id text PRIMARY KEY, user_id ` + idType + `, token text NOT NULL UNIQUE, expires_at ` + expiresType + ` NOT NULL)`,
|
||||
`CREATE TABLE events (id ` + idType + ` PRIMARY KEY, slug text, title text, title_es text, start_datetime text, status text, price real)`,
|
||||
`CREATE TABLE tickets (id text PRIMARY KEY, user_id ` + idType + `, event_id ` + idType + `, status text)`,
|
||||
`CREATE TABLE payments (id text PRIMARY KEY, ticket_id text, status text)`,
|
||||
|
||||
`INSERT INTO users VALUES ('` + uAdmin + `','a@x.py','Admin','admin',0,'active')`,
|
||||
`INSERT INTO users VALUES ('` + uMember + `','m@x.py','Member','user',0,'active')`,
|
||||
`INSERT INTO users VALUES ('` + uBuyer + `','b@x.py','Buyer','user',0,'active')`,
|
||||
`INSERT INTO users VALUES ('` + uPending + `','p@x.py','Pending','user',0,'active')`,
|
||||
`INSERT INTO users VALUES ('` + uFree + `','f@x.py','Free','user',0,'active')`,
|
||||
`INSERT INTO users VALUES ('` + uAdmin + `','a@x.py','Admin','admin','active',` + falseLit + `)`,
|
||||
`INSERT INTO users VALUES ('` + uMember + `','m@x.py','Member','user','active',` + falseLit + `)`,
|
||||
`INSERT INTO users VALUES ('` + uBuyer + `','b@x.py','Buyer','user','active',` + falseLit + `)`,
|
||||
`INSERT INTO users VALUES ('` + uPending + `','p@x.py','Pending','user','active',` + falseLit + `)`,
|
||||
`INSERT INTO users VALUES ('` + uFree + `','f@x.py','Free','user','active',` + falseLit + `)`,
|
||||
|
||||
`INSERT INTO events VALUES ('` + evPaid + `','fiesta','Fiesta','Fiesta ES','2026-06-01T20:00:00.000Z','completed',50000)`,
|
||||
`INSERT INTO events VALUES ('` + evFree + `','gratis','Gratis','Gratis ES','2026-06-02T20:00:00.000Z','completed',0)`,
|
||||
@@ -116,23 +122,27 @@ func setup(t *testing.T) *testEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wrk := worker.New(db, st, nil, cfg.StoragePath, 1)
|
||||
srv := New(cfg, db, st, auth.NewVerifier(cfg.JWTSecret, db), wrk)
|
||||
return &testEnv{handler: srv.Handler(), db: db, worker: wrk}
|
||||
srv := New(cfg, db, st, auth.NewVerifier(db), wrk)
|
||||
return &testEnv{handler: srv.Handler(), db: db, worker: wrk, pg: cfg.DBType == "postgres"}
|
||||
}
|
||||
|
||||
func makeToken(t *testing.T, sub, email, role string) string {
|
||||
// makeToken inserts a Better Auth session row for the user and returns a
|
||||
// bearer value in the cookie's "token.signature" format (the verifier
|
||||
// resolves the token part against auth_sessions; the signature is unused).
|
||||
func (e *testEnv) makeToken(t *testing.T, sub string) string {
|
||||
t.Helper()
|
||||
tv := 0
|
||||
claims := jwt.MapClaims{
|
||||
"sub": sub, "email": email, "role": role, "tokenVersion": tv,
|
||||
"iss": "spanglish", "aud": "spanglish-app",
|
||||
"iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(),
|
||||
token := "tok-" + newID()
|
||||
expires := time.Now().Add(time.Hour)
|
||||
var expiresVal any = expires.UnixMilli()
|
||||
if e.pg {
|
||||
expiresVal = expires
|
||||
}
|
||||
s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(testSecret))
|
||||
if err != nil {
|
||||
if _, err := e.db.Exec(e.db.Rebind(
|
||||
`INSERT INTO auth_sessions (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)`),
|
||||
newID(), sub, token, expiresVal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
return token + ".testsig"
|
||||
}
|
||||
|
||||
func (e *testEnv) request(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder {
|
||||
@@ -193,16 +203,61 @@ func TestAdminGate(t *testing.T) {
|
||||
if w := e.request(t, "POST", "/api/photos/galleries", "", body); w.Code != 401 {
|
||||
t.Fatalf("anon create: want 401, got %d", w.Code)
|
||||
}
|
||||
member := makeToken(t, uMember, "m@x.py", "user")
|
||||
member := e.makeToken(t, uMember)
|
||||
if w := e.request(t, "POST", "/api/photos/galleries", member, body); w.Code != 403 {
|
||||
t.Fatalf("member create: want 403, got %d", w.Code)
|
||||
}
|
||||
unknown := makeToken(t, newID(), "g@x.py", "admin")
|
||||
unknown := e.makeToken(t, newID())
|
||||
if w := e.request(t, "POST", "/api/photos/galleries", unknown, body); w.Code != 401 {
|
||||
t.Fatalf("unknown-user token: want 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionValidation(t *testing.T) {
|
||||
e := setup(t)
|
||||
body := map[string]string{"title": "Test"}
|
||||
|
||||
// Expired session -> 401
|
||||
expiredTok := "tok-" + newID()
|
||||
var expiresVal any = time.Now().Add(-time.Hour).UnixMilli()
|
||||
if e.pg {
|
||||
expiresVal = time.Now().Add(-time.Hour)
|
||||
}
|
||||
if _, err := e.db.Exec(e.db.Rebind(
|
||||
`INSERT INTO auth_sessions (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)`),
|
||||
newID(), uAdmin, expiredTok, expiresVal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w := e.request(t, "POST", "/api/photos/galleries", expiredTok+".sig", body); w.Code != 401 {
|
||||
t.Fatalf("expired session: want 401, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Banned (suspended) user -> 401 even with a live session
|
||||
bannedTok := e.makeToken(t, uMember)
|
||||
if _, err := e.db.Exec(e.db.Rebind(
|
||||
`UPDATE users SET account_status = 'suspended' WHERE id = ?`), uMember); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w := e.request(t, "POST", "/api/photos/galleries", bannedTok, body); w.Code != 401 {
|
||||
t.Fatalf("suspended user: want 401, got %d", w.Code)
|
||||
}
|
||||
if _, err := e.db.Exec(e.db.Rebind(
|
||||
`UPDATE users SET account_status = 'active' WHERE id = ?`), uMember); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Session via the browser cookie (URL-encoded token.signature) -> works
|
||||
adminTok := e.makeToken(t, uAdmin)
|
||||
req := httptest.NewRequest("POST", "/api/photos/galleries", bytes.NewReader([]byte(`{"title":"Via cookie"}`)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(&http.Cookie{Name: "spanglish.session_token", Value: strings.ReplaceAll(adminTok, ".", "%2E")})
|
||||
w := httptest.NewRecorder()
|
||||
e.handler.ServeHTTP(w, req)
|
||||
if w.Code != 200 && w.Code != 201 {
|
||||
t.Fatalf("cookie auth: want 2xx, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
type galleryResp struct {
|
||||
Gallery galleryJSON `json:"gallery"`
|
||||
Photos []photoJSON `json:"photos"`
|
||||
@@ -265,7 +320,7 @@ func processQueue(t *testing.T, e *testEnv, photoID string) store.Photo {
|
||||
|
||||
func TestUploadProcessAndServe(t *testing.T) {
|
||||
e := setup(t)
|
||||
admin := makeToken(t, uAdmin, "a@x.py", "admin")
|
||||
admin := e.makeToken(t, uAdmin)
|
||||
g := createGallery(t, e, admin, map[string]any{"title": "Fiesta de Junio", "visibility": "public"})
|
||||
if g.Slug != "fiesta-de-junio" {
|
||||
t.Fatalf("slug: %q", g.Slug)
|
||||
@@ -325,11 +380,11 @@ func TestUploadProcessAndServe(t *testing.T) {
|
||||
|
||||
func TestAccessMatrix(t *testing.T) {
|
||||
e := setup(t)
|
||||
admin := makeToken(t, uAdmin, "a@x.py", "admin")
|
||||
member := makeToken(t, uMember, "m@x.py", "user")
|
||||
buyer := makeToken(t, uBuyer, "b@x.py", "user")
|
||||
pending := makeToken(t, uPending, "p@x.py", "user")
|
||||
freeguy := makeToken(t, uFree, "f@x.py", "user")
|
||||
admin := e.makeToken(t, uAdmin)
|
||||
member := e.makeToken(t, uMember)
|
||||
buyer := e.makeToken(t, uBuyer)
|
||||
pending := e.makeToken(t, uPending)
|
||||
freeguy := e.makeToken(t, uFree)
|
||||
|
||||
get := func(slug, token, bearer string) int {
|
||||
path := "/api/photos/public/galleries/" + slug
|
||||
@@ -408,7 +463,7 @@ func TestAccessMatrix(t *testing.T) {
|
||||
|
||||
func TestReorderAndVisibilityUpdate(t *testing.T) {
|
||||
e := setup(t)
|
||||
admin := makeToken(t, uAdmin, "a@x.py", "admin")
|
||||
admin := e.makeToken(t, uAdmin)
|
||||
g := createGallery(t, e, admin, map[string]any{"title": "Orden", "visibility": "public"})
|
||||
jpg := testJPEG(t)
|
||||
p1 := uploadPhoto(t, e, admin, g.ID, jpg)
|
||||
@@ -447,7 +502,7 @@ func TestReorderAndVisibilityUpdate(t *testing.T) {
|
||||
// accepts anonymously.
|
||||
func TestViewTokens(t *testing.T) {
|
||||
e := setup(t)
|
||||
admin := makeToken(t, uAdmin, "a@x.py", "admin")
|
||||
admin := e.makeToken(t, uAdmin)
|
||||
|
||||
g := createGallery(t, e, admin, map[string]any{"title": "Privada Con Fotos", "visibility": "private"})
|
||||
p := uploadPhoto(t, e, admin, g.ID, testJPEG(t))
|
||||
@@ -499,8 +554,8 @@ func TestViewTokens(t *testing.T) {
|
||||
|
||||
func TestEventGalleryRoute(t *testing.T) {
|
||||
e := setup(t)
|
||||
admin := makeToken(t, uAdmin, "a@x.py", "admin")
|
||||
buyer := makeToken(t, uBuyer, "b@x.py", "user")
|
||||
admin := e.makeToken(t, uAdmin)
|
||||
buyer := e.makeToken(t, uBuyer)
|
||||
|
||||
g := createGallery(t, e, admin, map[string]any{"title": "Del Evento", "visibility": "ticket", "eventId": evPaid})
|
||||
|
||||
@@ -546,7 +601,7 @@ func TestEventGalleryRoute(t *testing.T) {
|
||||
|
||||
func TestSlugCollision(t *testing.T) {
|
||||
e := setup(t)
|
||||
admin := makeToken(t, uAdmin, "a@x.py", "admin")
|
||||
admin := e.makeToken(t, uAdmin)
|
||||
a := createGallery(t, e, admin, map[string]any{"title": "Misma Fiesta"})
|
||||
b := createGallery(t, e, admin, map[string]any{"title": "Misma Fiesta"})
|
||||
if a.Slug == b.Slug {
|
||||
|
||||
@@ -67,7 +67,7 @@ func (s *Server) viewTokenFor(g store.Gallery) string {
|
||||
if g.Visibility == store.VisibilityPublic {
|
||||
return ""
|
||||
}
|
||||
return mintViewToken([]byte(s.cfg.JWTSecret), g.ID)
|
||||
return mintViewToken([]byte(s.cfg.ViewTokenSecret), g.ID)
|
||||
}
|
||||
|
||||
func isoTime(t time.Time) string {
|
||||
|
||||
@@ -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