Introduces the Go photo-api service, nginx/systemd deploy wiring, and Next.js gallery/lightbox pages so event photos can be managed and browsed. Co-authored-by: Cursor <cursoragent@cursor.com>
100 lines
2.4 KiB
Go
100 lines
2.4 KiB
Go
// 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
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
|
|
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
|
|
)
|
|
|
|
const (
|
|
issuer = "spanglish"
|
|
audience = "spanglish-app"
|
|
)
|
|
|
|
var (
|
|
ErrNoToken = errors.New("no bearer token")
|
|
ErrInvalidToken = errors.New("invalid token")
|
|
)
|
|
|
|
// 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 claims struct {
|
|
Email string `json:"email"`
|
|
Role string `json:"role"`
|
|
TokenVersion *int `json:"tokenVersion"`
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
type Verifier struct {
|
|
secret []byte
|
|
db *store.DB
|
|
}
|
|
|
|
func NewVerifier(secret string, db *store.DB) *Verifier {
|
|
return &Verifier{secret: []byte(secret), db: db}
|
|
}
|
|
|
|
// FromRequest returns the authenticated user, ErrNoToken when no
|
|
// Authorization header is present, or ErrInvalidToken for anything bad.
|
|
func (v *Verifier) FromRequest(r *http.Request) (User, error) {
|
|
header := r.Header.Get("Authorization")
|
|
if header == "" {
|
|
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)
|
|
if err != nil {
|
|
return User{}, ErrInvalidToken
|
|
}
|
|
if ua.AccountStatus != "active" {
|
|
return User{}, ErrInvalidToken
|
|
}
|
|
if c.TokenVersion != nil && *c.TokenVersion != ua.TokenVersion {
|
|
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
|
|
}
|