mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 19:46:38 +08:00
refactor: split modules and harden scraping workflows
This commit is contained in:
@@ -21,6 +21,11 @@ const (
|
||||
CtxUserTier = "ctx_user_tier"
|
||||
CtxTokenPurpose = "ctx_token_purpose"
|
||||
CtxTokenMediaID = "ctx_token_media_id"
|
||||
|
||||
// AccessTokenCookieName carries the web access token for browser-managed
|
||||
// resource requests such as <img>, which cannot attach Authorization.
|
||||
AccessTokenCookieName = "msgo_access_token"
|
||||
AccessTokenCookiePath = "/api"
|
||||
)
|
||||
|
||||
// RequestLogger logs one structured line per request.
|
||||
@@ -199,6 +204,7 @@ func AuthRequired(secret string) gin.HandlerFunc {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": 40304, "message": "token scope denied"})
|
||||
return
|
||||
}
|
||||
syncAccessTokenCookie(c, raw, claims)
|
||||
c.Set(CtxUserID, claims.UserID)
|
||||
c.Set(CtxUserRole, claims.Role)
|
||||
c.Set(CtxUserTier, claims.Tier)
|
||||
@@ -208,6 +214,48 @@ func AuthRequired(secret string) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func syncAccessTokenCookie(c *gin.Context, raw string, claims *Claims) {
|
||||
if c == nil || claims == nil || strings.TrimSpace(raw) == "" || strings.TrimSpace(claims.Purpose) != "" {
|
||||
return
|
||||
}
|
||||
if existing, err := c.Cookie(AccessTokenCookieName); err == nil && existing == raw {
|
||||
return
|
||||
}
|
||||
maxAge := int(time.Hour.Seconds())
|
||||
expires := time.Now().Add(time.Hour)
|
||||
if claims.ExpiresAt != nil {
|
||||
expires = claims.ExpiresAt.Time
|
||||
ttl := time.Until(expires)
|
||||
if ttl <= 0 {
|
||||
return
|
||||
}
|
||||
maxAge = int(ttl.Seconds())
|
||||
if maxAge < 1 {
|
||||
maxAge = 1
|
||||
}
|
||||
}
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: AccessTokenCookieName,
|
||||
Value: raw,
|
||||
Path: AccessTokenCookiePath,
|
||||
MaxAge: maxAge,
|
||||
Expires: expires,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: requestIsHTTPS(c),
|
||||
})
|
||||
}
|
||||
|
||||
func requestIsHTTPS(c *gin.Context) bool {
|
||||
if c == nil || c.Request == nil {
|
||||
return false
|
||||
}
|
||||
if c.Request.TLS != nil {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
|
||||
}
|
||||
|
||||
func scopedTokenAllowedForRequest(c *gin.Context, claims *Claims) bool {
|
||||
if claims == nil || strings.TrimSpace(claims.Purpose) == "" {
|
||||
return true
|
||||
@@ -320,5 +368,8 @@ func extractToken(c *gin.Context) string {
|
||||
return value
|
||||
}
|
||||
}
|
||||
if cookie, err := c.Cookie(AccessTokenCookieName); err == nil {
|
||||
return strings.TrimSpace(cookie)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func TestCORSWildcardOriginAllowsProductionPreflight(t *testing.T) {
|
||||
@@ -29,3 +31,161 @@ func TestCORSWildcardOriginAllowsProductionPreflight(t *testing.T) {
|
||||
t.Fatalf("Access-Control-Allow-Origin = %q, want *", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRequiredAcceptsAccessTokenCookie(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
secret := "cookie-secret"
|
||||
token := signedMiddlewareTestToken(t, secret, Claims{
|
||||
UserID: "user-1",
|
||||
Role: "admin",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
},
|
||||
})
|
||||
|
||||
router := gin.New()
|
||||
router.Use(AuthRequired(secret))
|
||||
router.GET("/api/img", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"user_id": c.GetString(CtxUserID)})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/img?url=https%3A%2F%2Fexample.test%2Fposter.jpg", nil)
|
||||
req.AddCookie(&http.Cookie{Name: AccessTokenCookieName, Value: token})
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRequiredKeepsExplicitQueryTokenPriority(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
secret := "cookie-secret"
|
||||
accountToken := signedMiddlewareTestToken(t, secret, Claims{
|
||||
UserID: "user-1",
|
||||
Role: "admin",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
},
|
||||
})
|
||||
scopedToken := signedMiddlewareTestToken(t, secret, Claims{
|
||||
UserID: "user-1",
|
||||
Role: "admin",
|
||||
Purpose: "external_play",
|
||||
MediaID: "media-1",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
},
|
||||
})
|
||||
|
||||
router := gin.New()
|
||||
router.Use(AuthRequired(secret))
|
||||
router.GET("/api/me", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/me?token="+scopedToken, nil)
|
||||
req.AddCookie(&http.Cookie{Name: AccessTokenCookieName, Value: accountToken})
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRequiredSyncsAccessTokenCookieFromBearer(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
secret := "cookie-secret"
|
||||
token := signedMiddlewareTestToken(t, secret, Claims{
|
||||
UserID: "user-1",
|
||||
Role: "admin",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
},
|
||||
})
|
||||
|
||||
router := gin.New()
|
||||
router.Use(AuthRequired(secret))
|
||||
router.GET("/api/discover/feed", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/discover/feed", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
cookie := middlewareTestResponseCookie(t, w, AccessTokenCookieName)
|
||||
if cookie.Value != token {
|
||||
t.Fatal("synced cookie should contain the bearer token")
|
||||
}
|
||||
if cookie.Path != AccessTokenCookiePath {
|
||||
t.Fatalf("cookie path = %q, want %q", cookie.Path, AccessTokenCookiePath)
|
||||
}
|
||||
if !cookie.HttpOnly || cookie.SameSite != http.SameSiteLaxMode {
|
||||
t.Fatalf("cookie flags not suitable: httpOnly=%v sameSite=%v", cookie.HttpOnly, cookie.SameSite)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRequiredDoesNotSyncScopedPlaybackTokenCookie(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
secret := "cookie-secret"
|
||||
token := signedMiddlewareTestToken(t, secret, Claims{
|
||||
UserID: "user-1",
|
||||
Role: "admin",
|
||||
Purpose: "external_play",
|
||||
MediaID: "media-1",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
},
|
||||
})
|
||||
|
||||
router := gin.New()
|
||||
router.Use(AuthRequired(secret))
|
||||
router.GET("/api/stream/media-1", func(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/stream/media-1?token="+token, nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if cookie := optionalMiddlewareTestResponseCookie(w, AccessTokenCookieName); cookie != nil {
|
||||
t.Fatalf("scoped playback token should not be synced as web cookie: %#v", cookie)
|
||||
}
|
||||
}
|
||||
|
||||
func signedMiddlewareTestToken(t *testing.T, secret string, claims Claims) string {
|
||||
t.Helper()
|
||||
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
t.Fatalf("sign token: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func middlewareTestResponseCookie(t *testing.T, w *httptest.ResponseRecorder, name string) *http.Cookie {
|
||||
t.Helper()
|
||||
cookie := optionalMiddlewareTestResponseCookie(w, name)
|
||||
if cookie == nil {
|
||||
t.Fatalf("missing response cookie %q", name)
|
||||
}
|
||||
return cookie
|
||||
}
|
||||
|
||||
func optionalMiddlewareTestResponseCookie(w *httptest.ResponseRecorder, name string) *http.Cookie {
|
||||
for _, cookie := range w.Result().Cookies() {
|
||||
if cookie.Name == name {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user