mirror of
https://github.com/photoprism/photoprism.git
synced 2025-09-26 21:01:58 +08:00
376 lines
12 KiB
Go
376 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
gojwt "github.com/golang-jwt/jwt/v5"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/photoprism/photoprism/internal/auth/acl"
|
|
clusterjwt "github.com/photoprism/photoprism/internal/auth/jwt"
|
|
"github.com/photoprism/photoprism/internal/config"
|
|
"github.com/photoprism/photoprism/internal/photoprism/get"
|
|
"github.com/photoprism/photoprism/pkg/clean"
|
|
)
|
|
|
|
func TestAuthAnyJWT(t *testing.T) {
|
|
t.Run("ClusterScope", func(t *testing.T) {
|
|
fx := newPortalJWTFixture(t, "cluster-jwt-success")
|
|
spec := fx.defaultClaimsSpec()
|
|
token := fx.issue(t, spec)
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.RemoteAddr = "192.0.2.10:12345"
|
|
c.Request = req
|
|
|
|
session := authAnyJWT(c, "192.0.2.10", token, acl.ResourceCluster, nil)
|
|
require.NotNil(t, session)
|
|
assert.Equal(t, http.StatusOK, session.HttpStatus())
|
|
assert.Equal(t, spec.Subject, session.ClientUID)
|
|
assert.Contains(t, session.AuthScope, "cluster")
|
|
assert.Equal(t, spec.Issuer, session.AuthIssuer)
|
|
})
|
|
t.Run("ClusterCIDRAllowed", func(t *testing.T) {
|
|
fx := newPortalJWTFixture(t, "cluster-jwt-cidr-allow")
|
|
spec := fx.defaultClaimsSpec()
|
|
token := fx.issue(t, spec)
|
|
|
|
origCIDR := fx.nodeConf.Options().ClusterCIDR
|
|
fx.nodeConf.Options().ClusterCIDR = "192.0.2.0/24"
|
|
get.SetConfig(fx.nodeConf)
|
|
t.Cleanup(func() {
|
|
fx.nodeConf.Options().ClusterCIDR = origCIDR
|
|
get.SetConfig(fx.nodeConf)
|
|
})
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.RemoteAddr = "192.0.2.10:2222"
|
|
c.Request = req
|
|
|
|
session := authAnyJWT(c, "192.0.2.10", token, acl.ResourceCluster, nil)
|
|
require.NotNil(t, session)
|
|
assert.Equal(t, spec.Subject, session.ClientUID)
|
|
})
|
|
t.Run("ClusterCIDRBlocked", func(t *testing.T) {
|
|
fx := newPortalJWTFixture(t, "cluster-jwt-cidr-block")
|
|
spec := fx.defaultClaimsSpec()
|
|
token := fx.issue(t, spec)
|
|
|
|
origCIDR := fx.nodeConf.Options().ClusterCIDR
|
|
fx.nodeConf.Options().ClusterCIDR = "192.0.2.0/24"
|
|
get.SetConfig(fx.nodeConf)
|
|
t.Cleanup(func() {
|
|
fx.nodeConf.Options().ClusterCIDR = origCIDR
|
|
get.SetConfig(fx.nodeConf)
|
|
})
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.RemoteAddr = "203.0.113.10:2222"
|
|
c.Request = req
|
|
|
|
assert.Nil(t, authAnyJWT(c, "203.0.113.10", token, acl.ResourceCluster, nil))
|
|
})
|
|
t.Run("JWTScopeDefaultRejectsOtherResources", func(t *testing.T) {
|
|
fx := newPortalJWTFixture(t, "cluster-jwt-scope-default-reject")
|
|
spec := fx.defaultClaimsSpec()
|
|
spec.Scope = []string{"photos"}
|
|
token := fx.issue(t, spec)
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
req, _ := http.NewRequest(http.MethodGet, "/api/v1/photos", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.RemoteAddr = "192.0.2.60:1001"
|
|
c.Request = req
|
|
|
|
assert.Nil(t, authAnyJWT(c, "192.0.2.60", token, acl.ResourcePhotos, nil))
|
|
})
|
|
t.Run("JWTScopeAllowed", func(t *testing.T) {
|
|
fx := newPortalJWTFixture(t, "cluster-jwt-scope-allow")
|
|
token := fx.issue(t, fx.defaultClaimsSpec())
|
|
|
|
orig := fx.nodeConf.Options().JWTScope
|
|
fx.nodeConf.Options().JWTScope = "cluster vision"
|
|
get.SetConfig(fx.nodeConf)
|
|
t.Cleanup(func() {
|
|
fx.nodeConf.Options().JWTScope = orig
|
|
get.SetConfig(fx.nodeConf)
|
|
})
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.RemoteAddr = "192.0.2.30:1001"
|
|
c.Request = req
|
|
|
|
sess := authAnyJWT(c, "192.0.2.30", token, acl.ResourceCluster, nil)
|
|
require.NotNil(t, sess)
|
|
})
|
|
t.Run("JWTScopeAllowsSuperset", func(t *testing.T) {
|
|
fx := newPortalJWTFixture(t, "cluster-jwt-scope-reject")
|
|
token := fx.issue(t, fx.defaultClaimsSpec())
|
|
|
|
orig := fx.nodeConf.Options().JWTScope
|
|
fx.nodeConf.Options().JWTScope = "cluster"
|
|
get.SetConfig(fx.nodeConf)
|
|
t.Cleanup(func() {
|
|
fx.nodeConf.Options().JWTScope = orig
|
|
get.SetConfig(fx.nodeConf)
|
|
})
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.RemoteAddr = "192.0.2.40:1001"
|
|
c.Request = req
|
|
|
|
sess := authAnyJWT(c, "192.0.2.40", token, acl.ResourceCluster, nil)
|
|
require.NotNil(t, sess)
|
|
})
|
|
t.Run("JWTScopeCustomResource", func(t *testing.T) {
|
|
fx := newPortalJWTFixture(t, "cluster-jwt-scope-custom")
|
|
spec := fx.defaultClaimsSpec()
|
|
spec.Scope = []string{"photos"}
|
|
token := fx.issue(t, spec)
|
|
|
|
origScope := fx.nodeConf.Options().JWTScope
|
|
fx.nodeConf.Options().JWTScope = "photos"
|
|
get.SetConfig(fx.nodeConf)
|
|
t.Cleanup(func() {
|
|
fx.nodeConf.Options().JWTScope = origScope
|
|
get.SetConfig(fx.nodeConf)
|
|
})
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
req, _ := http.NewRequest(http.MethodGet, "/api/v1/photos", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.RemoteAddr = "192.0.2.50:2001"
|
|
c.Request = req
|
|
|
|
_, verifyErr := get.VerifyJWT(c.Request.Context(), token, clusterjwt.ExpectedClaims{
|
|
Issuer: fmt.Sprintf("portal:%s", fx.clusterUUID),
|
|
Audience: fmt.Sprintf("node:%s", fx.nodeUUID),
|
|
Scope: []string{"photos"},
|
|
JWKSURL: fx.nodeConf.JWKSUrl(),
|
|
})
|
|
require.NoError(t, verifyErr)
|
|
|
|
sess := authAnyJWT(c, "192.0.2.50", token, acl.ResourcePhotos, nil)
|
|
require.NotNil(t, sess)
|
|
})
|
|
t.Run("VisionScope", func(t *testing.T) {
|
|
fx := newPortalJWTFixture(t, "cluster-jwt-vision")
|
|
spec := fx.defaultClaimsSpec()
|
|
spec.Scope = []string{"vision"}
|
|
token := fx.issue(t, spec)
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
req, _ := http.NewRequest(http.MethodGet, "/api/v1/vision/status", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.RemoteAddr = "198.18.0.5:8080"
|
|
c.Request = req
|
|
|
|
session := authAnyJWT(c, "198.18.0.5", token, acl.ResourceVision, nil)
|
|
require.NotNil(t, session)
|
|
assert.Equal(t, http.StatusOK, session.HttpStatus())
|
|
assert.Contains(t, session.AuthScope, "vision")
|
|
assert.Equal(t, spec.Issuer, session.AuthIssuer)
|
|
})
|
|
t.Run("RejectsMalformedOrUnknown", func(t *testing.T) {
|
|
fx := newPortalJWTFixture(t, "cluster-jwt-invalid")
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
|
|
req.Header.Set("Authorization", "Bearer invalid-token-without-dots")
|
|
req.RemoteAddr = "192.0.2.10:12345"
|
|
c.Request = req
|
|
|
|
assert.Nil(t, authAnyJWT(c, "192.0.2.10", "invalid-token-without-dots", acl.ResourceCluster, nil))
|
|
|
|
// Ensure we also bail out when JWKS URL is not configured.
|
|
fx.nodeConf.SetJWKSUrl("")
|
|
get.SetConfig(fx.nodeConf)
|
|
assert.Nil(t, authAnyJWT(c, "192.0.2.10", "", acl.ResourceCluster, nil))
|
|
})
|
|
t.Run("NoIssuerMatch", func(t *testing.T) {
|
|
fx := newPortalJWTFixture(t, "cluster-jwt-no-issuer")
|
|
spec := fx.defaultClaimsSpec()
|
|
token := fx.issue(t, spec)
|
|
|
|
// Remove all issuer candidates.
|
|
origPortal := fx.nodeConf.Options().PortalUrl
|
|
origSite := fx.nodeConf.Options().SiteUrl
|
|
origClusterUUID := fx.nodeConf.Options().ClusterUUID
|
|
fx.nodeConf.Options().PortalUrl = ""
|
|
fx.nodeConf.Options().SiteUrl = ""
|
|
fx.nodeConf.Options().ClusterUUID = ""
|
|
get.SetConfig(fx.nodeConf)
|
|
t.Cleanup(func() {
|
|
fx.nodeConf.Options().PortalUrl = origPortal
|
|
fx.nodeConf.Options().SiteUrl = origSite
|
|
fx.nodeConf.Options().ClusterUUID = origClusterUUID
|
|
get.SetConfig(fx.nodeConf)
|
|
})
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.RemoteAddr = "203.0.113.5:2222"
|
|
c.Request = req
|
|
|
|
assert.Nil(t, authAnyJWT(c, "203.0.113.5", token, acl.ResourceCluster, nil))
|
|
})
|
|
t.Run("UnsupportedResource", func(t *testing.T) {
|
|
fx := newPortalJWTFixture(t, "cluster-jwt-unsupported")
|
|
token := fx.issue(t, fx.defaultClaimsSpec())
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.RemoteAddr = "198.51.100.7:9999"
|
|
c.Request = req
|
|
|
|
assert.Nil(t, authAnyJWT(c, "198.51.100.7", token, acl.ResourcePhotos, nil))
|
|
})
|
|
}
|
|
|
|
func TestJwtIssuerCandidates(t *testing.T) {
|
|
t.Run("IncludesAllSources", func(t *testing.T) {
|
|
conf := config.NewConfig(config.CliTestContext())
|
|
conf.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111"
|
|
conf.Options().PortalUrl = "https://portal.example.test/"
|
|
conf.Options().SiteUrl = "https://site.example.test/base/"
|
|
|
|
orig := get.Config()
|
|
get.SetConfig(conf)
|
|
t.Cleanup(func() { get.SetConfig(orig) })
|
|
|
|
cands := jwtIssuerCandidates(conf)
|
|
assert.Equal(t, []string{
|
|
"portal:11111111-1111-4111-8111-111111111111",
|
|
"https://portal.example.test",
|
|
"https://site.example.test/base",
|
|
}, cands)
|
|
})
|
|
t.Run("DefaultsToLocalhost", func(t *testing.T) {
|
|
conf := config.NewConfig(config.CliTestContext())
|
|
conf.Options().ClusterUUID = ""
|
|
conf.Options().PortalUrl = ""
|
|
conf.Options().SiteUrl = ""
|
|
|
|
assert.Equal(t, []string{"http://localhost:2342"}, jwtIssuerCandidates(conf))
|
|
})
|
|
}
|
|
|
|
func TestShouldAttemptJWT(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
req, _ := http.NewRequest(http.MethodGet, "/ping", nil)
|
|
c.Request = req
|
|
|
|
assert.True(t, shouldAttemptJWT(c, "a.b.c"))
|
|
assert.False(t, shouldAttemptJWT(nil, "a.b.c"))
|
|
assert.False(t, shouldAttemptJWT(c, "invalidtoken"))
|
|
assert.False(t, shouldAttemptJWT(c, ""))
|
|
}
|
|
|
|
func TestNodeAllowsJWT(t *testing.T) {
|
|
fx := newPortalJWTFixture(t, "node-allows")
|
|
conf := fx.nodeConf
|
|
|
|
assert.True(t, shouldAllowJWT(conf, "192.0.2.9"))
|
|
|
|
origCIDR := conf.Options().ClusterCIDR
|
|
conf.Options().ClusterCIDR = "192.0.2.0/24"
|
|
assert.True(t, shouldAllowJWT(conf, "192.0.2.25"))
|
|
assert.False(t, shouldAllowJWT(conf, "203.0.113.1"))
|
|
conf.Options().ClusterCIDR = origCIDR
|
|
|
|
origJWKS := conf.JWKSUrl()
|
|
conf.SetJWKSUrl("")
|
|
assert.False(t, shouldAllowJWT(conf, "192.0.2.25"))
|
|
conf.SetJWKSUrl(origJWKS)
|
|
|
|
assert.False(t, shouldAllowJWT(nil, "192.0.2.25"))
|
|
}
|
|
|
|
func TestExpectedClaimsFor(t *testing.T) {
|
|
fx := newPortalJWTFixture(t, "expected-claims")
|
|
|
|
claims := expectedClaimsFor(fx.nodeConf, "cluster")
|
|
assert.Equal(t, fmt.Sprintf("node:%s", fx.nodeUUID), claims.Audience)
|
|
assert.Equal(t, []string{"cluster"}, claims.Scope)
|
|
assert.Equal(t, fx.nodeConf.JWKSUrl(), claims.JWKSURL)
|
|
|
|
noScope := expectedClaimsFor(fx.nodeConf, "")
|
|
assert.Nil(t, noScope.Scope)
|
|
}
|
|
|
|
func TestVerifyTokenFromPortal(t *testing.T) {
|
|
fx := newPortalJWTFixture(t, "verify-token")
|
|
spec := fx.defaultClaimsSpec()
|
|
token := fx.issue(t, spec)
|
|
|
|
expected := expectedClaimsFor(fx.nodeConf, clean.Scope("cluster"))
|
|
claims := verifyTokenFromPortal(context.Background(), token, expected, []string{"wrong", spec.Issuer})
|
|
require.NotNil(t, claims)
|
|
assert.Equal(t, spec.Issuer, claims.Issuer)
|
|
assert.Equal(t, spec.Subject, claims.Subject)
|
|
|
|
nilClaims := verifyTokenFromPortal(context.Background(), token, expected, []string{"wrong"})
|
|
assert.Nil(t, nilClaims)
|
|
}
|
|
|
|
func TestSessionFromJWTClaims(t *testing.T) {
|
|
claims := &clusterjwt.Claims{
|
|
Scope: "cluster vision",
|
|
RegisteredClaims: gojwt.RegisteredClaims{
|
|
Issuer: "portal:test",
|
|
Subject: "portal:client",
|
|
ID: "token-id",
|
|
},
|
|
}
|
|
|
|
sess := sessionFromJWTClaims(claims, "192.0.2.100")
|
|
require.NotNil(t, sess)
|
|
assert.Equal(t, http.StatusOK, sess.HttpStatus())
|
|
assert.Equal(t, "portal:client", sess.ClientUID)
|
|
assert.Equal(t, clean.Scope("cluster vision"), sess.AuthScope)
|
|
assert.Equal(t, "portal:test", sess.AuthIssuer)
|
|
assert.Equal(t, "token-id", sess.AuthID)
|
|
assert.Equal(t, "192.0.2.100", sess.ClientIP)
|
|
}
|