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>
62 lines
2.1 KiB
Go
62 lines
2.1 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
|
|
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
|
|
)
|
|
|
|
// accessDenial describes why a viewer may not see a gallery. Each mode has
|
|
// a distinct message so the frontend can show a matching gate page (log in,
|
|
// ask for the share link, buy a ticket). The messages are part of the API
|
|
// contract with GalleryClient.tsx — change both together.
|
|
type accessDenial struct {
|
|
status int
|
|
msg string
|
|
}
|
|
|
|
// authorize is the single access-control decision point (PLAN.md §7); it is
|
|
// called from both the gallery-view handler and the file handler so every
|
|
// byte served re-checks.
|
|
func (s *Server) authorize(r *http.Request, g store.Gallery, user *auth.User, token string) *accessDenial {
|
|
if user != nil && user.IsAdmin() {
|
|
return nil
|
|
}
|
|
// A server-minted view token grants access to this one gallery until it
|
|
// 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.ViewTokenSecret), g.ID, token) {
|
|
return nil
|
|
}
|
|
switch g.Visibility {
|
|
case store.VisibilityPublic:
|
|
return nil
|
|
case store.VisibilityLink:
|
|
if token != "" && token == g.ShareToken {
|
|
return nil
|
|
}
|
|
return &accessDenial{http.StatusForbidden, "This gallery needs its share link"}
|
|
case store.VisibilityTicket:
|
|
// The share token is honored as an escape hatch (e.g. attendee +1s
|
|
// without accounts, at the admin's discretion).
|
|
if token != "" && token == g.ShareToken {
|
|
return nil
|
|
}
|
|
if user == nil {
|
|
return &accessDenial{http.StatusUnauthorized, "Authentication required"}
|
|
}
|
|
if g.EventID == "" {
|
|
return &accessDenial{http.StatusForbidden, "This gallery is only available to event attendees"}
|
|
}
|
|
ok, err := s.db.HasPaidTicket(r.Context(), user.ID, g.EventID)
|
|
if err != nil || !ok {
|
|
return &accessDenial{http.StatusForbidden, "This gallery is only available to event attendees"}
|
|
}
|
|
return nil
|
|
default: // private
|
|
return &accessDenial{http.StatusForbidden, "This gallery is private"}
|
|
}
|
|
}
|