Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions services/gateway/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import (
"github.com/rs/zerolog/log"

"flowix/gateway/internal/handler"
gwmw "flowix/gateway/internal/middleware"
"flowix/gateway/internal/metrics"
gwmw "flowix/gateway/internal/middleware"
"flowix/gateway/internal/proxy"
)

Expand Down Expand Up @@ -120,9 +120,12 @@ func main() {
r.With(authMw).Get("/api/v1/videos/{id}/hls-token", gwmw.HLSTokenHandler(jwtSecret, internalToken, metadataURL))

// --- Metadata service ---
// Публичные GET (лист и деталь) — без JWT
r.Get("/api/v1/videos", metadataProxy.ServeHTTP)
r.Get("/api/v1/videos/*", metadataProxy.ServeHTTP)
// Публичные GET (лист и деталь) — без обязательного JWT, но с OptionalAuth:
// валидный Bearer превращается в X-User-ID, чтобы metadata отдала приватные
// видео владельцу (issue #44)
optAuth := gwmw.OptionalAuth(jwtSecret)
r.With(optAuth).Get("/api/v1/videos", metadataProxy.ServeHTTP)
r.With(optAuth).Get("/api/v1/videos/*", metadataProxy.ServeHTTP)

// Защищённые мутации metadata — требуют JWT
r.With(authMw).Post("/api/v1/videos", metadataProxy.ServeHTTP)
Expand Down
12 changes: 10 additions & 2 deletions services/gateway/internal/middleware/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,14 @@ func AuthMiddleware(secret string) func(http.Handler) http.Handler {

// OptionalAuth — как Auth, но не требует токен: если токен есть — валидирует
// и кладёт в контекст, если нет — пропускает. Удобно для публичных GET.
// Невалидный/просроченный токен — тоже анонимный проход (issue #44): публичная
// лента не должна падать 401, когда фронт прислал истёкший access-токен.
func OptionalAuth(secret string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := r.Header.Get("Authorization")
// клиентский X-User-ID недоверенный — убираем, ставим только после валидации JWT
r.Header.Del("X-User-ID")
if h == "" || !strings.HasPrefix(h, "Bearer ") {
next.ServeHTTP(w, r)
return
Expand All @@ -71,11 +75,15 @@ func OptionalAuth(secret string) func(http.Handler) http.Handler {
return []byte(secret), nil
}, jwt.WithValidMethods([]string{"HS256"}))
if err != nil || !token.Valid {
// невалидный токен даже в optional — 401, чтобы клиент знал
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
next.ServeHTTP(w, r)
return
}
claims, _ := token.Claims.(jwt.MapClaims)
// refresh-токен не является идентичностью для чтения приватных данных
if typ, ok := claims["type"].(string); ok && typ != "" && typ != "access" {
next.ServeHTTP(w, r)
return
}
sub, _ := claims["sub"].(string)
if sub != "" {
ctx := context.WithValue(r.Context(), UserIDKey, sub)
Expand Down
81 changes: 81 additions & 0 deletions services/gateway/internal/middleware/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,84 @@ func TestAuthRejectsRefreshToken(t *testing.T) {
t.Fatalf("want 401 for refresh token got %d", w.Code)
}
}

// Issue #44: OptionalAuth must strip a client-supplied X-User-ID — it is the
// trust boundary for owner-scoped visibility on public video reads.
func TestOptionalAuthStripsForgedUserID(t *testing.T) {
secret := "test-secret"
var sawHeader string
h := OptionalAuth(secret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sawHeader = r.Header.Get("X-User-ID")
w.WriteHeader(200)
}))
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-User-ID", "victim")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != 200 || sawHeader != "" {
t.Fatalf("forged X-User-ID must be stripped, got %q (code %d)", sawHeader, w.Code)
}
}

func TestOptionalAuthSetsUserIDFromJWT(t *testing.T) {
secret := "test-secret"
var sawHeader string
h := OptionalAuth(secret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sawHeader = r.Header.Get("X-User-ID")
w.WriteHeader(200)
}))
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-User-ID", "victim")
req.Header.Set("Authorization", "Bearer "+mustToken(secret, "u1"))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != 200 || sawHeader != "u1" {
t.Fatalf("valid JWT must set X-User-ID=u1, got %q (code %d)", sawHeader, w.Code)
}
}

// Issue #44 (review): an expired/invalid token on a public endpoint must
// degrade to anonymous, not 401 — the frontend sends stored tokens on reads.
func TestOptionalAuthInvalidTokenIsAnonymous(t *testing.T) {
secret := "test-secret"
called := false
var sawHeader string
h := OptionalAuth(secret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
sawHeader = r.Header.Get("X-User-ID")
w.WriteHeader(200)
}))
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-User-ID", "victim")
req.Header.Set("Authorization", "Bearer invalid.token.here")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != 200 || !called || sawHeader != "" {
t.Fatalf("invalid token must be anonymous, got code %d called %v header %q", w.Code, called, sawHeader)
}
}

// Issue #44 review: a refresh token is not an access identity — OptionalAuth
// must treat it as anonymous (same rule as AuthMiddleware's type check).
func TestOptionalAuthRejectsRefreshToken(t *testing.T) {
secret := "test-secret"
var sawHeader string
called := false
h := OptionalAuth(secret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
sawHeader = r.Header.Get("X-User-ID")
w.WriteHeader(200)
}))
refresh := func() string {
tt := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"sub": "u1", "type": "refresh"})
s, _ := tt.SignedString([]byte(secret))
return s
}()
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("Authorization", "Bearer "+refresh)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != 200 || !called || sawHeader != "" {
t.Fatalf("refresh token must be anonymous, got code %d called %v header %q", w.Code, called, sawHeader)
}
}
9 changes: 6 additions & 3 deletions services/metadata/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,12 @@ func main() {
r.Patch("/api/v1/videos/{id}", vh.Update)
r.Delete("/api/v1/videos/{id}", vh.Delete)
})
// public list/get
r.Get("/api/v1/videos", vh.List)
r.Get("/api/v1/videos/{id}", vh.Get)
// public list/get: optional JWT — владелец видит свои private/unlisted
// (issue #44). Идентичность только из собственного JWT (X-User-ID не
// доверенный — metadata опубликована наружу).
optAuth := mw.OptionalAuth(jwtSecret)
r.With(optAuth).Get("/api/v1/videos", vh.List)
r.With(optAuth).Get("/api/v1/videos/{id}", vh.Get)
// internal — protected by X-Internal-Token (gateway/nginx/transcoder)
r.Group(func(r chi.Router) {
r.Use(mw.InternalAuth(internalToken))
Expand Down
31 changes: 31 additions & 0 deletions services/metadata/docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,12 @@ const docTemplate = `{
"$ref": "#/definitions/flowix_metadata_internal_model.Video"
}
},
"403": {
"description": "forbidden (private, not owner)",
"schema": {
"type": "string"
}
},
"404": {
"description": "not found",
"schema": {
Expand Down Expand Up @@ -309,6 +315,9 @@ const docTemplate = `{
},
"title": {
"type": "string"
},
"visibility": {
"$ref": "#/definitions/flowix_metadata_internal_model.Visibility"
}
}
},
Expand Down Expand Up @@ -364,6 +373,9 @@ const docTemplate = `{
},
"title": {
"type": "string"
},
"visibility": {
"$ref": "#/definitions/flowix_metadata_internal_model.Visibility"
}
}
},
Expand Down Expand Up @@ -405,6 +417,12 @@ const docTemplate = `{
},
"title": {
"type": "string"
},
"updated_at": {
"type": "string"
},
"visibility": {
"$ref": "#/definitions/flowix_metadata_internal_model.Visibility"
}
}
},
Expand All @@ -422,6 +440,19 @@ const docTemplate = `{
"StatusReady",
"StatusFailed"
]
},
"flowix_metadata_internal_model.Visibility": {
"type": "string",
"enum": [
"public",
"private",
"unlisted"
],
"x-enum-varnames": [
"VisibilityPublic",
"VisibilityPrivate",
"VisibilityUnlisted"
]
}
},
"securityDefinitions": {
Expand Down
31 changes: 31 additions & 0 deletions services/metadata/docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@
"$ref": "#/definitions/flowix_metadata_internal_model.Video"
}
},
"403": {
"description": "forbidden (private, not owner)",
"schema": {
"type": "string"
}
},
"404": {
"description": "not found",
"schema": {
Expand Down Expand Up @@ -302,6 +308,9 @@
},
"title": {
"type": "string"
},
"visibility": {
"$ref": "#/definitions/flowix_metadata_internal_model.Visibility"
}
}
},
Expand Down Expand Up @@ -357,6 +366,9 @@
},
"title": {
"type": "string"
},
"visibility": {
"$ref": "#/definitions/flowix_metadata_internal_model.Visibility"
}
}
},
Expand Down Expand Up @@ -398,6 +410,12 @@
},
"title": {
"type": "string"
},
"updated_at": {
"type": "string"
},
"visibility": {
"$ref": "#/definitions/flowix_metadata_internal_model.Visibility"
}
}
},
Expand All @@ -415,6 +433,19 @@
"StatusReady",
"StatusFailed"
]
},
"flowix_metadata_internal_model.Visibility": {
"type": "string",
"enum": [
"public",
"private",
"unlisted"
],
"x-enum-varnames": [
"VisibilityPublic",
"VisibilityPrivate",
"VisibilityUnlisted"
]
}
},
"securityDefinitions": {
Expand Down
22 changes: 22 additions & 0 deletions services/metadata/docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ definitions:
type: string
title:
type: string
visibility:
$ref: '#/definitions/flowix_metadata_internal_model.Visibility'
required:
- title
type: object
Expand Down Expand Up @@ -44,6 +46,8 @@ definitions:
type: string
title:
type: string
visibility:
$ref: '#/definitions/flowix_metadata_internal_model.Visibility'
type: object
flowix_metadata_internal_model.Video:
properties:
Expand Down Expand Up @@ -71,6 +75,10 @@ definitions:
type: string
title:
type: string
updated_at:
type: string
visibility:
$ref: '#/definitions/flowix_metadata_internal_model.Visibility'
type: object
flowix_metadata_internal_model.VideoStatus:
enum:
Expand All @@ -84,6 +92,16 @@ definitions:
- StatusProcessing
- StatusReady
- StatusFailed
flowix_metadata_internal_model.Visibility:
enum:
- public
- private
- unlisted
type: string
x-enum-varnames:
- VisibilityPublic
- VisibilityPrivate
- VisibilityUnlisted
info:
contact: {}
description: Video metadata CRUD + internal status (Go chi). JWT Bearer required
Expand Down Expand Up @@ -183,6 +201,10 @@ paths:
description: OK
schema:
$ref: '#/definitions/flowix_metadata_internal_model.Video'
"403":
description: forbidden (private, not owner)
schema:
type: string
"404":
description: not found
schema:
Expand Down
Loading
Loading