From 98de298e5b4aa74517e8b10173539616f31fe9d2 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Mon, 15 Jun 2026 17:53:10 +0300 Subject: [PATCH] feat(backend): file-scoped content tokens for media URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/cmd/server/main.go | 3 +- backend/internal/config/config.go | 9 +++ backend/internal/handler/file_handler.go | 39 ++++++++- backend/internal/handler/middleware.go | 50 ++++++++++++ backend/internal/handler/router.go | 12 ++- backend/internal/handler/router_test.go | 23 ++++++ backend/internal/integration/server_test.go | 4 +- backend/internal/service/auth_service.go | 62 ++++++++++++++ backend/internal/service/auth_service_test.go | 80 +++++++++++++++++++ 9 files changed, 275 insertions(+), 7 deletions(-) create mode 100644 backend/internal/handler/router_test.go create mode 100644 backend/internal/service/auth_service_test.go diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 4c26121..1db842a 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 0a7508a..2aa9641 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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"), diff --git a/backend/internal/handler/file_handler.go b/backend/internal/handler/file_handler.go index cda0979..44ef8fb 100644 --- a/backend/internal/handler/file_handler.go +++ b/backend/internal/handler/file_handler.go @@ -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 // --------------------------------------------------------------------------- diff --git a/backend/internal/handler/middleware.go b/backend/internal/handler/middleware.go index 4467ff8..3000c61 100644 --- a/backend/internal/handler/middleware.go +++ b/backend/internal/handler/middleware.go @@ -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 diff --git a/backend/internal/handler/router.go b/backend/internal/handler/router.go index 9d977c1..55ff518 100644 --- a/backend/internal/handler/router.go +++ b/backend/internal/handler/router.go @@ -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) // ------------------------------------------------------------------------- diff --git a/backend/internal/handler/router_test.go b/backend/internal/handler/router_test.go new file mode 100644 index 0000000..aaba7e3 --- /dev/null +++ b/backend/internal/handler/router_test.go @@ -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") + } +} diff --git a/backend/internal/integration/server_test.go b/backend/internal/integration/server_test.go index a08fd45..8439968 100644 --- a/backend/internal/integration/server_test.go +++ b/backend/internal/integration/server_test.go @@ -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) diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index a23abb1..9623805 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -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 { diff --git a/backend/internal/service/auth_service_test.go b/backend/internal/service/auth_service_test.go new file mode 100644 index 0000000..d0f0185 --- /dev/null +++ b/backend/internal/service/auth_service_test.go @@ -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") + } +}