API: Refactor "GET /api/v1/config" endpoint for JWT sessions #5230

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-10-29 12:29:12 +01:00
parent 99cf43261d
commit e1e673be7f
6 changed files with 79 additions and 20 deletions

View File

@@ -112,6 +112,18 @@ func authAnyJWT(c *gin.Context, clientIP, authToken string, resource acl.Resourc
IssuedAt: issuedAt,
NotBefore: notBefore,
ExpiresAt: expiresAt,
PreviewToken: func() string {
if tokenScopes.Contains(acl.ResourceFiles.String()) {
return conf.PreviewToken()
}
return ""
}(),
DownloadToken: func() string {
if tokenScopes.Contains(acl.ResourceFiles.String()) {
return conf.DownloadToken()
}
return ""
}(),
})
}

View File

@@ -53,13 +53,43 @@ func TestAuthAnyJWT(t *testing.T) {
assert.True(t, strings.HasPrefix(session.AuthID, "jwt"))
assert.Equal(t, session.AuthID, session.RefID)
assert.True(t, rnd.IsRefID(session.RefID))
assert.False(t, session.CreatedAt.IsZero())
assert.False(t, session.UpdatedAt.IsZero())
assert.NotZero(t, session.SessExpires)
assert.Greater(t, session.SessExpires, session.CreatedAt.Unix())
assert.True(t, session.SessExpires > session.CreatedAt.Unix())
assert.GreaterOrEqual(t, session.LastActive, session.CreatedAt.Unix())
assert.True(t, session.GetUser().IsUnknown())
assert.Equal(t, acl.RolePortal, session.GetClientRole())
assert.Empty(t, session.PreviewToken)
assert.Empty(t, session.DownloadToken)
})
t.Run("FilesScopeTokens", func(t *testing.T) {
fx := newPortalJWTFixture(t, "cluster-jwt-files")
spec := fx.defaultClaimsSpec()
spec.Scope = []string{"cluster", "files"}
token := fx.issue(t, spec)
origScope := fx.nodeConf.Options().JWTScope
fx.nodeConf.Options().JWTScope = "cluster vision metrics files"
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/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set(header.UserAgent, "PhotoPrism Portal/1.0")
req.RemoteAddr = "192.0.2.50:4567"
c.Request = req
session := authAnyJWT(c, "192.0.2.50", token, acl.ResourceFiles, acl.Permissions{acl.AccessLibrary})
require.NotNil(t, session)
assert.Equal(t, http.StatusOK, session.HttpStatus())
assert.Equal(t, fx.preview, session.PreviewToken)
assert.Equal(t, fx.download, session.DownloadToken)
assert.True(t, session.SessExpires > session.CreatedAt.Unix())
assert.True(t, session.LastActive >= session.CreatedAt.Unix())
})
t.Run("ClusterCIDRAllowed", func(t *testing.T) {
fx := newPortalJWTFixture(t, "cluster-jwt-cidr-allow")

View File

@@ -250,6 +250,8 @@ type portalJWTFixture struct {
issuer *clusterjwt.Issuer
clusterUUID string
nodeUUID string
preview string
download string
}
func newPortalJWTFixture(t *testing.T, suffix string) portalJWTFixture {
@@ -294,6 +296,8 @@ func newPortalJWTFixture(t *testing.T, suffix string) portalJWTFixture {
issuer: clusterjwt.NewIssuer(mgr),
clusterUUID: clusterUUID,
nodeUUID: nodeUUID,
preview: nodeConf.PreviewToken(),
download: nodeConf.DownloadToken(),
}
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/photoprism/get"
)
@@ -27,16 +28,12 @@ func UpdateClientConfig() {
// @Router /api/v1/config [get]
func GetClientConfig(router *gin.RouterGroup) {
router.GET("/config", func(c *gin.Context) {
sess := Session(ClientIP(c), AuthToken(c))
conf := get.Config()
// Check authentication.
if sess != nil {
// Return custom client config for authenticated user.
c.JSON(http.StatusOK, conf.ClientSession(sess))
if s := AuthAny(c, acl.ResourceConfig, acl.Permissions{acl.ActionView}); s.Valid() {
c.JSON(http.StatusOK, conf.ClientSession(s))
return
} else if conf.DisableFrontend() {
// Abort if not authenticated, and the web frontend is disabled.
AbortUnauthorized(c)
return
}

View File

@@ -931,12 +931,18 @@ func (m *Session) UpdateLastActive(save bool) *Session {
// Invalid checks if the session does not belong to a registered user or a visitor with shares.
func (m *Session) Invalid() bool {
if m == nil {
return true
}
return !m.Valid()
}
// Valid checks whether the session belongs to a registered user or a visitor with shares.
func (m *Session) Valid() bool {
if m.IsClient() {
if m == nil {
return false
} else if m.IsClient() {
return true
}

View File

@@ -22,6 +22,8 @@ type JWT struct {
IssuedAt *time.Time
NotBefore *time.Time
ExpiresAt *time.Time
PreviewToken string
DownloadToken string
}
// NewSessionFromJWT constructs an in-memory session based on verified
@@ -58,6 +60,14 @@ func NewSessionFromJWT(c *gin.Context, jwt *JWT) *Session {
sess.SetClientIP(header.ClientIP(c))
sess.SetUserAgent(header.ClientUserAgent(c))
// Set media preview and download tokens, if specified.
if jwt.PreviewToken != "" {
sess.PreviewToken = jwt.PreviewToken
}
if jwt.DownloadToken != "" {
sess.DownloadToken = jwt.DownloadToken
}
// Derive timestamps from JWT claims when available.
now := time.Now().UTC()
issuedAt := now