feat(backend): file-scoped content tokens for media URLs

Opening an original by URL (?access_token=) baked in the 15-minute access
token, so a long video opened in a new tab stopped streaming once that token
expired mid-playback: the access token can't be refreshed in an already-opened
tab, and its next Range request 401'd.

Add a content token: a signed, single-file capability (typ=content, fid claim)
with its own longer TTL (CONTENT_TOKEN_TTL, default 6h) and — crucially — no
session id, so it survives refresh rotation and outlives the short access TTL.
POST /files/:id/content-token mints one after the same view-ACL check content
serving does; GET /files/:id/content now runs under content-aware auth that
accepts either a normal access token or a content token scoped to that file.
View permission is still enforced against the token's user, so the token only
changes when a file may be read by URL, never which files. It's a bearer
capability for that one file until expiry, hence the bounded, configurable TTL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 17:53:10 +03:00
parent b470782e97
commit 98de298e5b
9 changed files with 275 additions and 7 deletions
+2 -1
View File
@@ -79,6 +79,7 @@ func main() {
cfg.JWTSecret,
cfg.JWTAccessTTL,
cfg.JWTRefreshTTL,
cfg.ContentTokenTTL,
)
aclSvc := service.NewACLService(aclRepo, fileRepo, tagRepo, categoryRepo, poolRepo, transactor)
auditSvc := service.NewAuditService(auditRepo)
@@ -106,7 +107,7 @@ func main() {
// Handlers
authMiddleware := handler.NewAuthMiddleware(authSvc)
authHandler := handler.NewAuthHandler(authSvc)
fileHandler := handler.NewFileHandler(fileSvc, tagSvc, cfg.MaxUploadBytes)
fileHandler := handler.NewFileHandler(fileSvc, tagSvc, authSvc, cfg.MaxUploadBytes)
tagHandler := handler.NewTagHandler(tagSvc, fileSvc)
categoryHandler := handler.NewCategoryHandler(categorySvc)
poolHandler := handler.NewPoolHandler(poolSvc)
+9
View File
@@ -18,6 +18,13 @@ type Config struct {
JWTSecret string
JWTAccessTTL time.Duration
JWTRefreshTTL time.Duration
// ContentTokenTTL is how long a content token stays valid. The token is a
// single-file capability used to open or stream an original by URL (e.g. a
// long video in a new tab); it is deliberately longer-lived than the access
// token and independent of the session, so playback survives access-token
// expiry and refresh rotation. Keep it only as long as a viewing session
// plausibly lasts — it is a bearer credential for that one file until expiry.
ContentTokenTTL time.Duration
// TrustedProxies lists the reverse-proxy hops (CIDRs or IPs) whose
// X-Forwarded-For header is trusted. The auth rate limiter keys on the
// client IP, so this must match the proxy in front of the app — otherwise
@@ -139,6 +146,8 @@ func Load() (*Config, error) {
JWTAccessTTL: parseDuration("JWT_ACCESS_TTL", "15m"),
JWTRefreshTTL: parseDuration("JWT_REFRESH_TTL", "720h"),
ContentTokenTTL: parseDuration("CONTENT_TOKEN_TTL", "6h"),
TrustedProxies: parseCSV("TRUSTED_PROXIES", "127.0.0.1/32,::1/128,172.16.0.0/12"),
AdminUsername: defaultStr("ADMIN_USERNAME", "admin"),
+36 -3
View File
@@ -22,13 +22,14 @@ import (
type FileHandler struct {
fileSvc *service.FileService
tagSvc *service.TagService
authSvc *service.AuthService
maxUploadBytes int64
}
// NewFileHandler creates a FileHandler. maxUploadBytes caps the size of an
// uploaded or replacement file.
func NewFileHandler(fileSvc *service.FileService, tagSvc *service.TagService, maxUploadBytes int64) *FileHandler {
return &FileHandler{fileSvc: fileSvc, tagSvc: tagSvc, maxUploadBytes: maxUploadBytes}
// uploaded or replacement file. authSvc mints content tokens for media URLs.
func NewFileHandler(fileSvc *service.FileService, tagSvc *service.TagService, authSvc *service.AuthService, maxUploadBytes int64) *FileHandler {
return &FileHandler{fileSvc: fileSvc, tagSvc: tagSvc, authSvc: authSvc, maxUploadBytes: maxUploadBytes}
}
// formFileLimited reads the "file" multipart field while bounding how many bytes
@@ -383,6 +384,38 @@ func (h *FileHandler) SoftDelete(c *gin.Context) {
c.Status(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// POST /files/:id/content-token
// ---------------------------------------------------------------------------
// CreateContentToken mints a short-lived, single-file capability token the
// client can put in a content URL's access_token query parameter to open or
// stream the original by link (e.g. a long video in a new tab) without the URL
// dying when the 15-minute access token expires. It first enforces view
// permission via fileSvc.Get, so a token is only issued for a file the caller
// may actually read.
func (h *FileHandler) CreateContentToken(c *gin.Context) {
id, ok := parseFileID(c)
if !ok {
return
}
// Authorize (and confirm existence) the same way content serving does.
if _, err := h.fileSvc.Get(c.Request.Context(), id); err != nil {
respondError(c, err)
return
}
userID, isAdmin, _ := domain.UserFromContext(c.Request.Context())
token, expiresIn, err := h.authSvc.GenerateContentToken(id.String(), userID, isAdmin)
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"token": token, "expires_in": expiresIn})
}
// ---------------------------------------------------------------------------
// GET /files/:id/content
// ---------------------------------------------------------------------------
+50
View File
@@ -5,6 +5,7 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/service"
@@ -50,6 +51,55 @@ func (m *AuthMiddleware) Handle() gin.HandlerFunc {
}
}
// HandleContent authenticates a file-content GET, accepting either a normal
// access token or a content token scoped (by its fid claim) to the :id in the
// path. The content token is what keeps a long media stream playing after the
// short access token would have expired. View permission is still enforced in
// the handler against the resolved user, so a content token only widens *when*
// a file may be read by URL, never *which* files.
func (m *AuthMiddleware) HandleContent() gin.HandlerFunc {
return func(c *gin.Context) {
token := bearerToken(c)
if token == "" {
contentUnauthorized(c)
return
}
// A regular access token grants access to everything as usual.
if claims, err := m.authSvc.ValidateAccessToken(c.Request.Context(), token); err == nil {
ctx := domain.WithUser(c.Request.Context(), claims.UserID, claims.IsAdmin, claims.SessionID)
c.Request = c.Request.WithContext(ctx)
c.Next()
return
}
// Otherwise accept a content token minted for exactly this file. Normalise
// the path id to canonical form so it matches the minted fid claim.
id, err := uuid.Parse(c.Param("id"))
if err != nil {
contentUnauthorized(c)
return
}
claims, err := m.authSvc.ValidateContentToken(token, id.String())
if err != nil {
contentUnauthorized(c)
return
}
// A content token carries no session (sid 0); it is session-independent.
ctx := domain.WithUser(c.Request.Context(), claims.UserID, claims.IsAdmin, claims.SessionID)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
func contentUnauthorized(c *gin.Context) {
c.JSON(http.StatusUnauthorized, errorBody{
Code: domain.ErrUnauthorized.Code(),
Message: "invalid or expired token",
})
c.Abort()
}
// bearerToken extracts the access token from the Authorization header. As a
// fallback it accepts an ?access_token= query parameter, but only for GET
// requests — this lets the browser open media (e.g. /files/{id}/content) via a
+11 -1
View File
@@ -91,8 +91,9 @@ func NewRouter(
files.PATCH("/:id", fileHandler.UpdateMeta)
files.DELETE("/:id", fileHandler.SoftDelete)
files.GET("/:id/content", fileHandler.GetContent)
files.PUT("/:id/content", fileHandler.ReplaceContent)
// Mints a content token (strict auth) for the GET /:id/content route below.
files.POST("/:id/content-token", fileHandler.CreateContentToken)
files.GET("/:id/thumbnail", fileHandler.GetThumbnail)
files.GET("/:id/preview", fileHandler.GetPreview)
files.POST("/:id/views", fileHandler.RecordView)
@@ -106,6 +107,15 @@ func NewRouter(
files.DELETE("/:id/tags/:tag_id", tagHandler.FileRemoveTag)
}
// Serving an original is the one read that can outlive a 15-minute access
// token — a long video streams via repeated Range requests over many minutes.
// So this route alone also accepts a file-scoped content token (see
// HandleContent), letting the media URL stay valid for the whole playback.
media := v1.Group("/files", auth.HandleContent())
{
media.GET("/:id/content", fileHandler.GetContent)
}
// -------------------------------------------------------------------------
// Tags (all require auth)
// -------------------------------------------------------------------------
+23
View File
@@ -0,0 +1,23 @@
package handler
import "testing"
// TestNewRouterRegisters builds the router with typed-nil dependencies to assert
// route registration itself succeeds. Gin panics on a route conflict (e.g. a
// duplicated method+path or an inconsistent wildcard name) during registration,
// before any handler runs — so this catches such mistakes without a database.
// Handlers are never invoked here; method values on nil pointers are fine.
func TestNewRouterRegisters(t *testing.T) {
r, err := NewRouter(
(*AuthMiddleware)(nil), (*AuthHandler)(nil),
(*FileHandler)(nil), (*TagHandler)(nil), (*CategoryHandler)(nil), (*PoolHandler)(nil),
(*UserHandler)(nil), (*ACLHandler)(nil), (*AuditHandler)(nil),
"", nil,
)
if err != nil {
t.Fatalf("NewRouter: %v", err)
}
if r == nil {
t.Fatal("NewRouter returned nil engine")
}
}
+2 -2
View File
@@ -127,7 +127,7 @@ func setupSuite(t *testing.T) *harness {
transactor := postgres.NewTransactor(pool)
// --- Services ------------------------------------------------------------
authSvc := service.NewAuthService(userRepo, sessionRepo, "test-secret", 15*time.Minute, 720*time.Hour)
authSvc := service.NewAuthService(userRepo, sessionRepo, "test-secret", 15*time.Minute, 720*time.Hour, 6*time.Hour)
aclSvc := service.NewACLService(aclRepo, fileRepo, tagRepo, categoryRepo, poolRepo, transactor)
auditSvc := service.NewAuditService(auditRepo)
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor)
@@ -143,7 +143,7 @@ func setupSuite(t *testing.T) *harness {
// --- Handlers ------------------------------------------------------------
authMiddleware := handler.NewAuthMiddleware(authSvc)
authHandler := handler.NewAuthHandler(authSvc)
fileHandler := handler.NewFileHandler(fileSvc, tagSvc, 500<<20)
fileHandler := handler.NewFileHandler(fileSvc, tagSvc, authSvc, 500<<20)
tagHandler := handler.NewTagHandler(tagSvc, fileSvc)
categoryHandler := handler.NewCategoryHandler(categorySvc)
poolHandler := handler.NewPoolHandler(poolSvc)
+62
View File
@@ -20,6 +20,11 @@ import (
const (
tokenTypeAccess = "access"
tokenTypeRefresh = "refresh"
// tokenTypeContent is a file-scoped capability for reading one file's
// content by URL (originals / media streaming). It is not tied to a session,
// so it outlives the short access TTL and refresh rotation — letting a long
// video keep playing past access-token expiry.
tokenTypeContent = "content"
)
// dummyPasswordHash is a valid bcrypt hash used to equalise the cost of a login
@@ -34,6 +39,8 @@ type Claims struct {
IsAdmin bool `json:"adm"`
SessionID int `json:"sid"`
TokenType string `json:"typ"`
// FileID scopes a content token to a single file; empty on access/refresh.
FileID string `json:"fid,omitempty"`
}
// TokenPair holds an issued access/refresh token pair with the access TTL.
@@ -50,6 +57,7 @@ type AuthService struct {
secret []byte
accessTTL time.Duration
refreshTTL time.Duration
contentTTL time.Duration
}
// NewAuthService creates an AuthService.
@@ -59,6 +67,7 @@ func NewAuthService(
jwtSecret string,
accessTTL time.Duration,
refreshTTL time.Duration,
contentTTL time.Duration,
) *AuthService {
return &AuthService{
users: users,
@@ -66,6 +75,7 @@ func NewAuthService(
secret: []byte(jwtSecret),
accessTTL: accessTTL,
refreshTTL: refreshTTL,
contentTTL: contentTTL,
}
}
@@ -233,6 +243,53 @@ func (s *AuthService) ValidateAccessToken(ctx context.Context, tokenStr string)
return claims, nil
}
// GenerateContentToken issues a file-scoped capability token authorizing reads of
// one file's content (originals / media streaming) by URL. Unlike the access
// token it carries no session and is not validated against one, so it survives
// refresh rotation and outlives the short access TTL — which is what lets a long
// video keep playing. It is a bearer credential for that single file until
// ContentTokenTTL elapses. Returns the signed token and its lifetime in seconds.
func (s *AuthService) GenerateContentToken(fileID string, userID int16, isAdmin bool) (string, int, error) {
jti, err := randomJTI()
if err != nil {
return "", 0, err
}
now := time.Now()
claims := Claims{
RegisteredClaims: jwt.RegisteredClaims{
ID: jti,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(s.contentTTL)),
},
UserID: userID,
IsAdmin: isAdmin,
TokenType: tokenTypeContent,
FileID: fileID,
}
signed, err := s.signClaims(claims)
if err != nil {
return "", 0, err
}
return signed, int(s.contentTTL.Seconds()), nil
}
// ValidateContentToken parses a content token and checks it authorizes fileID.
// It verifies the signature and expiry (via parseToken), the content token type,
// and that the embedded file ID matches the requested file — so a token minted
// for one file cannot read another. It is intentionally session-independent (no
// session lookup), which is what lets it outlive access-token/session rotation.
// Per-file view permission is still enforced downstream against the token's user.
func (s *AuthService) ValidateContentToken(tokenStr, fileID string) (*Claims, error) {
claims, err := s.parseToken(tokenStr)
if err != nil {
return nil, domain.ErrUnauthorized
}
if claims.TokenType != tokenTypeContent || claims.FileID != fileID {
return nil, domain.ErrUnauthorized
}
return claims, nil
}
// issueToken signs a JWT with the given parameters. A random JWT ID guarantees
// uniqueness even for tokens minted within the same second.
func (s *AuthService) issueToken(userID int16, isAdmin bool, sessionID int, ttl time.Duration, tokenType string) (string, error) {
@@ -252,6 +309,11 @@ func (s *AuthService) issueToken(userID int16, isAdmin bool, sessionID int, ttl
SessionID: sessionID,
TokenType: tokenType,
}
return s.signClaims(claims)
}
// signClaims signs claims into an HS256 JWT with the service secret.
func (s *AuthService) signClaims(claims Claims) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(s.secret)
if err != nil {
@@ -0,0 +1,80 @@
package service
import (
"testing"
"time"
)
// newContentTokenService builds an AuthService for content-token tests. The
// content-token methods never touch the user/session repos, so nil is fine.
func newContentTokenService(contentTTL time.Duration) *AuthService {
return NewAuthService(nil, nil, "test-secret", 15*time.Minute, 720*time.Hour, contentTTL)
}
func TestContentTokenRoundTrip(t *testing.T) {
s := newContentTokenService(time.Hour)
const fid = "11111111-1111-1111-1111-111111111111"
tok, expiresIn, err := s.GenerateContentToken(fid, 7, true)
if err != nil {
t.Fatalf("GenerateContentToken: %v", err)
}
if expiresIn != int(time.Hour.Seconds()) {
t.Fatalf("expires_in = %d, want %d", expiresIn, int(time.Hour.Seconds()))
}
claims, err := s.ValidateContentToken(tok, fid)
if err != nil {
t.Fatalf("ValidateContentToken: %v", err)
}
if claims.UserID != 7 || !claims.IsAdmin {
t.Fatalf("claims user mismatch: uid=%d adm=%v", claims.UserID, claims.IsAdmin)
}
if claims.FileID != fid || claims.TokenType != tokenTypeContent {
t.Fatalf("claims scope mismatch: fid=%q typ=%q", claims.FileID, claims.TokenType)
}
}
func TestContentTokenRejectsOtherFile(t *testing.T) {
s := newContentTokenService(time.Hour)
tok, _, err := s.GenerateContentToken("11111111-1111-1111-1111-111111111111", 7, false)
if err != nil {
t.Fatal(err)
}
// A token minted for one file must not authorize another.
if _, err := s.ValidateContentToken(tok, "22222222-2222-2222-2222-222222222222"); err == nil {
t.Fatal("expected rejection for a different file id")
}
}
func TestContentTokenRejectsAccessToken(t *testing.T) {
s := newContentTokenService(time.Hour)
// An ordinary access token must not pass as a content token (wrong type).
access, err := s.issueToken(7, false, 1, 15*time.Minute, tokenTypeAccess)
if err != nil {
t.Fatal(err)
}
if _, err := s.ValidateContentToken(access, ""); err == nil {
t.Fatal("expected rejection of an access token as a content token")
}
}
func TestContentTokenRejectsExpired(t *testing.T) {
// Negative TTL → the token is already expired when minted.
s := newContentTokenService(-time.Minute)
const fid = "11111111-1111-1111-1111-111111111111"
tok, _, err := s.GenerateContentToken(fid, 7, false)
if err != nil {
t.Fatal(err)
}
if _, err := s.ValidateContentToken(tok, fid); err == nil {
t.Fatal("expected rejection of an expired content token")
}
}
func TestContentTokenRejectsGarbage(t *testing.T) {
s := newContentTokenService(time.Hour)
if _, err := s.ValidateContentToken("not-a-jwt", "11111111-1111-1111-1111-111111111111"); err == nil {
t.Fatal("expected rejection of a malformed token")
}
}