mirror of
https://github.com/photoprism/photoprism.git
synced 2025-09-26 21:01:58 +08:00
Compare commits
5 Commits
4ea6e12a10
...
7e419f7419
Author | SHA1 | Date | |
---|---|---|---|
![]() |
7e419f7419 | ||
![]() |
633d4222ab | ||
![]() |
bae8ceb3a7 | ||
![]() |
4828c0423d | ||
![]() |
cb81f9be12 |
@@ -1,5 +1,7 @@
|
||||
# PhotoPrism® Repository Guidelines
|
||||
|
||||
**Last Updated:** September 25, 2025
|
||||
|
||||
## Purpose
|
||||
|
||||
This file tells automated coding agents (and humans) where to find the single sources of truth for building, testing, and contributing to PhotoPrism.
|
||||
@@ -157,6 +159,7 @@ Note: Across our public documentation, official images, and in production, the c
|
||||
- Go: run `make fmt-go swag-fmt` to reformat the backend code + Swagger annotations (see `Makefile` for additional targets)
|
||||
- Doc comments for packages and exported identifiers must be complete sentences that begin with the name of the thing being described and end with a period.
|
||||
- For short examples inside comments, indent code rather than using backticks; godoc treats indented blocks as preformatted.
|
||||
- Every Go package must contain a `<package>.go` file in its root (for example, `internal/auth/jwt/jwt.go`) with the standard license header and a short package description comment explaining its purpose.
|
||||
- JS/Vue: use the lint/format scripts in `frontend/package.json` (ESLint + Prettier)
|
||||
- All added code and tests **must** be formatted according to our standards.
|
||||
|
||||
@@ -258,6 +261,9 @@ If anything in this file conflicts with the `Makefile` or the Developer Guide, t
|
||||
### API & Config Changes
|
||||
|
||||
- Respect precedence: `options.yml` overrides CLI/env values, which override defaults. When adding a new option, update `internal/config/options.go` (yaml/flag tags), register it in `internal/config/flags.go`, expose a getter, surface it in `*config.Report()`, and write generated values back to `options.yml` by setting `c.options.OptionsYaml` before persisting. Use `CliTestContext` in `internal/config/test.go` to exercise new flags.
|
||||
- When touching configuration in Go code, use the public accessors on `*config.Config` (e.g. `Config.JWKSUrl()`, `Config.SetJWKSUrl()`, `Config.ClusterUUID()`) instead of mutating `Config.Options()` directly; reserve raw option tweaks for test fixtures only.
|
||||
- Logging: use the shared logger (`event.Log`) via the package-level `log` variable (see `internal/auth/jwt/logger.go`) instead of direct `fmt.Print*` or ad-hoc loggers.
|
||||
- Cluster registry tests (`internal/service/cluster/registry`) currently rely on a full test config because they persist `entity.Client` rows. They run migrations and seed the SQLite DB, so they are intentionally slow. If you refactor them, consider sharing a single `config.TestConfig()` across subtests or building a lightweight schema harness; do not swap to the minimal config helper unless the tests stop touching the database.
|
||||
- Favor explicit CLI flags: check `c.cliCtx.IsSet("<flag>")` before overriding user-supplied values, and follow the `ClusterUUID` pattern (`options.yml` → CLI/env → generated UUIDv4 persisted).
|
||||
- Database helpers: reuse `conf.Db()` / `conf.Database*()`, avoid GORM `WithContext`, quote MySQL identifiers, and reject unsupported drivers early.
|
||||
- Handler conventions: reuse limiter stacks (`limiter.Auth`, `limiter.Login`) and `limiter.AbortJSON` for 429s, lean on `api.ClientIP`, `header.BearerToken`, and `Abort*` helpers, compare secrets with constant time checks, set `Cache-Control: no-store` on sensitive responses, and register routes in `internal/server/routes.go`. For new list endpoints default `count=100` (max 1000) and `offset≥0`, document parameters explicitly, and set portal mode via `PHOTOPRISM_NODE_ROLE=portal` plus `PHOTOPRISM_JOIN_TOKEN` when needed.
|
||||
|
@@ -1,5 +1,7 @@
|
||||
PhotoPrism — Backend CODEMAP
|
||||
|
||||
**Last Updated:** September 24, 2025
|
||||
|
||||
Purpose
|
||||
- Give agents and contributors a fast, reliable map of where things live and how they fit together, so you can add features, fix bugs, and write tests without spelunking.
|
||||
- Sources of truth: prefer Makefile targets and the Developer Guide linked in AGENTS.md.
|
||||
|
1
go.mod
1
go.mod
@@ -80,6 +80,7 @@ require (
|
||||
github.com/davidbyttow/govips/v2 v2.16.0
|
||||
github.com/go-co-op/gocron/v2 v2.16.5
|
||||
github.com/go-sql-driver/mysql v1.9.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/prometheus/client_model v0.6.2
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
|
2
go.sum
2
go.sum
@@ -202,6 +202,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY=
|
||||
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
|
@@ -29,14 +29,19 @@ func AuthAny(c *gin.Context, resource acl.Resource, perms acl.Permissions) (s *e
|
||||
clientIp := ClientIP(c)
|
||||
authToken := AuthToken(c)
|
||||
|
||||
// Disable response caching.
|
||||
c.Header(header.CacheControl, header.CacheControlNoStore)
|
||||
|
||||
// Find active session to perform authorization check or deny if no session was found.
|
||||
if s = Session(clientIp, authToken); s == nil {
|
||||
if s = authAnyJWT(c, clientIp, authToken, resource, perms); s != nil {
|
||||
return s
|
||||
}
|
||||
event.AuditWarn([]string{clientIp, "%s %s without authentication", authn.Denied}, perms.String(), string(resource))
|
||||
return entity.SessionStatusUnauthorized()
|
||||
}
|
||||
|
||||
// Disable caching of responses and the client IP.
|
||||
c.Header(header.CacheControl, header.CacheControlNoStore)
|
||||
// Set client IP.
|
||||
s.SetClientIP(clientIp)
|
||||
|
||||
// If the request is from a client application, check its authorization based
|
||||
|
124
internal/api/api_auth_jwt.go
Normal file
124
internal/api/api_auth_jwt.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"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/entity"
|
||||
"github.com/photoprism/photoprism/internal/photoprism/get"
|
||||
"github.com/photoprism/photoprism/pkg/authn"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
)
|
||||
|
||||
// authAnyJWT attempts to authenticate a Portal-issued JWT when a cluster
|
||||
// node receives a request without an existing session. It verifies the token
|
||||
// against the node's cached JWKS, ensures the issuer/audience/scope match the
|
||||
// expected portal values, and, if valid, returns a client session mirroring the
|
||||
// JWT claims. It returns nil on any validation failure so the caller can fall
|
||||
// back to existing auth flows. Currently cluster and vision resources are
|
||||
// eligible for JWT-based authorization; vision access requires the `vision`
|
||||
// scope whereas cluster access requires the `cluster` scope.
|
||||
func authAnyJWT(c *gin.Context, clientIP, authToken string, resource acl.Resource, perms acl.Permissions) *entity.Session {
|
||||
if c == nil || authToken == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
_ = perms
|
||||
|
||||
if resource != acl.ResourceCluster && resource != acl.ResourceVision {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Basic sanity check for JWT structure.
|
||||
if strings.Count(authToken, ".") != 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
conf := get.Config()
|
||||
|
||||
if conf == nil || conf.IsPortal() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if conf.JWKSUrl() == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
verifier := clusterjwt.NewVerifier(conf)
|
||||
requiredScopes := []string{"cluster"}
|
||||
if resource == acl.ResourceVision {
|
||||
requiredScopes = []string{"vision"}
|
||||
}
|
||||
|
||||
expected := clusterjwt.ExpectedClaims{
|
||||
Audience: fmt.Sprintf("node:%s", conf.NodeUUID()),
|
||||
Scope: requiredScopes,
|
||||
JWKSURL: conf.JWKSUrl(),
|
||||
}
|
||||
|
||||
issuers := jwtIssuerCandidates(conf)
|
||||
|
||||
if len(issuers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
claims *clusterjwt.Claims
|
||||
err error
|
||||
)
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
for _, issuer := range issuers {
|
||||
expected.Issuer = issuer
|
||||
claims, err = verifier.VerifyToken(ctx, authToken, expected)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil
|
||||
} else if claims == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
sess := &entity.Session{
|
||||
Status: http.StatusOK,
|
||||
ClientUID: claims.Subject,
|
||||
AuthScope: clean.Scope(claims.Scope),
|
||||
AuthIssuer: claims.Issuer,
|
||||
AuthID: claims.ID,
|
||||
GrantType: authn.GrantJwtBearer.String(),
|
||||
AuthProvider: authn.ProviderClient.String(),
|
||||
}
|
||||
|
||||
sess.SetMethod(authn.MethodJWT)
|
||||
sess.SetClientName(claims.Subject)
|
||||
sess.SetClientIP(clientIP)
|
||||
|
||||
return sess
|
||||
}
|
||||
|
||||
// jwtIssuerCandidates returns the possible issuer values the node should accept
|
||||
// for Portal JWTs. It prefers the explicit portal cluster identifier and then
|
||||
// falls back to configured URLs so legacy installations migrate seamlessly.
|
||||
func jwtIssuerCandidates(conf *config.Config) []string {
|
||||
var out []string
|
||||
if uuid := conf.ClusterUUID(); uuid != "" {
|
||||
out = append(out, fmt.Sprintf("portal:%s", uuid))
|
||||
}
|
||||
if portal := strings.TrimSpace(conf.PortalUrl()); portal != "" {
|
||||
out = append(out, strings.TrimRight(portal, "/"))
|
||||
}
|
||||
if site := strings.TrimSpace(conf.SiteUrl()); site != "" {
|
||||
out = append(out, strings.TrimRight(site, "/"))
|
||||
}
|
||||
return out
|
||||
}
|
148
internal/api/api_auth_jwt_test.go
Normal file
148
internal/api/api_auth_jwt_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/auth/acl"
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/photoprism/get"
|
||||
)
|
||||
|
||||
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("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))
|
||||
})
|
||||
}
|
@@ -1,15 +1,23 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"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/auth/session"
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/photoprism/get"
|
||||
"github.com/photoprism/photoprism/internal/service/cluster"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
"github.com/photoprism/photoprism/pkg/service/http/header"
|
||||
)
|
||||
|
||||
@@ -137,3 +145,167 @@ func TestAuthToken(t *testing.T) {
|
||||
assert.Equal(t, "", bearerToken)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthAnyPortalJWT(t *testing.T) {
|
||||
fx := newPortalJWTFixture(t, "ok")
|
||||
|
||||
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 = "10.0.0.5:1234"
|
||||
c.Request = req
|
||||
|
||||
s := AuthAny(c, acl.ResourceCluster, acl.Permissions{acl.ActionView})
|
||||
require.NotNil(t, s)
|
||||
assert.True(t, s.IsClient())
|
||||
assert.Equal(t, http.StatusOK, s.HttpStatus())
|
||||
assert.Contains(t, s.AuthScope, "cluster")
|
||||
assert.Equal(t, fmt.Sprintf("portal:%s", fx.clusterUUID), s.AuthIssuer)
|
||||
assert.Equal(t, "portal:client-test", s.ClientUID)
|
||||
assert.False(t, s.Abort(c))
|
||||
|
||||
// Audience mismatch should reject the token once the node UUID changes.
|
||||
req2, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
|
||||
req2.Header.Set("Authorization", "Bearer "+token)
|
||||
req2.RemoteAddr = "10.0.0.5:1234"
|
||||
c.Request = req2
|
||||
fx.nodeConf.Options().NodeUUID = rnd.UUID()
|
||||
get.SetConfig(fx.nodeConf)
|
||||
s = AuthAny(c, acl.ResourceCluster, acl.Permissions{acl.ActionView})
|
||||
require.NotNil(t, s)
|
||||
assert.Equal(t, http.StatusUnauthorized, s.HttpStatus())
|
||||
assert.True(t, s.Abort(c))
|
||||
}
|
||||
|
||||
func TestAuthAnyPortalJWT_MissingScope(t *testing.T) {
|
||||
fx := newPortalJWTFixture(t, "missing-scope")
|
||||
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/cluster/theme", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.RemoteAddr = "10.0.0.5:1234"
|
||||
c.Request = req
|
||||
|
||||
s := AuthAny(c, acl.ResourceCluster, acl.Permissions{acl.ActionView})
|
||||
require.NotNil(t, s)
|
||||
assert.Equal(t, http.StatusUnauthorized, s.HttpStatus())
|
||||
assert.True(t, s.Abort(c))
|
||||
}
|
||||
|
||||
func TestAuthAnyPortalJWT_InvalidIssuer(t *testing.T) {
|
||||
fx := newPortalJWTFixture(t, "invalid-issuer")
|
||||
spec := fx.defaultClaimsSpec()
|
||||
spec.Issuer = "https://portal.invalid.test"
|
||||
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 = "10.0.0.5:1234"
|
||||
c.Request = req
|
||||
|
||||
s := AuthAny(c, acl.ResourceCluster, acl.Permissions{acl.ActionView})
|
||||
require.NotNil(t, s)
|
||||
assert.Equal(t, http.StatusUnauthorized, s.HttpStatus())
|
||||
assert.True(t, s.Abort(c))
|
||||
}
|
||||
|
||||
func TestAuthAnyPortalJWT_NoJWKSConfigured(t *testing.T) {
|
||||
fx := newPortalJWTFixture(t, "no-jwks")
|
||||
fx.nodeConf.SetJWKSUrl("")
|
||||
get.SetConfig(fx.nodeConf)
|
||||
|
||||
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 = "10.0.0.5:1234"
|
||||
c.Request = req
|
||||
|
||||
s := AuthAny(c, acl.ResourceCluster, acl.Permissions{acl.ActionView})
|
||||
require.NotNil(t, s)
|
||||
assert.Equal(t, http.StatusUnauthorized, s.HttpStatus())
|
||||
assert.True(t, s.Abort(c))
|
||||
}
|
||||
|
||||
type portalJWTFixture struct {
|
||||
nodeConf *config.Config
|
||||
issuer *clusterjwt.Issuer
|
||||
clusterUUID string
|
||||
nodeUUID string
|
||||
}
|
||||
|
||||
func newPortalJWTFixture(t *testing.T, suffix string) portalJWTFixture {
|
||||
t.Helper()
|
||||
t.Setenv("PHOTOPRISM_STORAGE_PATH", t.TempDir())
|
||||
origConf := get.Config()
|
||||
t.Cleanup(func() { get.SetConfig(origConf) })
|
||||
|
||||
nodeConf := config.NewTestConfig("auth-any-portal-jwt-" + suffix)
|
||||
nodeConf.Options().NodeRole = cluster.RoleInstance
|
||||
nodeConf.Options().Public = false
|
||||
clusterUUID := rnd.UUID()
|
||||
nodeConf.Options().ClusterUUID = clusterUUID
|
||||
nodeUUID := nodeConf.NodeUUID()
|
||||
nodeConf.Options().PortalUrl = "https://portal.example.test"
|
||||
|
||||
portalConf := config.NewTestConfig("auth-any-portal-jwt-issuer-" + suffix)
|
||||
portalConf.Options().NodeRole = cluster.RolePortal
|
||||
portalConf.Options().ClusterUUID = clusterUUID
|
||||
|
||||
mgr, err := clusterjwt.NewManager(portalConf)
|
||||
require.NoError(t, err)
|
||||
_, err = mgr.EnsureActiveKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
jwksBytes, err := json.Marshal(mgr.JWKS())
|
||||
require.NoError(t, err)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwksBytes)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
nodeConf.SetJWKSUrl(srv.URL + "/.well-known/jwks.json")
|
||||
get.SetConfig(nodeConf)
|
||||
|
||||
return portalJWTFixture{
|
||||
nodeConf: nodeConf,
|
||||
issuer: clusterjwt.NewIssuer(mgr),
|
||||
clusterUUID: clusterUUID,
|
||||
nodeUUID: nodeUUID,
|
||||
}
|
||||
}
|
||||
|
||||
func (fx portalJWTFixture) defaultClaimsSpec() clusterjwt.ClaimsSpec {
|
||||
return clusterjwt.ClaimsSpec{
|
||||
Issuer: fmt.Sprintf("portal:%s", fx.clusterUUID),
|
||||
Subject: "portal:client-test",
|
||||
Audience: fmt.Sprintf("node:%s", fx.nodeUUID),
|
||||
Scope: []string{"cluster", "vision"},
|
||||
}
|
||||
}
|
||||
|
||||
func (fx portalJWTFixture) issue(t *testing.T, spec clusterjwt.ClaimsSpec) string {
|
||||
t.Helper()
|
||||
token, err := fx.issuer.Issue(spec)
|
||||
require.NoError(t, err)
|
||||
return token
|
||||
}
|
||||
|
@@ -48,7 +48,7 @@ func TestMain(m *testing.M) {
|
||||
// Run unit tests.
|
||||
code := m.Run()
|
||||
|
||||
// Purge local SQLite test artifacts created during this package's tests.
|
||||
// Remove temporary SQLite files after running the tests.
|
||||
fs.PurgeTestDbFiles(".", false)
|
||||
|
||||
os.Exit(code)
|
||||
|
@@ -24,10 +24,10 @@ func TestClusterListNodes_Redaction(t *testing.T) {
|
||||
// Seed one node with internal URL and DB metadata.
|
||||
regy, err := reg.NewClientRegistryWithConfig(conf)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Nodes are UUID-first; seed with a UUID v7 so the registry includes it in List().
|
||||
n := ®.Node{UUID: rnd.UUIDv7(), Name: "pp-node-redact", Role: "instance", AdvertiseUrl: "http://pp-node:2342", SiteUrl: "https://photos.example.com"}
|
||||
n.Database.Name = "pp_db"
|
||||
n.Database.User = "pp_user"
|
||||
n := ®.Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-node-redact", Role: "instance", AdvertiseUrl: "http://pp-node:2342", SiteUrl: "https://photos.example.com"}}
|
||||
n.Database = &cluster.NodeDatabase{Name: "pp_db", User: "pp_user"}
|
||||
assert.NoError(t, regy.Put(n))
|
||||
|
||||
// Admin session shows internal fields
|
||||
@@ -55,10 +55,10 @@ func TestClusterListNodes_Redaction_ClientScope(t *testing.T) {
|
||||
|
||||
regy, err := reg.NewClientRegistryWithConfig(conf)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Seed node with internal URL and DB meta.
|
||||
n := ®.Node{Name: "pp-node-redact2", Role: "instance", AdvertiseUrl: "http://pp-node2:2342", SiteUrl: "https://photos2.example.com"}
|
||||
n.Database.Name = "pp_db2"
|
||||
n.Database.User = "pp_user2"
|
||||
n := ®.Node{Node: cluster.Node{Name: "pp-node-redact2", Role: "instance", AdvertiseUrl: "http://pp-node2:2342", SiteUrl: "https://photos2.example.com"}}
|
||||
n.Database = &cluster.NodeDatabase{Name: "pp_db2", User: "pp_user2"}
|
||||
assert.NoError(t, regy.Put(n))
|
||||
|
||||
// Create client session with cluster scope and no user (redacted view expected).
|
||||
|
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/auth/acl"
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/internal/photoprism/get"
|
||||
@@ -68,18 +69,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
|
||||
}
|
||||
|
||||
// Parse request.
|
||||
var req struct {
|
||||
NodeName string `json:"nodeName"`
|
||||
NodeUUID string `json:"nodeUUID"`
|
||||
NodeRole string `json:"nodeRole"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
AdvertiseUrl string `json:"advertiseUrl"`
|
||||
SiteUrl string `json:"siteUrl"`
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
RotateDatabase bool `json:"rotateDatabase"`
|
||||
RotateSecret bool `json:"rotateSecret"`
|
||||
}
|
||||
var req cluster.RegisterRequest
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "form invalid", "%s"}, clean.Error(err))
|
||||
@@ -227,13 +217,22 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
|
||||
event.AuditInfo([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "rotate db", event.Succeeded, "node %s"}, clean.LogQuote(name))
|
||||
}
|
||||
|
||||
jwksURL := buildJWKSURL(conf)
|
||||
|
||||
// Build response with struct types.
|
||||
opts := reg.NodeOptsForSession(nil) // registration is token-based, not session; default redaction is fine
|
||||
dbInfo := cluster.NodeDatabase{}
|
||||
|
||||
if n.Database != nil {
|
||||
dbInfo = *n.Database
|
||||
}
|
||||
|
||||
resp := cluster.RegisterResponse{
|
||||
UUID: conf.ClusterUUID(),
|
||||
Node: reg.BuildClusterNode(*n, opts),
|
||||
Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: n.Database.Name, User: n.Database.User, Driver: provisioner.DatabaseDriver},
|
||||
Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: dbInfo.Name, User: dbInfo.User, Driver: provisioner.DatabaseDriver},
|
||||
Secrets: respSecret,
|
||||
JWKSUrl: jwksURL,
|
||||
AlreadyRegistered: true,
|
||||
AlreadyProvisioned: true,
|
||||
}
|
||||
@@ -252,14 +251,18 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
|
||||
|
||||
// New node (client UID will be generated in registry.Put).
|
||||
n := ®.Node{
|
||||
Name: name,
|
||||
Role: clean.TypeLowerDash(req.NodeRole),
|
||||
UUID: requestedUUID,
|
||||
Labels: req.Labels,
|
||||
Node: cluster.Node{
|
||||
Name: name,
|
||||
Role: clean.TypeLowerDash(req.NodeRole),
|
||||
UUID: requestedUUID,
|
||||
Labels: req.Labels,
|
||||
},
|
||||
}
|
||||
|
||||
if n.UUID == "" {
|
||||
n.UUID = rnd.UUIDv7()
|
||||
}
|
||||
|
||||
// Derive a sensible default advertise URL when not provided by the client.
|
||||
if req.AdvertiseUrl != "" {
|
||||
n.AdvertiseUrl = req.AdvertiseUrl
|
||||
@@ -281,6 +284,11 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if n.Database == nil {
|
||||
n.Database = &cluster.NodeDatabase{}
|
||||
}
|
||||
|
||||
n.Database.Name, n.Database.User, n.Database.RotatedAt = creds.Name, creds.User, creds.RotatedAt
|
||||
n.Database.Driver = provisioner.DatabaseDriver
|
||||
|
||||
@@ -294,6 +302,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
|
||||
Node: reg.BuildClusterNode(*n, reg.NodeOptsForSession(nil)),
|
||||
Secrets: &cluster.RegisterSecrets{ClientSecret: n.ClientSecret, RotatedAt: n.RotatedAt},
|
||||
Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: creds.Name, User: creds.User, Driver: provisioner.DatabaseDriver, Password: creds.Password, DSN: creds.DSN, RotatedAt: creds.RotatedAt},
|
||||
JWKSUrl: buildJWKSURL(conf),
|
||||
AlreadyRegistered: false,
|
||||
AlreadyProvisioned: false,
|
||||
}
|
||||
@@ -348,5 +357,23 @@ func validateAdvertiseURL(u string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func buildJWKSURL(conf *config.Config) string {
|
||||
if conf == nil {
|
||||
return "/.well-known/jwks.json"
|
||||
}
|
||||
path := conf.BaseUri("/.well-known/jwks.json")
|
||||
if path == "" {
|
||||
path = "/.well-known/jwks.json"
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
site := strings.TrimRight(conf.SiteUrl(), "/")
|
||||
if site == "" {
|
||||
return path
|
||||
}
|
||||
return site + path
|
||||
}
|
||||
|
||||
// validateSiteURL applies the same rules as validateAdvertiseURL.
|
||||
func validateSiteURL(u string) bool { return validateAdvertiseURL(u) }
|
||||
|
@@ -31,7 +31,7 @@ func TestClusterNodesRegister(t *testing.T) {
|
||||
// Pre-create a node via registry and rotate to get a plaintext secret for tests
|
||||
regy, err := reg.NewClientRegistryWithConfig(conf)
|
||||
assert.NoError(t, err)
|
||||
n := ®.Node{UUID: rnd.UUIDv7(), Name: "pp-auth", Role: "instance"}
|
||||
n := ®.Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-auth", Role: "instance"}}
|
||||
assert.NoError(t, regy.Put(n))
|
||||
nr, err := regy.RotateSecret(n.UUID)
|
||||
assert.NoError(t, err)
|
||||
@@ -84,8 +84,9 @@ func TestClusterNodesRegister(t *testing.T) {
|
||||
|
||||
regy, err := reg.NewClientRegistryWithConfig(conf)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Pre-create node with a UUID
|
||||
n := ®.Node{UUID: rnd.UUIDv7(), Name: "pp-lock", Role: "instance"}
|
||||
n := ®.Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-lock", Role: "instance"}}
|
||||
assert.NoError(t, regy.Put(n))
|
||||
|
||||
// Attempt to change UUID via name without client credentials → 409
|
||||
@@ -172,7 +173,7 @@ func TestClusterNodesRegister(t *testing.T) {
|
||||
// used by OAuth tests running in the same package.
|
||||
regy, err := reg.NewClientRegistryWithConfig(conf)
|
||||
assert.NoError(t, err)
|
||||
n := ®.Node{Name: "pp-node-01", Role: "instance"}
|
||||
n := ®.Node{Node: cluster.Node{Name: "pp-node-01", Role: "instance"}}
|
||||
assert.NoError(t, regy.Put(n))
|
||||
|
||||
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01","rotateSecret":true}`, "t0k3n")
|
||||
@@ -195,7 +196,7 @@ func TestClusterNodesRegister(t *testing.T) {
|
||||
// Pre-create node in registry so handler goes through existing-node path.
|
||||
regy, err := reg.NewClientRegistryWithConfig(conf)
|
||||
assert.NoError(t, err)
|
||||
n := ®.Node{Name: "pp-node-02", Role: "instance"}
|
||||
n := ®.Node{Node: cluster.Node{Name: "pp-node-02", Role: "instance"}}
|
||||
assert.NoError(t, regy.Put(n))
|
||||
|
||||
// Provisioner is independent; endpoint should respond 200 and persist metadata.
|
||||
|
@@ -27,10 +27,13 @@ func TestClusterEndpoints(t *testing.T) {
|
||||
// Seed nodes in the registry
|
||||
regy, err := reg.NewClientRegistryWithConfig(conf)
|
||||
assert.NoError(t, err)
|
||||
n := ®.Node{Name: "pp-node-01", Role: "instance", UUID: rnd.UUIDv7()}
|
||||
|
||||
n := ®.Node{Node: cluster.Node{Name: "pp-node-01", Role: "instance", UUID: rnd.UUIDv7()}}
|
||||
assert.NoError(t, regy.Put(n))
|
||||
n2 := ®.Node{Name: "pp-node-02", Role: "service", UUID: rnd.UUIDv7()}
|
||||
|
||||
n2 := ®.Node{Node: cluster.Node{Name: "pp-node-02", Role: "service", UUID: rnd.UUIDv7()}}
|
||||
assert.NoError(t, regy.Put(n2))
|
||||
|
||||
// Resolve actual IDs (client-backed registry generates IDs)
|
||||
n, err = regy.FindByName("pp-node-01")
|
||||
assert.NoError(t, err)
|
||||
@@ -87,8 +90,10 @@ func TestClusterGetNode_UUIDValidation(t *testing.T) {
|
||||
// Seed a node and resolve its actual ID.
|
||||
regy, err := reg.NewClientRegistryWithConfig(conf)
|
||||
assert.NoError(t, err)
|
||||
n := ®.Node{Name: "pp-node-99", Role: "instance", UUID: rnd.UUIDv7()}
|
||||
|
||||
n := ®.Node{Node: cluster.Node{Name: "pp-node-99", Role: "instance", UUID: rnd.UUIDv7()}}
|
||||
assert.NoError(t, regy.Put(n))
|
||||
|
||||
n, err = regy.FindByName("pp-node-99")
|
||||
assert.NoError(t, err)
|
||||
|
||||
@@ -114,9 +119,11 @@ func TestClusterGetNode_UUIDValidation(t *testing.T) {
|
||||
|
||||
// Excessively long ID (>64 chars) is rejected.
|
||||
longID := make([]byte, 65)
|
||||
|
||||
for i := range longID {
|
||||
longID[i] = 'a'
|
||||
}
|
||||
|
||||
r = PerformRequest(app, http.MethodGet, "/api/v1/cluster/nodes/"+string(longID))
|
||||
assert.Equal(t, http.StatusNotFound, r.Code)
|
||||
}
|
||||
|
@@ -21,8 +21,9 @@ func TestClusterUpdateNode_SiteUrl(t *testing.T) {
|
||||
|
||||
regy, err := reg.NewClientRegistryWithConfig(conf)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Seed node
|
||||
n := ®.Node{Name: "pp-node-siteurl", Role: "instance", UUID: rnd.UUIDv7()}
|
||||
n := ®.Node{Node: cluster.Node{Name: "pp-node-siteurl", Role: "instance", UUID: rnd.UUIDv7()}}
|
||||
assert.NoError(t, regy.Put(n))
|
||||
n, err = regy.FindByName("pp-node-siteurl")
|
||||
assert.NoError(t, err)
|
||||
|
@@ -422,6 +422,9 @@
|
||||
"database": {
|
||||
"$ref": "#/definitions/cluster.RegisterDatabase"
|
||||
},
|
||||
"jwksUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"node": {
|
||||
"$ref": "#/definitions/cluster.Node"
|
||||
},
|
||||
|
107
internal/auth/jwt/issuer.go
Normal file
107
internal/auth/jwt/issuer.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gojwt "github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
var (
|
||||
// DefaultTokenTTL is the default lifetime for issued tokens.
|
||||
DefaultTokenTTL = 300 * time.Second
|
||||
// MaxTokenTTL clamps configurable lifetimes to a safe upper bound.
|
||||
MaxTokenTTL = 900 * time.Second
|
||||
)
|
||||
|
||||
// TokenTTL controls the default lifetime used when a ClaimsSpec does not override TTL.
|
||||
var TokenTTL = DefaultTokenTTL
|
||||
|
||||
// ClaimsSpec describes the claims to embed in a signed token.
|
||||
type ClaimsSpec struct {
|
||||
Issuer string
|
||||
Subject string
|
||||
Audience string
|
||||
Scope []string
|
||||
TTL time.Duration
|
||||
}
|
||||
|
||||
// validate performs sanity checks on the claim specification before issuing a token.
|
||||
func (s ClaimsSpec) validate() error {
|
||||
if strings.TrimSpace(s.Issuer) == "" {
|
||||
return errors.New("jwt: issuer required")
|
||||
}
|
||||
if strings.TrimSpace(s.Subject) == "" {
|
||||
return errors.New("jwt: subject required")
|
||||
}
|
||||
if strings.TrimSpace(s.Audience) == "" {
|
||||
return errors.New("jwt: audience required")
|
||||
}
|
||||
if len(s.Scope) == 0 {
|
||||
return errors.New("jwt: scope required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Issuer signs JWTs on behalf of the Portal using the manager's active key.
|
||||
type Issuer struct {
|
||||
manager *Manager
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewIssuer returns an Issuer bound to the provided Manager.
|
||||
func NewIssuer(m *Manager) *Issuer {
|
||||
return &Issuer{manager: m, now: time.Now}
|
||||
}
|
||||
|
||||
// Issue signs a JWT using the manager's active key according to spec.
|
||||
func (i *Issuer) Issue(spec ClaimsSpec) (string, error) {
|
||||
if i == nil || i.manager == nil {
|
||||
return "", errors.New("jwt: issuer not initialized")
|
||||
}
|
||||
if err := spec.validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ttl := spec.TTL
|
||||
if ttl <= 0 {
|
||||
ttl = TokenTTL
|
||||
}
|
||||
if ttl > MaxTokenTTL {
|
||||
ttl = MaxTokenTTL
|
||||
}
|
||||
|
||||
key, err := i.manager.EnsureActiveKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
issuedAt := i.now().UTC()
|
||||
expiresAt := issuedAt.Add(ttl)
|
||||
|
||||
claims := &Claims{
|
||||
Scope: strings.Join(spec.Scope, " "),
|
||||
RegisteredClaims: gojwt.RegisteredClaims{
|
||||
Issuer: spec.Issuer,
|
||||
Subject: spec.Subject,
|
||||
Audience: gojwt.ClaimStrings{spec.Audience},
|
||||
IssuedAt: gojwt.NewNumericDate(issuedAt),
|
||||
NotBefore: gojwt.NewNumericDate(issuedAt),
|
||||
ExpiresAt: gojwt.NewNumericDate(expiresAt),
|
||||
ID: rnd.GenerateUID(rnd.PrefixMixed),
|
||||
},
|
||||
}
|
||||
|
||||
token := gojwt.NewWithClaims(gojwt.SigningMethodEdDSA, claims)
|
||||
token.Header["kid"] = key.Kid
|
||||
token.Header["typ"] = "JWT"
|
||||
|
||||
signed, err := token.SignedString(key.PrivateKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return signed, nil
|
||||
}
|
27
internal/auth/jwt/jwt.go
Normal file
27
internal/auth/jwt/jwt.go
Normal file
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
Package jwt provides helpers for managing Ed25519 signing keys and issuing or
|
||||
verifying short-lived JWTs used for secure communication between the Portal and
|
||||
cluster nodes.
|
||||
|
||||
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
|
||||
<https://docs.photoprism.app/license/agpl>
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
The AGPL is supplemented by our Trademark and Brand Guidelines,
|
||||
which describe how our Brand Assets may be used:
|
||||
<https://www.photoprism.app/trademark>
|
||||
|
||||
Feel free to send an email to hello@photoprism.app if you have questions,
|
||||
want to support our work, or just want to say hello.
|
||||
|
||||
Additional information can be found in our Developer Guide:
|
||||
<https://docs.photoprism.app/developer-guide/>
|
||||
*/
|
||||
package jwt
|
30
internal/auth/jwt/jwt_test.go
Normal file
30
internal/auth/jwt/jwt_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Init test logger.
|
||||
log = logrus.StandardLogger()
|
||||
log.SetLevel(logrus.TraceLevel)
|
||||
event.AuditLog = log
|
||||
|
||||
c := config.TestConfig()
|
||||
defer c.CloseDb()
|
||||
|
||||
// Run unit tests.
|
||||
code := m.Run()
|
||||
|
||||
// Remove temporary SQLite files after running the tests.
|
||||
fs.PurgeTestDbFiles(".", false)
|
||||
|
||||
os.Exit(code)
|
||||
}
|
6
internal/auth/jwt/logger.go
Normal file
6
internal/auth/jwt/logger.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package jwt
|
||||
|
||||
import "github.com/photoprism/photoprism/internal/event"
|
||||
|
||||
// log provides package-wide logging using the shared event logger.
|
||||
var log = event.Log
|
288
internal/auth/jwt/manager.go
Normal file
288
internal/auth/jwt/manager.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
const (
|
||||
privateKeyPrefix = "ed25519-"
|
||||
privateKeyExt = ".jwk"
|
||||
publicKeyExt = ".pub.jwk"
|
||||
)
|
||||
|
||||
type keyRecord struct {
|
||||
Kty string `json:"kty"`
|
||||
Crv string `json:"crv"`
|
||||
Kid string `json:"kid"`
|
||||
X string `json:"x"`
|
||||
D string `json:"d,omitempty"`
|
||||
CreatedAt int64 `json:"createdAt,omitempty"`
|
||||
NotAfter int64 `json:"notAfter,omitempty"`
|
||||
}
|
||||
|
||||
// Manager handles Ed25519 key lifecycle for JWT issuance and JWKS exposure.
|
||||
type Manager struct {
|
||||
conf *config.Config
|
||||
|
||||
mu sync.RWMutex
|
||||
keys []*Key
|
||||
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// ErrNoActiveKey indicates that the manager has no active key pair available.
|
||||
var ErrNoActiveKey = errors.New("jwt: no active signing key")
|
||||
|
||||
// NewManager creates a Manager bound to the provided config.
|
||||
func NewManager(conf *config.Config) (*Manager, error) {
|
||||
if conf == nil {
|
||||
return nil, errors.New("jwt: config is nil")
|
||||
}
|
||||
|
||||
m := &Manager{
|
||||
conf: conf,
|
||||
now: time.Now,
|
||||
}
|
||||
|
||||
if err := m.loadKeys(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// keyDir returns the directory in which key material is stored.
|
||||
func (m *Manager) keyDir() string {
|
||||
return filepath.Join(m.conf.PortalConfigPath(), "keys")
|
||||
}
|
||||
|
||||
// EnsureActiveKey returns the current active key, generating one if necessary.
|
||||
func (m *Manager) EnsureActiveKey() (*Key, error) {
|
||||
if k, err := m.ActiveKey(); err == nil {
|
||||
return k, nil
|
||||
}
|
||||
|
||||
return m.generateKey()
|
||||
}
|
||||
|
||||
// ActiveKey returns the most recent, non-expired signing key.
|
||||
func (m *Manager) ActiveKey() (*Key, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
now := m.now().Unix()
|
||||
|
||||
for i := len(m.keys) - 1; i >= 0; i-- {
|
||||
k := m.keys[i]
|
||||
if k.NotAfter != 0 && now > k.NotAfter {
|
||||
continue
|
||||
}
|
||||
return k.clone(), nil
|
||||
}
|
||||
|
||||
return nil, ErrNoActiveKey
|
||||
}
|
||||
|
||||
// JWKS returns the public JWKS representation of all non-expired keys.
|
||||
func (m *Manager) JWKS() *JWKS {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
now := m.now().Unix()
|
||||
keys := make([]PublicJWK, 0, len(m.keys))
|
||||
|
||||
for _, k := range m.keys {
|
||||
if k.NotAfter != 0 && now > k.NotAfter {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, PublicJWK{
|
||||
Kty: keyTypeOKP,
|
||||
Crv: curveEd25519,
|
||||
Kid: k.Kid,
|
||||
X: base64.RawURLEncoding.EncodeToString(k.PublicKey),
|
||||
})
|
||||
}
|
||||
|
||||
return &JWKS{Keys: keys}
|
||||
}
|
||||
|
||||
// AllKeys returns a slice copy containing all loaded keys (for testing/inspection).
|
||||
func (m *Manager) AllKeys() []*Key {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
out := make([]*Key, len(m.keys))
|
||||
for i, k := range m.keys {
|
||||
out[i] = k.clone()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// loadKeys reads existing key records from disk into memory.
|
||||
func (m *Manager) loadKeys() error {
|
||||
dir := m.keyDir()
|
||||
|
||||
if err := fs.MkdirAll(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
keys := make([]*Key, 0, len(entries))
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(name, privateKeyPrefix) || !strings.HasSuffix(name, privateKeyExt) {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(name, publicKeyExt) {
|
||||
// Skip public-only artifacts when reloading.
|
||||
continue
|
||||
}
|
||||
|
||||
keyPath := filepath.Join(dir, name)
|
||||
b, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var rec keyRecord
|
||||
if err := json.Unmarshal(b, &rec); err != nil {
|
||||
return err
|
||||
}
|
||||
if rec.Kty != keyTypeOKP || rec.Crv != curveEd25519 || rec.Kid == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
privBytes, err := base64.RawURLEncoding.DecodeString(rec.D)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(privBytes) != ed25519.SeedSize {
|
||||
return fmt.Errorf("jwt: invalid private key length %d", len(privBytes))
|
||||
}
|
||||
|
||||
priv := ed25519.NewKeyFromSeed(privBytes)
|
||||
pub := make([]byte, ed25519.PublicKeySize)
|
||||
copy(pub, priv[ed25519.SeedSize:])
|
||||
|
||||
k := &Key{
|
||||
Kid: rec.Kid,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
NotAfter: rec.NotAfter,
|
||||
PrivateKey: priv,
|
||||
PublicKey: ed25519.PublicKey(pub),
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
return keys[i].CreatedAt < keys[j].CreatedAt
|
||||
})
|
||||
|
||||
m.mu.Lock()
|
||||
m.keys = keys
|
||||
m.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateKey creates a fresh Ed25519 key pair, persists it, and returns a clone.
|
||||
func (m *Manager) generateKey() (*Key, error) {
|
||||
seed := make([]byte, ed25519.SeedSize)
|
||||
if _, err := rand.Read(seed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
priv := ed25519.NewKeyFromSeed(seed)
|
||||
pub := priv[ed25519.SeedSize:]
|
||||
|
||||
now := m.now().UTC()
|
||||
fingerprint := sha256.Sum256(pub)
|
||||
kid := fmt.Sprintf("%s-%s", now.Format("20060102T1504Z"), hex.EncodeToString(fingerprint[:4]))
|
||||
|
||||
k := &Key{
|
||||
Kid: kid,
|
||||
CreatedAt: now.Unix(),
|
||||
NotAfter: 0,
|
||||
PrivateKey: priv,
|
||||
PublicKey: append(ed25519.PublicKey(nil), pub...),
|
||||
}
|
||||
|
||||
if err := m.persistKey(k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.keys = append(m.keys, k)
|
||||
sort.Slice(m.keys, func(i, j int) bool {
|
||||
return m.keys[i].CreatedAt < m.keys[j].CreatedAt
|
||||
})
|
||||
m.mu.Unlock()
|
||||
|
||||
return k.clone(), nil
|
||||
}
|
||||
|
||||
// persistKey writes the private and public key records to disk using secure permissions.
|
||||
func (m *Manager) persistKey(k *Key) error {
|
||||
dir := m.keyDir()
|
||||
if err := fs.MkdirAll(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
privRec := keyRecord{
|
||||
Kty: keyTypeOKP,
|
||||
Crv: curveEd25519,
|
||||
Kid: k.Kid,
|
||||
X: base64.RawURLEncoding.EncodeToString(k.PublicKey),
|
||||
D: base64.RawURLEncoding.EncodeToString(k.PrivateKey.Seed()),
|
||||
CreatedAt: k.CreatedAt,
|
||||
NotAfter: k.NotAfter,
|
||||
}
|
||||
|
||||
privPath := filepath.Join(dir, privateKeyPrefix+k.Kid+privateKeyExt)
|
||||
pubPath := filepath.Join(dir, privateKeyPrefix+k.Kid+publicKeyExt)
|
||||
|
||||
privJSON, err := json.Marshal(privRec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(privPath, privJSON, fs.ModeSecretFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Public record omits private component.
|
||||
pubRec := privRec
|
||||
pubRec.D = ""
|
||||
pubJSON, err := json.Marshal(pubRec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(pubPath, pubJSON, fs.ModeFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
82
internal/auth/jwt/manager_test.go
Normal file
82
internal/auth/jwt/manager_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
cfg "github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
func TestManagerEnsureActiveKey(t *testing.T) {
|
||||
c := cfg.TestConfig()
|
||||
m, err := NewManager(c)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, m)
|
||||
|
||||
fixed := time.Date(2025, 9, 24, 10, 30, 0, 0, time.UTC)
|
||||
m.now = func() time.Time { return fixed }
|
||||
|
||||
key, err := m.EnsureActiveKey()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, key)
|
||||
require.True(t, strings.HasPrefix(key.Kid, "20250924T1030Z-"))
|
||||
|
||||
// Key files should be persisted.
|
||||
privPath := filepath.Join(c.PortalConfigPath(), "keys", privateKeyPrefix+key.Kid+privateKeyExt)
|
||||
pubPath := filepath.Join(c.PortalConfigPath(), "keys", privateKeyPrefix+key.Kid+publicKeyExt)
|
||||
require.True(t, fs.FileExists(privPath))
|
||||
require.True(t, fs.FileExists(pubPath))
|
||||
|
||||
// Second call should reuse same key.
|
||||
next, err := m.EnsureActiveKey()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, key.Kid, next.Kid)
|
||||
|
||||
// JWKS should expose the key.
|
||||
jwks := m.JWKS()
|
||||
require.Len(t, jwks.Keys, 1)
|
||||
require.Equal(t, key.Kid, jwks.Keys[0].Kid)
|
||||
|
||||
// Reload manager from disk.
|
||||
m2, err := NewManager(c)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, m2)
|
||||
reloaded, err := m2.ActiveKey()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, key.Kid, reloaded.Kid)
|
||||
}
|
||||
|
||||
func TestManagerGenerateSecondKey(t *testing.T) {
|
||||
c := cfg.TestConfig()
|
||||
m, err := NewManager(c)
|
||||
require.NoError(t, err)
|
||||
|
||||
first := time.Date(2025, 9, 24, 10, 30, 0, 0, time.UTC)
|
||||
m.now = func() time.Time { return first }
|
||||
k1, err := m.EnsureActiveKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
second := first.Add(24 * time.Hour)
|
||||
m.now = func() time.Time { return second }
|
||||
// Force generation by clearing in-memory keys to simulate expiration.
|
||||
m.mu.Lock()
|
||||
m.keys[len(m.keys)-1].NotAfter = first.Unix()
|
||||
m.mu.Unlock()
|
||||
|
||||
k2, err := m.EnsureActiveKey()
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, k1.Kid, k2.Kid)
|
||||
|
||||
// JWKS should include both keys (old not expired due to manual NotAfter=CreatedAt).
|
||||
jwks := m.JWKS()
|
||||
require.NotEmpty(t, jwks.Keys)
|
||||
|
||||
// Clean up generated files.
|
||||
require.NoError(t, os.RemoveAll(filepath.Join(c.PortalConfigPath(), "keys")))
|
||||
}
|
56
internal/auth/jwt/types.go
Normal file
56
internal/auth/jwt/types.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
|
||||
gojwt "github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
keyTypeOKP = "OKP"
|
||||
curveEd25519 = "Ed25519"
|
||||
)
|
||||
|
||||
// PublicJWK represents the public portion of an Ed25519 key in JWK form.
|
||||
type PublicJWK struct {
|
||||
Kty string `json:"kty"`
|
||||
Crv string `json:"crv"`
|
||||
Kid string `json:"kid"`
|
||||
X string `json:"x"`
|
||||
}
|
||||
|
||||
// JWKS represents a JSON Web Key Set.
|
||||
type JWKS struct {
|
||||
Keys []PublicJWK `json:"keys"`
|
||||
}
|
||||
|
||||
// Claims represents cluster JWT claims.
|
||||
type Claims struct {
|
||||
Scope string `json:"scope"`
|
||||
gojwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// Key encapsulates an Ed25519 keypair with metadata used for JWKS rotation.
|
||||
type Key struct {
|
||||
Kid string
|
||||
CreatedAt int64
|
||||
NotAfter int64
|
||||
|
||||
PrivateKey ed25519.PrivateKey
|
||||
PublicKey ed25519.PublicKey
|
||||
}
|
||||
|
||||
// clone returns a shallow copy of the key to avoid exposing internal slices.
|
||||
func (k *Key) clone() *Key {
|
||||
if k == nil {
|
||||
return nil
|
||||
}
|
||||
c := *k
|
||||
if k.PrivateKey != nil {
|
||||
c.PrivateKey = append(ed25519.PrivateKey(nil), k.PrivateKey...)
|
||||
}
|
||||
if k.PublicKey != nil {
|
||||
c.PublicKey = append(ed25519.PublicKey(nil), k.PublicKey...)
|
||||
}
|
||||
return &c
|
||||
}
|
390
internal/auth/jwt/verifier.go
Normal file
390
internal/auth/jwt/verifier.go
Normal file
@@ -0,0 +1,390 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
gojwt "github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
var (
|
||||
errKeyNotFound = errors.New("jwt: key not found")
|
||||
)
|
||||
|
||||
const (
|
||||
// jwksFetchMaxRetries caps the number of immediate retry attempts after a fetch error.
|
||||
jwksFetchMaxRetries = 3
|
||||
// jwksFetchBaseDelay is the initial retry delay (with jitter) applied after the first failure.
|
||||
jwksFetchBaseDelay = 200 * time.Millisecond
|
||||
// jwksFetchMaxDelay is the upper bound for retry delays to prevent unbounded backoff.
|
||||
jwksFetchMaxDelay = 2 * time.Second
|
||||
)
|
||||
|
||||
// randInt63n is defined for deterministic testing of jitter (overridable in tests).
|
||||
var randInt63n = rand.Int63n
|
||||
|
||||
// cacheEntry stores the JWKS material cached on disk and in memory.
|
||||
type cacheEntry struct {
|
||||
URL string `json:"url"`
|
||||
ETag string `json:"etag,omitempty"`
|
||||
Keys []PublicJWK `json:"keys"`
|
||||
FetchedAt int64 `json:"fetchedAt"`
|
||||
}
|
||||
|
||||
// Verifier validates Portal-issued JWTs on Nodes using JWKS with caching.
|
||||
type Verifier struct {
|
||||
conf *config.Config
|
||||
|
||||
mu sync.Mutex
|
||||
cache cacheEntry
|
||||
cachePath string
|
||||
|
||||
httpClient *http.Client
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// ExpectedClaims describes the constraints that must hold for a token.
|
||||
type ExpectedClaims struct {
|
||||
Issuer string
|
||||
Audience string
|
||||
Scope []string
|
||||
JWKSURL string
|
||||
}
|
||||
|
||||
// NewVerifier instantiates a verifier with sane defaults.
|
||||
func NewVerifier(conf *config.Config) *Verifier {
|
||||
v := &Verifier{
|
||||
conf: conf,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
now: time.Now,
|
||||
}
|
||||
if conf != nil {
|
||||
v.cachePath = filepath.Join(conf.ConfigPath(), "jwks-cache.json")
|
||||
}
|
||||
_ = v.loadCache()
|
||||
return v
|
||||
}
|
||||
|
||||
// Prime ensures JWKS material is cached locally.
|
||||
func (v *Verifier) Prime(ctx context.Context, jwksURL string) error {
|
||||
_, err := v.keysForURL(ctx, jwksURL, true)
|
||||
return err
|
||||
}
|
||||
|
||||
// VerifyToken validates a JWT against the expected claims and returns decoded claims.
|
||||
func (v *Verifier) VerifyToken(ctx context.Context, tokenString string, expected ExpectedClaims) (*Claims, error) {
|
||||
if v == nil {
|
||||
return nil, errors.New("jwt: verifier not initialized")
|
||||
}
|
||||
if strings.TrimSpace(tokenString) == "" {
|
||||
return nil, errors.New("jwt: token is empty")
|
||||
}
|
||||
if strings.TrimSpace(expected.Issuer) == "" {
|
||||
return nil, errors.New("jwt: expected issuer required")
|
||||
}
|
||||
if strings.TrimSpace(expected.Audience) == "" {
|
||||
return nil, errors.New("jwt: expected audience required")
|
||||
}
|
||||
if len(expected.Scope) == 0 {
|
||||
return nil, errors.New("jwt: expected scope required")
|
||||
}
|
||||
|
||||
url := strings.TrimSpace(expected.JWKSURL)
|
||||
if url == "" && v.conf != nil {
|
||||
url = strings.TrimSpace(v.conf.JWKSUrl())
|
||||
}
|
||||
if url == "" {
|
||||
return nil, errors.New("jwt: jwks url not configured")
|
||||
}
|
||||
|
||||
leeway := 60 * time.Second
|
||||
if v.conf != nil && v.conf.JWTLeeway() > 0 {
|
||||
leeway = time.Duration(v.conf.JWTLeeway()) * time.Second
|
||||
}
|
||||
|
||||
parser := gojwt.NewParser(
|
||||
gojwt.WithLeeway(leeway),
|
||||
gojwt.WithValidMethods([]string{gojwt.SigningMethodEdDSA.Alg()}),
|
||||
gojwt.WithIssuer(expected.Issuer),
|
||||
gojwt.WithAudience(expected.Audience),
|
||||
)
|
||||
|
||||
claims := &Claims{}
|
||||
keyFunc := func(token *gojwt.Token) (interface{}, error) {
|
||||
kid, _ := token.Header["kid"].(string)
|
||||
if kid == "" {
|
||||
return nil, errors.New("jwt: missing kid header")
|
||||
}
|
||||
pk, err := v.publicKeyForKid(ctx, url, kid, false)
|
||||
if errors.Is(err, errKeyNotFound) {
|
||||
pk, err = v.publicKeyForKid(ctx, url, kid, true)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pk, nil
|
||||
}
|
||||
|
||||
if _, err := parser.ParseWithClaims(tokenString, claims, keyFunc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims.IssuedAt == nil || claims.ExpiresAt == nil {
|
||||
return nil, errors.New("jwt: missing temporal claims")
|
||||
}
|
||||
if ttl := claims.ExpiresAt.Time.Sub(claims.IssuedAt.Time); ttl > MaxTokenTTL {
|
||||
return nil, errors.New("jwt: token ttl exceeds maximum")
|
||||
}
|
||||
|
||||
scopeSet := map[string]struct{}{}
|
||||
for _, s := range strings.Fields(claims.Scope) {
|
||||
scopeSet[s] = struct{}{}
|
||||
}
|
||||
for _, req := range expected.Scope {
|
||||
if _, ok := scopeSet[req]; !ok {
|
||||
return nil, fmt.Errorf("jwt: missing scope %s", req)
|
||||
}
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// publicKeyForKid resolves the public key for the given key ID, fetching JWKS data if needed.
|
||||
func (v *Verifier) publicKeyForKid(ctx context.Context, url, kid string, force bool) (ed25519.PublicKey, error) {
|
||||
keys, err := v.keysForURL(ctx, url, force)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, k := range keys {
|
||||
if k.Kid != kid {
|
||||
continue
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(k.X)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) != ed25519.PublicKeySize {
|
||||
return nil, fmt.Errorf("jwt: invalid public key length %d", len(raw))
|
||||
}
|
||||
pk := make(ed25519.PublicKey, ed25519.PublicKeySize)
|
||||
copy(pk, raw)
|
||||
return pk, nil
|
||||
}
|
||||
return nil, errKeyNotFound
|
||||
}
|
||||
|
||||
// keysForURL returns JWKS keys for the specified endpoint, reusing cache when possible.
|
||||
func (v *Verifier) keysForURL(ctx context.Context, url string, force bool) ([]PublicJWK, error) {
|
||||
ttl := 300 * time.Second
|
||||
if v.conf != nil && v.conf.JWKSCacheTTL() > 0 {
|
||||
ttl = time.Duration(v.conf.JWKSCacheTTL()) * time.Second
|
||||
}
|
||||
|
||||
attempts := 0
|
||||
|
||||
for {
|
||||
cached := v.snapshotCache()
|
||||
|
||||
if keys, ok := v.cachedKeys(url, ttl, cached, force); ok {
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
etag := ""
|
||||
if !force && cached.URL == url {
|
||||
etag = cached.ETag
|
||||
}
|
||||
|
||||
result, err := v.fetchJWKS(ctx, url, etag)
|
||||
if err != nil {
|
||||
if !force && cached.URL == url && len(cached.Keys) > 0 {
|
||||
return append([]PublicJWK(nil), cached.Keys...), nil
|
||||
}
|
||||
|
||||
attempts++
|
||||
if attempts >= jwksFetchMaxRetries {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
delay := backoffDuration(attempts)
|
||||
log.Debugf("jwt: jwks fetch retry %d for %s in %s (%s)", attempts, url, delay, err)
|
||||
|
||||
select {
|
||||
case <-time.After(delay):
|
||||
continue
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
if keys, ok := v.updateCache(url, result); ok {
|
||||
return keys, nil
|
||||
}
|
||||
// Cache changed by another goroutine between snapshot and update; retry.
|
||||
}
|
||||
}
|
||||
|
||||
// snapshotCache returns the current JWKS cache entry under lock for safe reading.
|
||||
func (v *Verifier) snapshotCache() cacheEntry {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
cache := v.cache
|
||||
return cache
|
||||
}
|
||||
|
||||
// cachedKeys returns cached JWKS keys if they are fresh enough and match the target URL.
|
||||
func (v *Verifier) cachedKeys(url string, ttl time.Duration, cache cacheEntry, force bool) ([]PublicJWK, bool) {
|
||||
if force || cache.URL != url || len(cache.Keys) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
age := v.now().Unix() - cache.FetchedAt
|
||||
if age < 0 {
|
||||
return nil, false
|
||||
}
|
||||
if time.Duration(age)*time.Second > ttl {
|
||||
return nil, false
|
||||
}
|
||||
return append([]PublicJWK(nil), cache.Keys...), true
|
||||
}
|
||||
|
||||
type jwksFetchResult struct {
|
||||
keys []PublicJWK
|
||||
etag string
|
||||
fetchedAt int64
|
||||
notModified bool
|
||||
}
|
||||
|
||||
// fetchJWKS downloads the JWKS document (respecting conditional requests) and returns the parsed keys.
|
||||
func (v *Verifier) fetchJWKS(ctx context.Context, url, etag string) (*jwksFetchResult, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if etag != "" {
|
||||
req.Header.Set("If-None-Match", etag)
|
||||
}
|
||||
|
||||
resp, err := v.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusNotModified:
|
||||
return &jwksFetchResult{
|
||||
etag: etag,
|
||||
fetchedAt: v.now().Unix(),
|
||||
notModified: true,
|
||||
}, nil
|
||||
case http.StatusOK:
|
||||
var body JWKS
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(body.Keys) == 0 {
|
||||
return nil, errors.New("jwt: jwks contains no keys")
|
||||
}
|
||||
return &jwksFetchResult{
|
||||
keys: append([]PublicJWK(nil), body.Keys...),
|
||||
etag: resp.Header.Get("ETag"),
|
||||
fetchedAt: v.now().Unix(),
|
||||
}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("jwt: jwks fetch failed: %s", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// updateCache stores the JWKS fetch result on success and returns the fresh keys.
|
||||
func (v *Verifier) updateCache(url string, result *jwksFetchResult) ([]PublicJWK, bool) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
if result.notModified {
|
||||
if v.cache.URL != url {
|
||||
return nil, false
|
||||
}
|
||||
v.cache.FetchedAt = result.fetchedAt
|
||||
if result.etag != "" {
|
||||
v.cache.ETag = result.etag
|
||||
}
|
||||
_ = v.saveCacheLocked()
|
||||
return append([]PublicJWK(nil), v.cache.Keys...), true
|
||||
}
|
||||
|
||||
v.cache = cacheEntry{
|
||||
URL: url,
|
||||
ETag: result.etag,
|
||||
Keys: append([]PublicJWK(nil), result.keys...),
|
||||
FetchedAt: result.fetchedAt,
|
||||
}
|
||||
_ = v.saveCacheLocked()
|
||||
return append([]PublicJWK(nil), v.cache.Keys...), true
|
||||
}
|
||||
|
||||
// loadCache restores a previously persisted JWKS cache entry from disk.
|
||||
func (v *Verifier) loadCache() error {
|
||||
if v.cachePath == "" || !fs.FileExists(v.cachePath) {
|
||||
return nil
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(v.cachePath)
|
||||
if err != nil || len(b) == 0 {
|
||||
return err
|
||||
}
|
||||
|
||||
var entry cacheEntry
|
||||
if err := json.Unmarshal(b, &entry); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
v.cache = entry
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveCacheLocked persists the current cache entry to disk; caller must hold the mutex.
|
||||
func (v *Verifier) saveCacheLocked() error {
|
||||
if v.cachePath == "" {
|
||||
return nil
|
||||
}
|
||||
if err := fs.MkdirAll(filepath.Dir(v.cachePath)); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(v.cache)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(v.cachePath, data, fs.ModeSecretFile)
|
||||
}
|
||||
|
||||
// backoffDuration returns the retry delay for the given fetch attempt, adding jitter.
|
||||
func backoffDuration(attempt int) time.Duration {
|
||||
if attempt < 1 {
|
||||
attempt = 1
|
||||
}
|
||||
|
||||
base := jwksFetchBaseDelay << (attempt - 1)
|
||||
if base > jwksFetchMaxDelay {
|
||||
base = jwksFetchMaxDelay
|
||||
}
|
||||
|
||||
jitterRange := base / 2
|
||||
if jitterRange > 0 {
|
||||
base += time.Duration(randInt63n(int64(jitterRange) + 1))
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
168
internal/auth/jwt/verifier_test.go
Normal file
168
internal/auth/jwt/verifier_test.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
gojwt "github.com/golang-jwt/jwt/v5"
|
||||
|
||||
cfg "github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
func TestVerifierPrimeAndVerify(t *testing.T) {
|
||||
portalCfg := cfg.TestConfig()
|
||||
clusterUUID := rnd.UUIDv7()
|
||||
portalCfg.Options().ClusterUUID = clusterUUID
|
||||
|
||||
mgr, err := NewManager(portalCfg)
|
||||
require.NoError(t, err)
|
||||
mgr.now = func() time.Time { return time.Date(2025, 9, 24, 10, 30, 0, 0, time.UTC) }
|
||||
_, err = mgr.EnsureActiveKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
jwksBytes, err := json.Marshal(mgr.JWKS())
|
||||
require.NoError(t, err)
|
||||
|
||||
etag := `"v1"`
|
||||
var requestCount int
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestCount++
|
||||
if r.Header.Get("If-None-Match") == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
w.Header().Set("ETag", etag)
|
||||
w.Header().Set("Cache-Control", "max-age=300")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(jwksBytes)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
nodeCfg := cfg.NewTestConfig("jwt-verifier-node")
|
||||
nodeCfg.SetJWKSUrl(server.URL + "/.well-known/jwks.json")
|
||||
nodeCfg.Options().ClusterUUID = clusterUUID
|
||||
nodeUUID := nodeCfg.NodeUUID()
|
||||
|
||||
issuer := NewIssuer(mgr)
|
||||
issuer.now = func() time.Time { return time.Now().UTC() }
|
||||
|
||||
spec := ClaimsSpec{
|
||||
Issuer: fmt.Sprintf("portal:%s", clusterUUID),
|
||||
Subject: "portal:client-test",
|
||||
Audience: fmt.Sprintf("node:%s", nodeUUID),
|
||||
Scope: []string{"cluster", "vision"},
|
||||
}
|
||||
|
||||
token, err := issuer.Issue(spec)
|
||||
require.NoError(t, err)
|
||||
|
||||
verifier := NewVerifier(nodeCfg)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, verifier.Prime(ctx, nodeCfg.JWKSUrl()))
|
||||
require.Equal(t, 1, requestCount)
|
||||
|
||||
claims, err := verifier.VerifyToken(ctx, token, ExpectedClaims{
|
||||
Issuer: spec.Issuer,
|
||||
Audience: spec.Audience,
|
||||
Scope: []string{"cluster"},
|
||||
JWKSURL: nodeCfg.JWKSUrl(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, spec.Subject, claims.Subject)
|
||||
require.Contains(t, claims.Scope, "cluster")
|
||||
|
||||
// Force cache refresh by expiring entry and verify 304 handling.
|
||||
verifier.mu.Lock()
|
||||
verifier.cache.FetchedAt -= 1000
|
||||
verifier.mu.Unlock()
|
||||
|
||||
_, err = verifier.VerifyToken(ctx, token, ExpectedClaims{
|
||||
Issuer: spec.Issuer,
|
||||
Audience: spec.Audience,
|
||||
Scope: []string{"cluster"},
|
||||
JWKSURL: nodeCfg.JWKSUrl(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, requestCount)
|
||||
|
||||
// Missing scope should fail.
|
||||
_, err = verifier.VerifyToken(ctx, token, ExpectedClaims{
|
||||
Issuer: spec.Issuer,
|
||||
Audience: spec.Audience,
|
||||
Scope: []string{"cluster", "unknown"},
|
||||
JWKSURL: nodeCfg.JWKSUrl(),
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestIssuerClampTTL(t *testing.T) {
|
||||
portalCfg := cfg.TestConfig()
|
||||
mgr, err := NewManager(portalCfg)
|
||||
require.NoError(t, err)
|
||||
mgr.now = func() time.Time { return time.Unix(0, 0) }
|
||||
_, err = mgr.EnsureActiveKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
issuer := NewIssuer(mgr)
|
||||
issuer.now = func() time.Time { return time.Unix(1000, 0) }
|
||||
|
||||
spec := ClaimsSpec{
|
||||
Issuer: "portal:test",
|
||||
Subject: "portal:client",
|
||||
Audience: "node:test",
|
||||
Scope: []string{"cluster"},
|
||||
TTL: 7200 * time.Second,
|
||||
}
|
||||
|
||||
token, err := issuer.Issue(spec)
|
||||
require.NoError(t, err)
|
||||
|
||||
parsed := &Claims{}
|
||||
parser := gojwt.NewParser(gojwt.WithValidMethods([]string{gojwt.SigningMethodEdDSA.Alg()}), gojwt.WithoutClaimsValidation())
|
||||
_, err = parser.ParseWithClaims(token, parsed, func(token *gojwt.Token) (interface{}, error) {
|
||||
key, _ := mgr.ActiveKey()
|
||||
return key.PublicKey, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
ttl := parsed.ExpiresAt.Time.Sub(parsed.IssuedAt.Time)
|
||||
require.Equal(t, MaxTokenTTL, ttl)
|
||||
}
|
||||
|
||||
func TestBackoffDuration(t *testing.T) {
|
||||
origRand := randInt63n
|
||||
randInt63n = func(n int64) int64 {
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
return n - 1
|
||||
}
|
||||
t.Cleanup(func() { randInt63n = origRand })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
attempt int
|
||||
expect time.Duration
|
||||
}{
|
||||
{"Attempt1", 1, 300 * time.Millisecond},
|
||||
{"Attempt2", 2, 600 * time.Millisecond},
|
||||
{"Attempt3", 3, 1200 * time.Millisecond},
|
||||
{"Attempt4", 4, 2400 * time.Millisecond},
|
||||
{"Attempt5", 5, 3 * time.Second},
|
||||
{"AttemptZero", 0, 300 * time.Millisecond},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := backoffDuration(tt.attempt); got != tt.expect {
|
||||
t.Errorf("%s: expected %s, got %s", tt.name, tt.expect, got)
|
||||
}
|
||||
}
|
||||
}
|
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
@@ -20,5 +21,8 @@ func TestMain(m *testing.M) {
|
||||
|
||||
code := m.Run()
|
||||
|
||||
// Remove temporary SQLite files after running the tests.
|
||||
fs.PurgeTestDbFiles(".", false)
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
@@ -99,12 +99,12 @@ func clusterNodesRotateAction(ctx *cli.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"nodeName": name,
|
||||
"rotate": rotateDatabase,
|
||||
"rotateSecret": rotateSecret,
|
||||
payload := cluster.RegisterRequest{
|
||||
NodeName: name,
|
||||
RotateDatabase: rotateDatabase,
|
||||
RotateSecret: rotateSecret,
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
b, _ := json.Marshal(payload)
|
||||
|
||||
url := stringsTrimRightSlash(portalURL) + "/api/v1/cluster/nodes/register"
|
||||
var resp cluster.RegisterResponse
|
||||
|
@@ -85,28 +85,28 @@ func clusterRegisterAction(ctx *cli.Context) error {
|
||||
}
|
||||
site := conf.SiteUrl()
|
||||
|
||||
body := map[string]interface{}{
|
||||
"nodeName": name,
|
||||
"nodeRole": nodeRole,
|
||||
"labels": parseLabelSlice(ctx.StringSlice("label")),
|
||||
"advertiseUrl": advertise,
|
||||
"rotate": ctx.Bool("rotate"),
|
||||
"rotateSecret": ctx.Bool("rotate-secret"),
|
||||
payload := cluster.RegisterRequest{
|
||||
NodeName: name,
|
||||
NodeRole: nodeRole,
|
||||
Labels: parseLabelSlice(ctx.StringSlice("label")),
|
||||
AdvertiseUrl: advertise,
|
||||
RotateDatabase: ctx.Bool("rotate"),
|
||||
RotateSecret: ctx.Bool("rotate-secret"),
|
||||
}
|
||||
// If we already have client credentials (e.g., re-register), include them so the
|
||||
// portal can verify and authorize UUID/name moves or metadata updates.
|
||||
if id, secret := strings.TrimSpace(conf.NodeClientID()), strings.TrimSpace(conf.NodeClientSecret()); id != "" && secret != "" {
|
||||
body["clientId"] = id
|
||||
body["clientSecret"] = secret
|
||||
payload.ClientID = id
|
||||
payload.ClientSecret = secret
|
||||
}
|
||||
if site != "" && site != advertise {
|
||||
body["siteUrl"] = site
|
||||
payload.SiteUrl = site
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
b, _ := json.Marshal(payload)
|
||||
|
||||
if ctx.Bool("dry-run") {
|
||||
if ctx.Bool("json") {
|
||||
out := map[string]any{"portalUrl": portalURL, "payload": body}
|
||||
out := map[string]any{"portalUrl": portalURL, "payload": payload}
|
||||
jb, _ := json.Marshal(out)
|
||||
fmt.Println(string(jb))
|
||||
} else {
|
||||
@@ -116,19 +116,19 @@ func clusterRegisterAction(ctx *cli.Context) error {
|
||||
fmt.Println("(derived defaults were used where flags were omitted)")
|
||||
}
|
||||
fmt.Printf("Advertise: %s\n", advertise)
|
||||
if v, ok := body["siteUrl"].(string); ok && v != "" {
|
||||
fmt.Printf("Site URL: %s\n", v)
|
||||
if payload.SiteUrl != "" {
|
||||
fmt.Printf("Site URL: %s\n", payload.SiteUrl)
|
||||
}
|
||||
// Warn if non-HTTPS on public host; server will enforce too.
|
||||
if warnInsecurePublicURL(advertise) {
|
||||
fmt.Println("Warning: advertise-url is http for a public host; server may reject it (HTTPS required).")
|
||||
}
|
||||
if v, ok := body["siteUrl"].(string); ok && v != "" && warnInsecurePublicURL(v) {
|
||||
if payload.SiteUrl != "" && warnInsecurePublicURL(payload.SiteUrl) {
|
||||
fmt.Println("Warning: site-url is http for a public host; server may reject it (HTTPS required).")
|
||||
}
|
||||
// Single-line summary for quick operator scan
|
||||
if v, ok := body["siteUrl"].(string); ok && v != "" {
|
||||
fmt.Printf("Derived: portal=%s advertise=%s site=%s\n", portalURL, advertise, v)
|
||||
if payload.SiteUrl != "" {
|
||||
fmt.Printf("Derived: portal=%s advertise=%s site=%s\n", portalURL, advertise, payload.SiteUrl)
|
||||
} else {
|
||||
fmt.Printf("Derived: portal=%s advertise=%s\n", portalURL, advertise)
|
||||
}
|
||||
|
@@ -151,7 +151,7 @@ func TestClusterNodesRotate_DBOnly_JSON(t *testing.T) {
|
||||
}
|
||||
// Read payload to assert rotate flags
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
rotate := gjson.GetBytes(b, "rotate").Bool()
|
||||
rotate := gjson.GetBytes(b, "rotateDatabase").Bool()
|
||||
rotateSecret := gjson.GetBytes(b, "rotateSecret").Bool()
|
||||
// Expect DB rotation only
|
||||
if !rotate || rotateSecret {
|
||||
@@ -203,7 +203,7 @@ func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) {
|
||||
return
|
||||
}
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
rotate := gjson.GetBytes(b, "rotate").Bool()
|
||||
rotate := gjson.GetBytes(b, "rotateDatabase").Bool()
|
||||
rotateSecret := gjson.GetBytes(b, "rotateSecret").Bool()
|
||||
// Expect secret-only rotation
|
||||
if rotate || !rotateSecret {
|
||||
@@ -422,7 +422,7 @@ func TestClusterRegister_RotateDatabase_JSON(t *testing.T) {
|
||||
return
|
||||
}
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
if !gjson.GetBytes(b, "rotate").Bool() || gjson.GetBytes(b, "rotateSecret").Bool() {
|
||||
if !gjson.GetBytes(b, "rotateDatabase").Bool() || gjson.GetBytes(b, "rotateSecret").Bool() {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -463,7 +463,7 @@ func TestClusterRegister_RotateSecret_JSON(t *testing.T) {
|
||||
return
|
||||
}
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
if gjson.GetBytes(b, "rotate").Bool() || !gjson.GetBytes(b, "rotateSecret").Bool() {
|
||||
if gjson.GetBytes(b, "rotateDatabase").Bool() || !gjson.GetBytes(b, "rotateSecret").Bool() {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/photoprism/get"
|
||||
"github.com/photoprism/photoprism/internal/service/cluster"
|
||||
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
@@ -95,7 +96,7 @@ func TestClusterSuccessPaths_PortalLocal(t *testing.T) {
|
||||
// Create a registry node via FileRegistry.
|
||||
r, err := reg.NewClientRegistryWithConfig(c)
|
||||
assert.NoError(t, err)
|
||||
n := ®.Node{Name: "pp-node-01", Role: "instance", Labels: map[string]string{"env": "test"}}
|
||||
n := ®.Node{Node: cluster.Node{Name: "pp-node-01", Role: "instance", Labels: map[string]string{"env": "test"}}}
|
||||
assert.NoError(t, r.Put(n))
|
||||
|
||||
// nodes ls (JSON)
|
||||
|
@@ -42,7 +42,7 @@ func TestMain(m *testing.M) {
|
||||
// Run unit tests.
|
||||
code := m.Run()
|
||||
|
||||
// Purge local SQLite test artifacts created during this package's tests.
|
||||
// Remove temporary SQLite files after running the tests.
|
||||
fs.PurgeTestDbFiles(".", false)
|
||||
|
||||
os.Exit(code)
|
||||
|
@@ -210,6 +210,43 @@ func (c *Config) NodeClientSecret() string {
|
||||
}
|
||||
}
|
||||
|
||||
// JWKSUrl returns the configured JWKS endpoint for portal-issued JWTs. Nodes normally
|
||||
// persist this URL from the portal's register response, which derives it from SiteUrl;
|
||||
// manual overrides are only required for custom deployments.
|
||||
func (c *Config) JWKSUrl() string {
|
||||
return strings.TrimSpace(c.options.JWKSUrl)
|
||||
}
|
||||
|
||||
// SetJWKSUrl updates the configured JWKS endpoint for portal-issued JWTs.
|
||||
func (c *Config) SetJWKSUrl(url string) {
|
||||
if c == nil || c.options == nil {
|
||||
return
|
||||
}
|
||||
c.options.JWKSUrl = strings.TrimSpace(url)
|
||||
}
|
||||
|
||||
// JWKSCacheTTL returns the JWKS cache lifetime in seconds (default 300, max 3600).
|
||||
func (c *Config) JWKSCacheTTL() int {
|
||||
if c.options.JWKSCacheTTL <= 0 {
|
||||
return 300
|
||||
}
|
||||
if c.options.JWKSCacheTTL > 3600 {
|
||||
return 3600
|
||||
}
|
||||
return c.options.JWKSCacheTTL
|
||||
}
|
||||
|
||||
// JWTLeeway returns the permitted clock skew in seconds (default 60, max 300).
|
||||
func (c *Config) JWTLeeway() int {
|
||||
if c.options.JWTLeeway <= 0 {
|
||||
return 60
|
||||
}
|
||||
if c.options.JWTLeeway > 300 {
|
||||
return 300
|
||||
}
|
||||
return c.options.JWTLeeway
|
||||
}
|
||||
|
||||
// AdvertiseUrl returns the advertised node URL for intra-cluster calls (scheme://host[:port]).
|
||||
func (c *Config) AdvertiseUrl() string {
|
||||
if c.options.AdvertiseUrl != "" {
|
||||
|
@@ -72,6 +72,17 @@ func TestConfig_Cluster(t *testing.T) {
|
||||
assert.True(t, c.IsPortal())
|
||||
c.Options().NodeRole = ""
|
||||
})
|
||||
t.Run("JWKSUrlSetter", func(t *testing.T) {
|
||||
c := NewConfig(CliTestContext())
|
||||
c.options.JWKSUrl = ""
|
||||
assert.Equal(t, "", c.JWKSUrl())
|
||||
|
||||
c.SetJWKSUrl(" https://portal.example/.well-known/jwks.json ")
|
||||
assert.Equal(t, "https://portal.example/.well-known/jwks.json", c.JWKSUrl())
|
||||
|
||||
c.SetJWKSUrl("")
|
||||
assert.Equal(t, "", c.JWKSUrl())
|
||||
})
|
||||
t.Run("Paths", func(t *testing.T) {
|
||||
c := NewConfig(CliTestContext())
|
||||
|
||||
|
@@ -31,7 +31,7 @@ func TestMain(m *testing.M) {
|
||||
|
||||
code := m.Run()
|
||||
|
||||
// Purge local SQLite test artifacts created during this package's tests.
|
||||
// Remove temporary SQLite files after running the tests.
|
||||
fs.PurgeTestDbFiles(".", false)
|
||||
|
||||
os.Exit(code)
|
||||
|
@@ -720,6 +720,23 @@ var Flags = CliFlags{
|
||||
EnvVars: EnvVars("NODE_CLIENT_SECRET"),
|
||||
Hidden: true,
|
||||
}}, {
|
||||
Flag: &cli.StringFlag{
|
||||
Name: "jwks-url",
|
||||
Usage: "JWKS endpoint `URL` provided by the cluster portal for JWT verification",
|
||||
EnvVars: EnvVars("JWKS_URL"),
|
||||
}}, {
|
||||
Flag: &cli.IntFlag{
|
||||
Name: "jwks-cache-ttl",
|
||||
Usage: "JWKS cache lifetime in `SECONDS` (default 300, max 3600)",
|
||||
Value: 300,
|
||||
EnvVars: EnvVars("JWKS_CACHE_TTL"),
|
||||
}}, {
|
||||
Flag: &cli.IntFlag{
|
||||
Name: "jwt-leeway",
|
||||
Usage: "JWT clock skew allowance in `SECONDS` (default 60, max 300)",
|
||||
Value: 60,
|
||||
EnvVars: EnvVars("JWT_LEEWAY"),
|
||||
}}, {
|
||||
Flag: &cli.StringFlag{
|
||||
Name: "advertise-url",
|
||||
Usage: "advertised `URL` for intra-cluster calls (scheme://host[:port])",
|
||||
|
@@ -152,6 +152,9 @@ type Options struct {
|
||||
NodeRole string `yaml:"-" json:"-" flag:"node-role"`
|
||||
NodeClientID string `yaml:"NodeClientID" json:"-" flag:"node-client-id"`
|
||||
NodeClientSecret string `yaml:"NodeClientSecret" json:"-" flag:"node-client-secret"`
|
||||
JWKSUrl string `yaml:"JWKSUrl" json:"-" flag:"jwks-url"`
|
||||
JWKSCacheTTL int `yaml:"JWKSCacheTTL" json:"-" flag:"jwks-cache-ttl"`
|
||||
JWTLeeway int `yaml:"JWTLeeway" json:"-" flag:"jwt-leeway"`
|
||||
AdvertiseUrl string `yaml:"AdvertiseUrl" json:"-" flag:"advertise-url"`
|
||||
HttpsProxy string `yaml:"HttpsProxy" json:"HttpsProxy" flag:"https-proxy"`
|
||||
HttpsProxyInsecure bool `yaml:"HttpsProxyInsecure" json:"HttpsProxyInsecure" flag:"https-proxy-insecure"`
|
||||
|
@@ -188,6 +188,9 @@ func (c *Config) Report() (rows [][]string, cols []string) {
|
||||
{"node-uuid", c.NodeUUID()},
|
||||
{"node-client-id", c.NodeClientID()},
|
||||
{"node-client-secret", fmt.Sprintf("%s", strings.Repeat("*", utf8.RuneCountInString(c.NodeClientSecret())))},
|
||||
{"jwks-url", c.JWKSUrl()},
|
||||
{"jwks-cache-ttl", fmt.Sprintf("%d", c.JWKSCacheTTL())},
|
||||
{"jwt-leeway", fmt.Sprintf("%d", c.JWTLeeway())},
|
||||
{"advertise-url", c.AdvertiseUrl()},
|
||||
|
||||
// Proxy Servers.
|
||||
|
@@ -24,7 +24,7 @@ func TestMain(m *testing.M) {
|
||||
|
||||
code := m.Run()
|
||||
|
||||
// Purge local SQLite test artifacts created during this package's tests.
|
||||
// Remove temporary SQLite files after running the tests.
|
||||
fs.PurgeTestDbFiles(".", false)
|
||||
|
||||
os.Exit(code)
|
||||
|
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
@@ -22,6 +23,9 @@ func TestMain(m *testing.M) {
|
||||
|
||||
code := m.Run()
|
||||
|
||||
// Remove temporary SQLite files after running the tests.
|
||||
fs.PurgeTestDbFiles(".", false)
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
|
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
@@ -21,5 +22,8 @@ func TestMain(m *testing.M) {
|
||||
|
||||
code := m.Run()
|
||||
|
||||
// Remove temporary SQLite files after running the tests.
|
||||
fs.PurgeTestDbFiles(".", false)
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
26
internal/ffmpeg/apple/apple.go
Normal file
26
internal/ffmpeg/apple/apple.go
Normal file
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
Package apple contains helper routines and constants for wiring up Apple
|
||||
VideoToolbox (VT) encoder support in ffmpeg command generation.
|
||||
|
||||
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
|
||||
<https://docs.photoprism.app/license/agpl>
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
The AGPL is supplemented by our Trademark and Brand Guidelines,
|
||||
which describe how our Brand Assets may be used:
|
||||
<https://www.photoprism.app/trademark>
|
||||
|
||||
Feel free to send an email to hello@photoprism.app if you have questions,
|
||||
want to support our work, or just want to say hello.
|
||||
|
||||
Additional information can be found in our Developer Guide:
|
||||
<https://docs.photoprism.app/developer-guide/>
|
||||
*/
|
||||
package apple
|
26
internal/ffmpeg/intel/intel.go
Normal file
26
internal/ffmpeg/intel/intel.go
Normal file
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
Package intel collects ffmpeg helpers for configuring Intel Quick Sync Video
|
||||
(QSV) encoders when building PhotoPrism transcoding pipelines.
|
||||
|
||||
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
|
||||
<https://docs.photoprism.app/license/agpl>
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
The AGPL is supplemented by our Trademark and Brand Guidelines,
|
||||
which describe how our Brand Assets may be used:
|
||||
<https://www.photoprism.app/trademark>
|
||||
|
||||
Feel free to send an email to hello@photoprism.app if you have questions,
|
||||
want to support our work, or just want to say hello.
|
||||
|
||||
Additional information can be found in our Developer Guide:
|
||||
<https://docs.photoprism.app/developer-guide/>
|
||||
*/
|
||||
package intel
|
26
internal/ffmpeg/nvidia/nvidia.go
Normal file
26
internal/ffmpeg/nvidia/nvidia.go
Normal file
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
Package nvidia provides ffmpeg helpers for NVENC/NVDEC integration so
|
||||
PhotoPrism can leverage NVIDIA GPUs for hardware-accelerated workflows.
|
||||
|
||||
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
|
||||
<https://docs.photoprism.app/license/agpl>
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
The AGPL is supplemented by our Trademark and Brand Guidelines,
|
||||
which describe how our Brand Assets may be used:
|
||||
<https://www.photoprism.app/trademark>
|
||||
|
||||
Feel free to send an email to hello@photoprism.app if you have questions,
|
||||
want to support our work, or just want to say hello.
|
||||
|
||||
Additional information can be found in our Developer Guide:
|
||||
<https://docs.photoprism.app/developer-guide/>
|
||||
*/
|
||||
package nvidia
|
26
internal/ffmpeg/v4l/v4l.go
Normal file
26
internal/ffmpeg/v4l/v4l.go
Normal file
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
Package v4l wraps ffmpeg command helpers for Video4Linux (V4L2) devices used
|
||||
on Linux systems when capturing or transcoding with hardware-backed codecs.
|
||||
|
||||
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
|
||||
<https://docs.photoprism.app/license/agpl>
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
The AGPL is supplemented by our Trademark and Brand Guidelines,
|
||||
which describe how our Brand Assets may be used:
|
||||
<https://www.photoprism.app/trademark>
|
||||
|
||||
Feel free to send an email to hello@photoprism.app if you have questions,
|
||||
want to support our work, or just want to say hello.
|
||||
|
||||
Additional information can be found in our Developer Guide:
|
||||
<https://docs.photoprism.app/developer-guide/>
|
||||
*/
|
||||
package v4l
|
26
internal/ffmpeg/vaapi/vaapi.go
Normal file
26
internal/ffmpeg/vaapi/vaapi.go
Normal file
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
Package vaapi contains ffmpeg helpers for configuring VA-API hardware
|
||||
acceleration on compatible GPUs when building PhotoPrism encode pipelines.
|
||||
|
||||
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
|
||||
<https://docs.photoprism.app/license/agpl>
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
The AGPL is supplemented by our Trademark and Brand Guidelines,
|
||||
which describe how our Brand Assets may be used:
|
||||
<https://www.photoprism.app/trademark>
|
||||
|
||||
Feel free to send an email to hello@photoprism.app if you have questions,
|
||||
want to support our work, or just want to say hello.
|
||||
|
||||
Additional information can be found in our Developer Guide:
|
||||
<https://docs.photoprism.app/developer-guide/>
|
||||
*/
|
||||
package vaapi
|
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/internal/photoprism"
|
||||
"github.com/photoprism/photoprism/internal/photoprism/get"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
@@ -25,5 +26,8 @@ func TestMain(m *testing.M) {
|
||||
|
||||
code := m.Run()
|
||||
|
||||
// Remove temporary SQLite files after running the tests.
|
||||
fs.PurgeTestDbFiles(".", false)
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
58
internal/photoprism/get/jwt.go
Normal file
58
internal/photoprism/get/jwt.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package get
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/auth/jwt"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
)
|
||||
|
||||
var (
|
||||
onceJWTManager sync.Once
|
||||
onceJWTIssuer sync.Once
|
||||
)
|
||||
|
||||
func initJWTManager() {
|
||||
conf := Config()
|
||||
if conf == nil || !conf.IsPortal() {
|
||||
return
|
||||
}
|
||||
manager, err := jwt.NewManager(conf)
|
||||
if err != nil {
|
||||
log.Warnf("jwt: manager init failed (%s)", clean.Error(err))
|
||||
return
|
||||
}
|
||||
if _, err := manager.EnsureActiveKey(); err != nil {
|
||||
log.Warnf("jwt: ensure signing key failed (%s)", clean.Error(err))
|
||||
}
|
||||
services.JWTManager = manager
|
||||
}
|
||||
|
||||
// JWTManager returns the portal key manager; nil on nodes.
|
||||
func JWTManager() *jwt.Manager {
|
||||
onceJWTManager.Do(initJWTManager)
|
||||
return services.JWTManager
|
||||
}
|
||||
|
||||
func initJWTIssuer() {
|
||||
manager := JWTManager()
|
||||
if manager == nil {
|
||||
return
|
||||
}
|
||||
services.JWTIssuer = jwt.NewIssuer(manager)
|
||||
}
|
||||
|
||||
// JWTIssuer returns the portal JWT issuer helper; nil on nodes.
|
||||
func JWTIssuer() *jwt.Issuer {
|
||||
onceJWTIssuer.Do(initJWTIssuer)
|
||||
return services.JWTIssuer
|
||||
}
|
||||
|
||||
// JWTVerifier returns a verifier bound to the current config.
|
||||
func JWTVerifier() *jwt.Verifier {
|
||||
conf := Config()
|
||||
if conf == nil {
|
||||
return nil
|
||||
}
|
||||
return jwt.NewVerifier(conf)
|
||||
}
|
@@ -27,6 +27,7 @@ package get
|
||||
import (
|
||||
gc "github.com/patrickmn/go-cache"
|
||||
|
||||
clusterjwt "github.com/photoprism/photoprism/internal/auth/jwt"
|
||||
"github.com/photoprism/photoprism/internal/auth/oidc"
|
||||
"github.com/photoprism/photoprism/internal/auth/session"
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
@@ -54,6 +55,8 @@ var services struct {
|
||||
Thumbs *photoprism.Thumbs
|
||||
Session *session.Session
|
||||
OIDC *oidc.Client
|
||||
JWTManager *clusterjwt.Manager
|
||||
JWTIssuer *clusterjwt.Issuer
|
||||
}
|
||||
|
||||
func SetConfig(c *config.Config) {
|
||||
|
@@ -20,7 +20,7 @@ func TestMain(m *testing.M) {
|
||||
|
||||
code := m.Run()
|
||||
|
||||
// Purge local SQLite test artifacts created during this package's tests.
|
||||
// Remove temporary SQLite files after running the tests.
|
||||
fs.PurgeTestDbFiles(".", false)
|
||||
|
||||
os.Exit(code)
|
||||
|
@@ -1,12 +1,17 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/photoprism/get"
|
||||
"github.com/photoprism/photoprism/internal/server/wellknown"
|
||||
"github.com/photoprism/photoprism/pkg/service/http/header"
|
||||
)
|
||||
|
||||
// registerWellknownRoutes adds "/.well-known/" service discovery routes.
|
||||
@@ -20,4 +25,32 @@ func registerWellknownRoutes(router *gin.Engine, conf *config.Config) {
|
||||
router.Any(conf.BaseUri("/.well-known/openid-configuration"), func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, wellknown.NewOpenIDConfiguration(conf))
|
||||
})
|
||||
|
||||
// Registers the "/.well-known/jwks.json" endpoint for cluster JWT verification.
|
||||
router.GET(conf.BaseUri("/.well-known/jwks.json"), func(c *gin.Context) {
|
||||
if !conf.IsPortal() {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
manager := get.JWTManager()
|
||||
if manager == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "jwks unavailable"})
|
||||
return
|
||||
}
|
||||
jwks := manager.JWKS()
|
||||
payload, err := json.Marshal(jwks)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "jwks marshal failed"})
|
||||
return
|
||||
}
|
||||
sum := sha256.Sum256(payload)
|
||||
etag := fmt.Sprintf("\"%x\"", sum[:8])
|
||||
ttl := conf.JWKSCacheTTL()
|
||||
if ttl <= 0 {
|
||||
ttl = 300
|
||||
}
|
||||
c.Header(header.CacheControl, fmt.Sprintf("max-age=%d, public", ttl))
|
||||
c.Header(header.ETag, etag)
|
||||
c.Data(http.StatusOK, header.ContentTypeJson, payload)
|
||||
})
|
||||
}
|
||||
|
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/internal/photoprism/get"
|
||||
"github.com/photoprism/photoprism/internal/server/limiter"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
@@ -29,5 +30,8 @@ func TestMain(m *testing.M) {
|
||||
// Run unit tests.
|
||||
code := m.Run()
|
||||
|
||||
// Remove temporary SQLite files after running the tests.
|
||||
fs.PurgeTestDbFiles(".", false)
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
@@ -2,6 +2,7 @@ package wellknown
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/auth/acl"
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
@@ -37,13 +38,19 @@ type OpenIDConfiguration struct {
|
||||
|
||||
// NewOpenIDConfiguration creates a service discovery endpoint response based on the config provided.
|
||||
func NewOpenIDConfiguration(conf *config.Config) *OpenIDConfiguration {
|
||||
jwksPath := conf.BaseUri("/.well-known/jwks.json")
|
||||
if jwksPath == "" {
|
||||
jwksPath = "/.well-known/jwks.json"
|
||||
}
|
||||
jwksURL := strings.TrimRight(conf.SiteUrl(), "/") + jwksPath
|
||||
|
||||
return &OpenIDConfiguration{
|
||||
Issuer: conf.SiteUrl(),
|
||||
AuthorizationEndpoint: fmt.Sprintf("%sapi/v1/oauth/authorize", conf.SiteUrl()),
|
||||
TokenEndpoint: fmt.Sprintf("%sapi/v1/oauth/token", conf.SiteUrl()),
|
||||
UserinfoEndpoint: fmt.Sprintf("%sapi/v1/oauth/userinfo", conf.SiteUrl()),
|
||||
RegistrationEndpoint: "",
|
||||
JwksUri: "",
|
||||
JwksUri: jwksURL,
|
||||
ResponseTypesSupported: OAuthResponseTypes,
|
||||
ResponseModesSupported: []string{},
|
||||
GrantTypesSupported: OAuthGrantTypes,
|
||||
|
@@ -16,6 +16,7 @@ func TestOpenIDConfiguration(t *testing.T) {
|
||||
assert.IsType(t, &OpenIDConfiguration{}, result)
|
||||
assert.Equal(t, "http://localhost:2342/api/v1/oauth/token", result.TokenEndpoint)
|
||||
assert.Equal(t, "http://localhost:2342/api/v1/oauth/revoke", result.RevocationEndpoint)
|
||||
assert.Equal(t, "http://localhost:2342/.well-known/jwks.json", result.JwksUri)
|
||||
assert.Equal(t, OAuthResponseTypes, result.ResponseTypesSupported)
|
||||
assert.Equal(t, OAuthRevocationEndpointAuthMethods, result.RevocationEndpointAuthMethodsSupported)
|
||||
})
|
||||
|
18
internal/server/wellknown/wellknown_test.go
Normal file
18
internal/server/wellknown/wellknown_test.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package wellknown
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Run unit tests.
|
||||
code := m.Run()
|
||||
|
||||
// Remove temporary SQLite files after running the tests.
|
||||
fs.PurgeTestDbFiles(".", false)
|
||||
|
||||
os.Exit(code)
|
||||
}
|
@@ -1,6 +1,7 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
|
||||
"gopkg.in/yaml.v2"
|
||||
|
||||
clusterjwt "github.com/photoprism/photoprism/internal/auth/jwt"
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/internal/service/cluster"
|
||||
@@ -117,27 +119,27 @@ func registerWithPortal(c *config.Config, portal *url.URL, token string) error {
|
||||
opts.DatabaseDSN == "" && opts.DatabaseName == "" && opts.DatabaseUser == "" && opts.DatabasePassword == "" &&
|
||||
c.DatabasePassword() == ""
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"nodeName": c.NodeName(),
|
||||
"nodeUUID": c.NodeUUID(),
|
||||
"nodeRole": cluster.RoleInstance, // JSON wire format is string
|
||||
"advertiseUrl": c.AdvertiseUrl(),
|
||||
payload := cluster.RegisterRequest{
|
||||
NodeName: c.NodeName(),
|
||||
NodeUUID: c.NodeUUID(),
|
||||
NodeRole: cluster.RoleInstance,
|
||||
AdvertiseUrl: c.AdvertiseUrl(),
|
||||
}
|
||||
// Include client credentials when present so the Portal can verify re-registration
|
||||
// and authorize UUID/name changes.
|
||||
if id, secret := strings.TrimSpace(c.NodeClientID()), strings.TrimSpace(c.NodeClientSecret()); id != "" && secret != "" {
|
||||
payload["clientId"] = id
|
||||
payload["clientSecret"] = secret
|
||||
payload.ClientID = id
|
||||
payload.ClientSecret = secret
|
||||
}
|
||||
|
||||
// Include siteUrl when it differs from advertiseUrl; server will validate/normalize.
|
||||
if su := c.SiteUrl(); su != "" && su != c.AdvertiseUrl() {
|
||||
payload["siteUrl"] = su
|
||||
payload.SiteUrl = su
|
||||
}
|
||||
|
||||
if wantRotateDatabase {
|
||||
// Align with API: request database rotation/creation on (re)register.
|
||||
payload["rotateDatabase"] = true
|
||||
payload.RotateDatabase = true
|
||||
}
|
||||
|
||||
bodyBytes, _ := json.Marshal(payload)
|
||||
@@ -171,6 +173,7 @@ func registerWithPortal(c *config.Config, portal *url.URL, token string) error {
|
||||
if err := persistRegistration(c, &r, wantRotateDatabase); err != nil {
|
||||
return err
|
||||
}
|
||||
primeJWKS(c, r.JWKSUrl)
|
||||
if resp.StatusCode == http.StatusCreated {
|
||||
log.Infof("cluster: registered as %s (%d)", clean.LogQuote(r.Node.Name), resp.StatusCode)
|
||||
} else {
|
||||
@@ -226,8 +229,13 @@ func persistRegistration(c *config.Config, r *cluster.RegisterResponse, wantRota
|
||||
updates["NodeClientSecret"] = r.Secrets.ClientSecret
|
||||
}
|
||||
|
||||
if url := strings.TrimSpace(r.JWKSUrl); url != "" {
|
||||
updates["JWKSUrl"] = url
|
||||
c.SetJWKSUrl(url)
|
||||
}
|
||||
|
||||
// Persist NodeUUID from portal response if provided and not set locally.
|
||||
if r.Node.UUID != "" && c.Options().NodeUUID == "" {
|
||||
if r.Node.UUID != "" && c.NodeUUID() == "" {
|
||||
updates["NodeUUID"] = r.Node.UUID
|
||||
}
|
||||
|
||||
@@ -267,6 +275,22 @@ func persistRegistration(c *config.Config, r *cluster.RegisterResponse, wantRota
|
||||
return nil
|
||||
}
|
||||
|
||||
func primeJWKS(c *config.Config, url string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
url = strings.TrimSpace(url)
|
||||
if url == "" {
|
||||
return
|
||||
}
|
||||
verifier := clusterjwt.NewVerifier(c)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := verifier.Prime(ctx, url); err != nil {
|
||||
log.Debugf("cluster: jwks prime skipped (%s)", clean.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func hasDBUpdate(m map[string]interface{}) bool {
|
||||
if _, ok := m["DatabaseDSN"]; ok {
|
||||
return true
|
||||
|
@@ -29,6 +29,7 @@ func TestInitConfig_NoPortal_NoOp(t *testing.T) {
|
||||
func TestRegister_PersistSecretAndDB(t *testing.T) {
|
||||
t.Setenv("PHOTOPRISM_STORAGE_PATH", t.TempDir())
|
||||
// Fake Portal server.
|
||||
var jwksURL string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/cluster/nodes/register":
|
||||
@@ -39,6 +40,7 @@ func TestRegister_PersistSecretAndDB(t *testing.T) {
|
||||
Node: cluster.Node{Name: "pp-node-01"},
|
||||
UUID: rnd.UUID(),
|
||||
Secrets: &cluster.RegisterSecrets{ClientSecret: "SECRET"},
|
||||
JWKSUrl: jwksURL,
|
||||
Database: cluster.RegisterDatabase{
|
||||
Driver: config.MySQL,
|
||||
Host: "db.local",
|
||||
@@ -57,6 +59,7 @@ func TestRegister_PersistSecretAndDB(t *testing.T) {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
jwksURL = srv.URL + "/.well-known/jwks.json"
|
||||
defer srv.Close()
|
||||
|
||||
c := config.NewTestConfig("bootstrap-reg")
|
||||
@@ -78,6 +81,7 @@ func TestRegister_PersistSecretAndDB(t *testing.T) {
|
||||
// DSN branch should be preferred and persisted.
|
||||
assert.Contains(t, c.Options().DatabaseDSN, "@tcp(db.local:3306)/pp_db")
|
||||
assert.Equal(t, config.MySQL, c.Options().DatabaseDriver)
|
||||
assert.Equal(t, srv.URL+"/.well-known/jwks.json", c.JWKSUrl())
|
||||
}
|
||||
|
||||
func TestThemeInstall_Missing(t *testing.T) {
|
||||
@@ -90,12 +94,13 @@ func TestThemeInstall_Missing(t *testing.T) {
|
||||
_ = zw.Close()
|
||||
|
||||
// Fake Portal server (register -> oauth token -> theme)
|
||||
var jwksURL2 string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/cluster/nodes/register":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// Return NodeClientID + NodeClientSecret so bootstrap can request OAuth token
|
||||
_ = json.NewEncoder(w).Encode(cluster.RegisterResponse{UUID: rnd.UUID(), Node: cluster.Node{ClientID: "cs5gfen1bgxz7s9i", Name: "pp-node-01"}, Secrets: &cluster.RegisterSecrets{ClientSecret: "s3cr3t"}})
|
||||
_ = json.NewEncoder(w).Encode(cluster.RegisterResponse{UUID: rnd.UUID(), Node: cluster.Node{ClientID: "cs5gfen1bgxz7s9i", Name: "pp-node-01"}, Secrets: &cluster.RegisterSecrets{ClientSecret: "s3cr3t"}, JWKSUrl: jwksURL2})
|
||||
case "/api/v1/oauth/token":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "tok", "token_type": "Bearer"})
|
||||
@@ -107,6 +112,7 @@ func TestThemeInstall_Missing(t *testing.T) {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
jwksURL2 = srv.URL + "/.well-known/jwks.json"
|
||||
defer srv.Close()
|
||||
|
||||
c := config.NewTestConfig("bootstrap-theme")
|
||||
@@ -133,6 +139,7 @@ func TestThemeInstall_Missing(t *testing.T) {
|
||||
func TestRegister_SQLite_NoDBPersist(t *testing.T) {
|
||||
t.Setenv("PHOTOPRISM_STORAGE_PATH", t.TempDir())
|
||||
// Portal responds with DB DSN, but local driver is SQLite → must not persist DB.
|
||||
var jwksURL3 string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/cluster/nodes/register":
|
||||
@@ -141,6 +148,7 @@ func TestRegister_SQLite_NoDBPersist(t *testing.T) {
|
||||
resp := cluster.RegisterResponse{
|
||||
Node: cluster.Node{Name: "pp-node-01"},
|
||||
Secrets: &cluster.RegisterSecrets{ClientSecret: "SECRET"},
|
||||
JWKSUrl: jwksURL3,
|
||||
Database: cluster.RegisterDatabase{Host: "db.local", Port: 3306, Name: "pp_db", User: "pp_user", Password: "pp_pw", DSN: "pp_user:pp_pw@tcp(db.local:3306)/pp_db?charset=utf8mb4&parseTime=true"},
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
@@ -148,6 +156,7 @@ func TestRegister_SQLite_NoDBPersist(t *testing.T) {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
jwksURL3 = srv.URL + "/.well-known/jwks.json"
|
||||
defer srv.Close()
|
||||
|
||||
c := config.NewTestConfig("bootstrap-sqlite")
|
||||
@@ -165,6 +174,7 @@ func TestRegister_SQLite_NoDBPersist(t *testing.T) {
|
||||
assert.Equal(t, "SECRET", c.NodeClientSecret())
|
||||
assert.Equal(t, config.SQLite3, c.DatabaseDriver())
|
||||
assert.Equal(t, origDSN, c.Options().DatabaseDSN)
|
||||
assert.Equal(t, srv.URL+"/.well-known/jwks.json", c.JWKSUrl())
|
||||
}
|
||||
|
||||
func TestRegister_404_NoRetry(t *testing.T) {
|
||||
|
@@ -9,7 +9,11 @@ import (
|
||||
|
||||
// TestMain ensures SQLite test DB artifacts are purged after the suite runs.
|
||||
func TestMain(m *testing.M) {
|
||||
// Run unit tests.
|
||||
code := m.Run()
|
||||
|
||||
// Remove temporary SQLite files after running the tests.
|
||||
fs.PurgeTestDbFiles(".", false)
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
@@ -29,21 +29,22 @@ type Credentials struct {
|
||||
func GetCredentials(ctx context.Context, conf *config.Config, nodeUUID, nodeName string, rotate bool) (Credentials, bool, error) {
|
||||
out := Credentials{}
|
||||
|
||||
// Normalize provisioner driver to lower-case to accept variants like "MySQL"/"MariaDB".
|
||||
DatabaseDriver = strings.ToLower(DatabaseDriver)
|
||||
// Normalize the configured admin driver locally so we accept variants like "MySQL"/"MariaDB"
|
||||
// without mutating the global setting (keeps config reporting consistent).
|
||||
driver := strings.ToLower(DatabaseDriver)
|
||||
|
||||
switch DatabaseDriver {
|
||||
switch driver {
|
||||
case config.MySQL, config.MariaDB:
|
||||
// ok
|
||||
case config.SQLite3, config.Postgres:
|
||||
return out, false, errors.New("database must be MySQL/MariaDB for auto-provisioning")
|
||||
default:
|
||||
// Driver is configured externally for the provisioner (decoupled from app config).
|
||||
return out, false, fmt.Errorf("unsupported auto-provisioning database driver: %s", DatabaseDriver)
|
||||
return out, false, fmt.Errorf("unsupported auto-provisioning database driver: %s", driver)
|
||||
}
|
||||
|
||||
// Compute deterministic names and a candidate password.
|
||||
dbName, dbUser, dbPass := GenerateCreds(conf, nodeUUID, nodeName)
|
||||
dbName, dbUser, dbPass := GenerateCredentials(conf, nodeUUID, nodeName)
|
||||
|
||||
// Extra safety: enforce allowed identifier charset.
|
||||
if !identRe.MatchString(dbName) || !identRe.MatchString(dbUser) {
|
||||
@@ -122,10 +123,10 @@ func GetCredentials(ctx context.Context, conf *config.Config, nodeUUID, nodeName
|
||||
out.Port = DatabasePort
|
||||
out.Name = dbName
|
||||
out.User = dbUser
|
||||
out.Driver = DatabaseDriver
|
||||
out.Driver = driver
|
||||
|
||||
if out.Password != "" {
|
||||
out.DSN = BuildDSN(DatabaseDriver, out.Host, out.Port, out.User, out.Password, out.Name)
|
||||
out.DSN = BuildDSN(driver, out.Host, out.Port, out.User, out.Password, out.Name)
|
||||
}
|
||||
|
||||
return out, created, nil
|
||||
|
@@ -103,6 +103,7 @@ func TestGetCredentials_DriverNormalization(t *testing.T) {
|
||||
DatabaseDriver = "PostGreS"
|
||||
_, _, err := GetCredentials(ctx, c, "11111111-1111-4111-8111-111111111111", "pp-node", false)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "PostGreS", DatabaseDriver)
|
||||
|
||||
// Unknown driver should return the unsupported error including normalized name.
|
||||
DatabaseDriver = "TiDB"
|
||||
@@ -110,4 +111,5 @@ func TestGetCredentials_DriverNormalization(t *testing.T) {
|
||||
if assert.Error(t, err) {
|
||||
assert.Contains(t, err.Error(), "unsupported auto-provisioning database driver: tidb")
|
||||
}
|
||||
assert.Equal(t, "TiDB", DatabaseDriver)
|
||||
}
|
||||
|
@@ -13,11 +13,17 @@ import (
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// ProvisionDSN specifies database auto-provisioning DSN, for example:
|
||||
// ProvisionDSN specifies the admin DSN used for auto-provisioning, for example:
|
||||
// root:insecure@tcp(127.0.0.1:3306)/photoprism?charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true
|
||||
var ProvisionDSN = "root:photoprism@tcp(mariadb:4001)/photoprism?charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true"
|
||||
|
||||
// DatabaseHost is the hostname of the admin server used for provisioning operations.
|
||||
var DatabaseHost = "mariadb"
|
||||
|
||||
// DatabasePort is the port of the admin server used for provisioning operations.
|
||||
var DatabasePort = 4001
|
||||
|
||||
// DatabaseDriver indicates the SQL driver used for provisioning (independent from the app DB driver).
|
||||
var DatabaseDriver = "mysql"
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
@@ -24,10 +24,10 @@ const (
|
||||
dbMax = 64
|
||||
)
|
||||
|
||||
// GenerateCreds computes deterministic database name and user for a node under the given portal
|
||||
// GenerateCredentials computes deterministic database name and user for a node under the given portal
|
||||
// plus a random password. Naming is stable for a given (clusterUUID, nodeUUID) pair and changes
|
||||
// if the cluster UUID or node UUID changes.
|
||||
func GenerateCreds(conf *config.Config, nodeUUID, nodeName string) (dbName, dbUser, dbPass string) {
|
||||
func GenerateCredentials(conf *config.Config, nodeUUID, nodeName string) (dbName, dbUser, dbPass string) {
|
||||
clusterUUID := conf.ClusterUUID()
|
||||
|
||||
// Compute base32 (no padding) HMAC suffixes scoped by cluster UUID and node UUID.
|
||||
@@ -59,6 +59,9 @@ func BuildDSN(driver, host string, port int, user, pass, name string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// hmacBase32 returns a lowercase Base32 (no padding) encoded HMAC-SHA256 digest
|
||||
// derived from the provided key and data. It is used to generate deterministic
|
||||
// suffixes for database identifiers while keeping the resulting string URL/identifier safe.
|
||||
func hmacBase32(key, data string) string {
|
||||
mac := hmac.New(sha256.New, []byte(key))
|
||||
_, _ = mac.Write([]byte(data))
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package provisioner
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -8,13 +9,13 @@ import (
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
)
|
||||
|
||||
func TestGenerateCreds_StabilityAndBudgets(t *testing.T) {
|
||||
func TestGenerateCredentials_StabilityAndBudgets(t *testing.T) {
|
||||
c := config.NewConfig(config.CliTestContext())
|
||||
// Fix the cluster UUID via options to ensure determinism.
|
||||
c.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111"
|
||||
|
||||
db1, user1, pass1 := GenerateCreds(c, "11111111-1111-4111-8111-111111111111", "pp-node-01")
|
||||
db2, user2, pass2 := GenerateCreds(c, "11111111-1111-4111-8111-111111111111", "pp-node-01")
|
||||
db1, user1, pass1 := GenerateCredentials(c, "11111111-1111-4111-8111-111111111111", "pp-node-01")
|
||||
db2, user2, pass2 := GenerateCredentials(c, "11111111-1111-4111-8111-111111111111", "pp-node-01")
|
||||
|
||||
// Names stable; password random.
|
||||
assert.Equal(t, db1, db2)
|
||||
@@ -28,24 +29,24 @@ func TestGenerateCreds_StabilityAndBudgets(t *testing.T) {
|
||||
assert.Contains(t, user1, "photoprism_")
|
||||
}
|
||||
|
||||
func TestGenerateCreds_DifferentPortal(t *testing.T) {
|
||||
func TestGenerateCredentials_DifferentPortal(t *testing.T) {
|
||||
c1 := config.NewConfig(config.CliTestContext())
|
||||
c2 := config.NewConfig(config.CliTestContext())
|
||||
c1.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111"
|
||||
c2.Options().ClusterUUID = "22222222-2222-4222-8222-222222222222"
|
||||
|
||||
db1, user1, _ := GenerateCreds(c1, "11111111-1111-4111-8111-111111111111", "pp-node-01")
|
||||
db2, user2, _ := GenerateCreds(c2, "11111111-1111-4111-1111-111111111111", "pp-node-01")
|
||||
db1, user1, _ := GenerateCredentials(c1, "11111111-1111-4111-8111-111111111111", "pp-node-01")
|
||||
db2, user2, _ := GenerateCredentials(c2, "11111111-1111-4111-1111-111111111111", "pp-node-01")
|
||||
|
||||
assert.NotEqual(t, db1, db2)
|
||||
assert.NotEqual(t, user1, user2)
|
||||
}
|
||||
|
||||
func TestGenerateCreds_Truncation(t *testing.T) {
|
||||
func TestGenerateCredentials_Truncation(t *testing.T) {
|
||||
c := config.NewConfig(config.CliTestContext())
|
||||
c.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111"
|
||||
longName := "this-is-a-very-very-long-node-name-that-should-be-truncated-to-fit-username-and-db-budgets"
|
||||
db, user, _ := GenerateCreds(c, "11111111-1111-4111-8111-111111111111", longName)
|
||||
db, user, _ := GenerateCredentials(c, "11111111-1111-4111-8111-111111111111", longName)
|
||||
|
||||
assert.LessOrEqual(t, len(user), 32)
|
||||
assert.LessOrEqual(t, len(db), 64)
|
||||
@@ -58,7 +59,21 @@ func TestBuildDSN(t *testing.T) {
|
||||
assert.Contains(t, dsn, "parseTime=true")
|
||||
}
|
||||
|
||||
func TestEnsureNodeDatabase_SqliteRejected(t *testing.T) {
|
||||
func TestHmacBase32_LowercaseDeterministic(t *testing.T) {
|
||||
a := hmacBase32("k1", "data")
|
||||
b := hmacBase32("k1", "data")
|
||||
c := hmacBase32("k1", "other")
|
||||
|
||||
assert.Equal(t, a, b, "same key/data should produce identical digest")
|
||||
assert.NotEqual(t, a, c, "different data should change the digest")
|
||||
assert.NotZero(t, len(a))
|
||||
assert.Equal(t, strings.ToLower(a), a, "digest must be lowercase")
|
||||
for _, ch := range a {
|
||||
assert.Contains(t, "abcdefghijklmnopqrstuvwxyz234567", string(ch))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCredentials_SqliteRejected(t *testing.T) {
|
||||
c := config.NewConfig(config.CliTestContext())
|
||||
// Ensure we're on SQLite in tests.
|
||||
if c.DatabaseDriver() != config.SQLite3 {
|
||||
|
@@ -26,7 +26,8 @@ func toNode(c *entity.Client) *Node {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
n := &Node{
|
||||
n := &Node{}
|
||||
n.Node = cluster.Node{
|
||||
UUID: c.NodeUUID,
|
||||
Name: c.ClientName,
|
||||
Role: c.ClientRole,
|
||||
@@ -43,10 +44,11 @@ func toNode(c *entity.Client) *Node {
|
||||
}
|
||||
n.SiteUrl = data.SiteURL
|
||||
if db := data.Database; db != nil {
|
||||
n.Database.Name = db.Name
|
||||
n.Database.User = db.User
|
||||
n.Database.Driver = db.Driver
|
||||
n.Database.RotatedAt = db.RotatedAt
|
||||
dest := n.ensureDatabase()
|
||||
dest.Name = db.Name
|
||||
dest.User = db.User
|
||||
dest.Driver = db.Driver
|
||||
dest.RotatedAt = db.RotatedAt
|
||||
}
|
||||
n.RotatedAt = data.RotatedAt
|
||||
}
|
||||
@@ -126,14 +128,14 @@ func (r *ClientRegistry) Put(n *Node) error {
|
||||
m.NodeUUID = n.UUID
|
||||
}
|
||||
data.RotatedAt = n.RotatedAt
|
||||
if n.Database.Name != "" || n.Database.User != "" || n.Database.RotatedAt != "" {
|
||||
if db := n.Database; db != nil && (db.Name != "" || db.User != "" || db.RotatedAt != "") {
|
||||
if data.Database == nil {
|
||||
data.Database = &entity.ClientDatabase{}
|
||||
}
|
||||
data.Database.Name = n.Database.Name
|
||||
data.Database.User = n.Database.User
|
||||
data.Database.Driver = n.Database.Driver
|
||||
data.Database.RotatedAt = n.Database.RotatedAt
|
||||
data.Database.Name = db.Name
|
||||
data.Database.User = db.User
|
||||
data.Database.Driver = db.Driver
|
||||
data.Database.RotatedAt = db.RotatedAt
|
||||
}
|
||||
m.SetData(data)
|
||||
|
||||
@@ -165,9 +167,10 @@ func (r *ClientRegistry) Put(n *Node) error {
|
||||
}
|
||||
n.SiteUrl = data.SiteURL
|
||||
if db := data.Database; db != nil {
|
||||
n.Database.Name = db.Name
|
||||
n.Database.User = db.User
|
||||
n.Database.RotatedAt = db.RotatedAt
|
||||
dest := n.ensureDatabase()
|
||||
dest.Name = db.Name
|
||||
dest.User = db.User
|
||||
dest.RotatedAt = db.RotatedAt
|
||||
}
|
||||
n.RotatedAt = data.RotatedAt
|
||||
}
|
||||
|
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
cfg "github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/service/cluster"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
@@ -44,7 +45,7 @@ func TestClientRegistry_RoleChange(t *testing.T) {
|
||||
assert.NoError(t, c.Init())
|
||||
|
||||
r, _ := NewClientRegistryWithConfig(c)
|
||||
n := &Node{Name: "pp-role", Role: "service"}
|
||||
n := &Node{Node: cluster.Node{Name: "pp-role", Role: "service"}}
|
||||
assert.NoError(t, r.Put(n))
|
||||
got, err := r.FindByName("pp-role")
|
||||
assert.NoError(t, err)
|
||||
@@ -52,7 +53,7 @@ func TestClientRegistry_RoleChange(t *testing.T) {
|
||||
assert.Equal(t, "service", got.Role)
|
||||
}
|
||||
// Change to instance
|
||||
upd := &Node{ClientID: got.ClientID, Name: got.Name, Role: "instance"}
|
||||
upd := &Node{Node: cluster.Node{ClientID: got.ClientID, Name: got.Name, Role: "instance"}}
|
||||
assert.NoError(t, r.Put(upd))
|
||||
got2, err := r.FindByName("pp-role")
|
||||
assert.NoError(t, err)
|
||||
|
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
cfg "github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/service/cluster"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
@@ -23,16 +24,19 @@ func TestClientRegistry_PutFindListRotate(t *testing.T) {
|
||||
|
||||
// Create new node
|
||||
n := &Node{
|
||||
UUID: rnd.UUIDv7(),
|
||||
Name: "pp-node-a",
|
||||
Role: "instance",
|
||||
SiteUrl: "https://photos.example.com",
|
||||
AdvertiseUrl: "http://pp-node-a:2342",
|
||||
Labels: map[string]string{"env": "test"},
|
||||
Node: cluster.Node{
|
||||
UUID: rnd.UUIDv7(),
|
||||
Name: "pp-node-a",
|
||||
Role: "instance",
|
||||
SiteUrl: "https://photos.example.com",
|
||||
AdvertiseUrl: "http://pp-node-a:2342",
|
||||
Labels: map[string]string{"env": "test"},
|
||||
},
|
||||
}
|
||||
n.Database.Name = "pp_db"
|
||||
n.Database.User = "pp_user"
|
||||
n.Database.RotatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
db := n.ensureDatabase()
|
||||
db.Name = "pp_db"
|
||||
db.User = "pp_user"
|
||||
db.RotatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
n.RotatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
n.ClientSecret = rnd.ClientSecret()
|
||||
|
||||
@@ -49,8 +53,10 @@ func TestClientRegistry_PutFindListRotate(t *testing.T) {
|
||||
assert.Equal(t, "instance", got.Role)
|
||||
assert.Equal(t, "http://pp-node-a:2342", got.AdvertiseUrl)
|
||||
assert.Equal(t, "https://photos.example.com", got.SiteUrl)
|
||||
assert.Equal(t, "pp_db", got.Database.Name)
|
||||
assert.Equal(t, "pp_user", got.Database.User)
|
||||
if assert.NotNil(t, got.Database) {
|
||||
assert.Equal(t, "pp_db", got.Database.Name)
|
||||
assert.Equal(t, "pp_user", got.Database.User)
|
||||
}
|
||||
assert.NotEmpty(t, got.CreatedAt)
|
||||
assert.NotEmpty(t, got.UpdatedAt)
|
||||
// Secret is not persisted in plaintext
|
||||
@@ -88,7 +94,7 @@ func TestClientRegistry_PutFindListRotate(t *testing.T) {
|
||||
}
|
||||
|
||||
// Update labels and site URL via Put (upsert by id)
|
||||
upd := &Node{ClientID: got.ClientID, Name: got.Name, Labels: map[string]string{"env": "prod"}, SiteUrl: "https://photos.example.org"}
|
||||
upd := &Node{Node: cluster.Node{ClientID: got.ClientID, Name: got.Name, Labels: map[string]string{"env": "prod"}, SiteUrl: "https://photos.example.org"}}
|
||||
assert.NoError(t, r.Put(upd))
|
||||
got2, err := r.FindByName("pp-node-a")
|
||||
assert.NoError(t, err)
|
||||
|
@@ -1,23 +1,20 @@
|
||||
package registry
|
||||
|
||||
import "github.com/photoprism/photoprism/internal/service/cluster"
|
||||
|
||||
// Node represents a registered cluster node (transport DTO inside registry package).
|
||||
// It is used by both client-backed and (legacy) file-backed registries.
|
||||
// It embeds the public cluster.Node DTO so we have a single source of truth for fields.
|
||||
// Additional internal-only metadata is stored alongside the embedded struct.
|
||||
type Node struct {
|
||||
UUID string `json:"uuid"` // primary identifier (UUID v7)
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
ClientID string `json:"clientId,omitempty"` // OAuth client identifier (legacy)
|
||||
ClientSecret string `json:"-"` // plaintext only when newly created/rotated in-memory
|
||||
SiteUrl string `json:"siteUrl,omitempty"`
|
||||
AdvertiseUrl string `json:"advertiseUrl,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
RotatedAt string `json:"rotatedAt,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Database struct {
|
||||
Name string `json:"name"`
|
||||
User string `json:"user"`
|
||||
Driver string `json:"driver,omitempty"`
|
||||
RotatedAt string `json:"rotatedAt,omitempty"`
|
||||
} `json:"database,omitempty"`
|
||||
cluster.Node
|
||||
ClientSecret string `json:"-"` // plaintext only when newly created/rotated in-memory
|
||||
RotatedAt string `json:"rotatedAt,omitempty"` // secret rotation timestamp
|
||||
}
|
||||
|
||||
// ensureDatabase returns a writable NodeDatabase, creating one if missing.
|
||||
func (n *Node) ensureDatabase() *cluster.NodeDatabase {
|
||||
if n.Node.Database == nil {
|
||||
n.Node.Database = &cluster.NodeDatabase{}
|
||||
}
|
||||
return n.Node.Database
|
||||
}
|
||||
|
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
cfg "github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/service/cluster"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
@@ -20,13 +21,13 @@ func TestClientRegistry_ClientIDReuse_CannotHijackExistingUUID(t *testing.T) {
|
||||
|
||||
r, _ := NewClientRegistryWithConfig(c)
|
||||
// Seed two independent nodes
|
||||
a := &Node{UUID: rnd.UUIDv7(), Name: "pp-a", Role: "instance"}
|
||||
b := &Node{UUID: rnd.UUIDv7(), Name: "pp-b", Role: "service"}
|
||||
a := &Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-a", Role: "instance"}}
|
||||
b := &Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-b", Role: "service"}}
|
||||
assert.NoError(t, r.Put(a))
|
||||
assert.NoError(t, r.Put(b))
|
||||
|
||||
// Attempt to update UUID=b while passing ClientID of a
|
||||
assert.NoError(t, r.Put(&Node{UUID: b.UUID, ClientID: a.ClientID, Role: "service"}))
|
||||
assert.NoError(t, r.Put(&Node{Node: cluster.Node{UUID: b.UUID, ClientID: a.ClientID, Role: "service"}}))
|
||||
|
||||
// a stays attached to its original UUID and ClientID
|
||||
gotA, err := r.FindByNodeUUID(a.UUID)
|
||||
@@ -56,12 +57,12 @@ func TestClientRegistry_ClientIDReuse_ChangesUUIDWhenTargetMissing(t *testing.T)
|
||||
|
||||
r, _ := NewClientRegistryWithConfig(c)
|
||||
// Seed one node
|
||||
a := &Node{UUID: rnd.UUIDv7(), Name: "pp-x", Role: "instance"}
|
||||
a := &Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-x", Role: "instance"}}
|
||||
assert.NoError(t, r.Put(a))
|
||||
|
||||
// Move the row to a new UUID by referencing the same ClientID and a new UUID
|
||||
newUUID := rnd.UUIDv7()
|
||||
assert.NoError(t, r.Put(&Node{UUID: newUUID, ClientID: a.ClientID}))
|
||||
assert.NoError(t, r.Put(&Node{Node: cluster.Node{UUID: newUUID, ClientID: a.ClientID}}))
|
||||
|
||||
// Old UUID no longer resolves
|
||||
_, err := r.FindByNodeUUID(a.UUID)
|
||||
|
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
cfg "github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/service/cluster"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
@@ -18,7 +19,7 @@ func TestClientRegistry_FindByClientID(t *testing.T) {
|
||||
assert.NoError(t, c.Init())
|
||||
|
||||
r, _ := NewClientRegistryWithConfig(c)
|
||||
n := &Node{Name: "pp-find-client", Role: "instance", UUID: rnd.UUIDv7()}
|
||||
n := &Node{Node: cluster.Node{Name: "pp-find-client", Role: "instance", UUID: rnd.UUIDv7()}}
|
||||
assert.NoError(t, r.Put(n))
|
||||
|
||||
got, err := r.FindByClientID(n.ClientID)
|
||||
@@ -75,15 +76,15 @@ func TestClientRegistry_SwapNames_UUIDAuthoritative(t *testing.T) {
|
||||
assert.NoError(t, c.Init())
|
||||
|
||||
r, _ := NewClientRegistryWithConfig(c)
|
||||
a := &Node{UUID: rnd.UUIDv7(), Name: "pp-a", Role: "instance"}
|
||||
b := &Node{UUID: rnd.UUIDv7(), Name: "pp-b", Role: "service"}
|
||||
a := &Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-a", Role: "instance"}}
|
||||
b := &Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-b", Role: "service"}}
|
||||
assert.NoError(t, r.Put(a))
|
||||
assert.NoError(t, r.Put(b))
|
||||
|
||||
// Swap names via UUID-targeted updates
|
||||
assert.NoError(t, r.Put(&Node{UUID: a.UUID, Name: "pp-b"}))
|
||||
assert.NoError(t, r.Put(&Node{Node: cluster.Node{UUID: a.UUID, Name: "pp-b"}}))
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
assert.NoError(t, r.Put(&Node{UUID: b.UUID, Name: "pp-a"}))
|
||||
assert.NoError(t, r.Put(&Node{Node: cluster.Node{UUID: b.UUID, Name: "pp-a"}}))
|
||||
|
||||
// UUID lookups map to the correct updated names
|
||||
gotA, err := r.FindByNodeUUID(a.UUID)
|
||||
@@ -121,11 +122,12 @@ func TestClientRegistry_DBDriverAndFields(t *testing.T) {
|
||||
assert.NoError(t, c.Init())
|
||||
|
||||
r, _ := NewClientRegistryWithConfig(c)
|
||||
n := &Node{UUID: rnd.UUIDv7(), Name: "pp-db", Role: "instance"}
|
||||
n.Database.Name = "photoprism_d123"
|
||||
n.Database.User = "photoprism_u123"
|
||||
n.Database.Driver = "mysql"
|
||||
n.Database.RotatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
n := &Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-db", Role: "instance"}}
|
||||
db := n.ensureDatabase()
|
||||
db.Name = "photoprism_d123"
|
||||
db.User = "photoprism_u123"
|
||||
db.Driver = "mysql"
|
||||
db.RotatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
assert.NoError(t, r.Put(n))
|
||||
|
||||
got, err := r.FindByNodeUUID(n.UUID)
|
||||
|
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
cfg "github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/service/cluster"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
@@ -21,10 +22,10 @@ func TestClientRegistry_RotateSecretByUUID_LatestRow(t *testing.T) {
|
||||
uuid := rnd.UUIDv7()
|
||||
|
||||
// Create two entries for same NodeUUID; c2 will be latest
|
||||
n1 := &Node{UUID: uuid, Name: "pp-rot-a", Role: "instance"}
|
||||
n1 := &Node{Node: cluster.Node{UUID: uuid, Name: "pp-rot-a", Role: "instance"}}
|
||||
assert.NoError(t, r.Put(n1))
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
n2 := &Node{UUID: uuid, Name: "pp-rot-b", Role: "instance"}
|
||||
n2 := &Node{Node: cluster.Node{UUID: uuid, Name: "pp-rot-b", Role: "instance"}}
|
||||
assert.NoError(t, r.Put(n2))
|
||||
|
||||
// Rotate by UUID
|
||||
|
@@ -9,14 +9,19 @@ import (
|
||||
|
||||
cfg "github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/service/cluster"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
// TestMain ensures SQLite test DB artifacts are purged after the suite runs.
|
||||
func TestMain(m *testing.M) {
|
||||
// Run unit tests.
|
||||
code := m.Run()
|
||||
|
||||
// Remove temporary SQLite files after running the tests.
|
||||
fs.PurgeTestDbFiles(".", false)
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
@@ -33,7 +38,7 @@ func TestClientRegistry_GetAndDelete(t *testing.T) {
|
||||
}
|
||||
|
||||
// Create node
|
||||
n := &Node{Name: "pp-del", Role: "instance", UUID: rnd.UUIDv7()}
|
||||
n := &Node{Node: cluster.Node{Name: "pp-del", Role: "instance", UUID: rnd.UUIDv7()}}
|
||||
assert.NoError(t, r.Put(n))
|
||||
assert.NotEmpty(t, n.ClientID)
|
||||
assert.True(t, rnd.IsUID(n.ClientID, entity.ClientUID))
|
||||
@@ -69,8 +74,8 @@ func TestClientRegistry_ListOrderByUpdatedAtDesc(t *testing.T) {
|
||||
|
||||
r, _ := NewClientRegistryWithConfig(c)
|
||||
|
||||
a := &Node{Name: "pp-a", Role: "instance", UUID: rnd.UUIDv7()}
|
||||
b := &Node{Name: "pp-b", Role: "service", UUID: rnd.UUIDv7()}
|
||||
a := &Node{Node: cluster.Node{Name: "pp-a", Role: "instance", UUID: rnd.UUIDv7()}}
|
||||
b := &Node{Node: cluster.Node{Name: "pp-b", Role: "service", UUID: rnd.UUIDv7()}}
|
||||
assert.NoError(t, r.Put(a))
|
||||
// Ensure distinct UpdatedAt values (DBs often have second precision)
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
@@ -78,7 +83,7 @@ func TestClientRegistry_ListOrderByUpdatedAtDesc(t *testing.T) {
|
||||
|
||||
// Update a to make it most recent
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
assert.NoError(t, r.Put(&Node{ClientID: a.ClientID, Name: a.Name}))
|
||||
assert.NoError(t, r.Put(&Node{Node: cluster.Node{ClientID: a.ClientID, Name: a.Name}}))
|
||||
|
||||
list, err := r.List()
|
||||
assert.NoError(t, err)
|
||||
@@ -94,18 +99,21 @@ func TestClientRegistry_ListOrderByUpdatedAtDesc(t *testing.T) {
|
||||
func TestResponseBuilders_RedactionAndOpts(t *testing.T) {
|
||||
// Base node with all fields
|
||||
n := Node{
|
||||
ClientID: "cs5gfen1bgxz7s9i",
|
||||
Name: "pp-node",
|
||||
Role: "instance",
|
||||
SiteUrl: "https://photos.example.com",
|
||||
AdvertiseUrl: "http://node:2342",
|
||||
Labels: map[string]string{"env": "prod"},
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Node: cluster.Node{
|
||||
ClientID: "cs5gfen1bgxz7s9i",
|
||||
Name: "pp-node",
|
||||
Role: "instance",
|
||||
SiteUrl: "https://photos.example.com",
|
||||
AdvertiseUrl: "http://node:2342",
|
||||
Labels: map[string]string{"env": "prod"},
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
n.Database.Name = "dbn"
|
||||
n.Database.User = "dbu"
|
||||
n.Database.RotatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
dbInfo := n.ensureDatabase()
|
||||
dbInfo.Name = "dbn"
|
||||
dbInfo.User = "dbu"
|
||||
dbInfo.RotatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
// Non-admin (default opts): redact advertise/database
|
||||
out := BuildClusterNode(n, NodeOpts{})
|
||||
@@ -190,7 +198,7 @@ func TestClientRegistry_GetClusterNodeByUUID(t *testing.T) {
|
||||
r, _ := NewClientRegistryWithConfig(c)
|
||||
// Insert a node with NodeUUID
|
||||
nu := rnd.UUIDv7()
|
||||
n := &Node{Name: "pp-getuuid", Role: "instance", UUID: nu}
|
||||
n := &Node{Node: cluster.Node{Name: "pp-getuuid", Role: "instance", UUID: nu}}
|
||||
assert.NoError(t, r.Put(n))
|
||||
|
||||
// Fetch DTO by NodeUUID
|
||||
@@ -208,7 +216,7 @@ func TestClientRegistry_FindByName_NormalizesDNSLabel(t *testing.T) {
|
||||
|
||||
r, _ := NewClientRegistryWithConfig(c)
|
||||
// Create canonical node name
|
||||
n := &Node{Name: "my-node-prod", Role: "instance"}
|
||||
n := &Node{Node: cluster.Node{Name: "my-node-prod", Role: "instance"}}
|
||||
assert.NoError(t, r.Put(n))
|
||||
// Lookup using mixed separators and case
|
||||
got, err := r.FindByName("My.Node/Prod")
|
||||
|
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
cfg "github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/service/cluster"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
@@ -21,14 +22,14 @@ func TestClientRegistry_PutUpdateByUUID(t *testing.T) {
|
||||
uuid := rnd.UUIDv7()
|
||||
|
||||
// Create via UUID
|
||||
n := &Node{UUID: uuid, Name: "pp-uuid", Role: "instance", Labels: map[string]string{"a": "1"}}
|
||||
n := &Node{Node: cluster.Node{UUID: uuid, Name: "pp-uuid", Role: "instance", Labels: map[string]string{"a": "1"}}}
|
||||
assert.NoError(t, r.Put(n))
|
||||
assert.NotEmpty(t, n.ClientID)
|
||||
assert.True(t, rnd.IsUUID(n.UUID))
|
||||
assert.True(t, rnd.IsUID(n.ClientID, entity.ClientUID))
|
||||
|
||||
// Update same record by UUID only; change name and labels
|
||||
upd := &Node{UUID: uuid, Name: "pp-uuid-new", Labels: map[string]string{"a": "2", "b": "x"}}
|
||||
upd := &Node{Node: cluster.Node{UUID: uuid, Name: "pp-uuid-new", Labels: map[string]string{"a": "2", "b": "x"}}}
|
||||
assert.NoError(t, r.Put(upd))
|
||||
|
||||
got, err := r.FindByNodeUUID(uuid)
|
||||
@@ -127,14 +128,14 @@ func TestClientRegistry_PutPrefersUUIDOverClientID(t *testing.T) {
|
||||
|
||||
r, _ := NewClientRegistryWithConfig(c)
|
||||
// Seed two separate records
|
||||
n1 := &Node{UUID: rnd.UUIDv7(), Name: "pp-a", Role: "instance"}
|
||||
n1 := &Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-a", Role: "instance"}}
|
||||
assert.NoError(t, r.Put(n1))
|
||||
n2 := &Node{Name: "pp-b", Role: "service"}
|
||||
n2 := &Node{Node: cluster.Node{Name: "pp-b", Role: "service"}}
|
||||
assert.NoError(t, r.Put(n2))
|
||||
|
||||
// Now attempt to update by UUID of n1 while also passing n2.ClientID:
|
||||
// implementation must use UUID and not attach to n2.
|
||||
upd := &Node{UUID: n1.UUID, ClientID: n2.ClientID, Role: "service"}
|
||||
upd := &Node{Node: cluster.Node{UUID: n1.UUID, ClientID: n2.ClientID, Role: "service"}}
|
||||
assert.NoError(t, r.Put(upd))
|
||||
|
||||
got1, err := r.FindByNodeUUID(n1.UUID)
|
||||
|
@@ -23,28 +23,14 @@ func NodeOptsForSession(s *entity.Session) NodeOpts {
|
||||
|
||||
// BuildClusterNode builds a cluster.Node DTO from a registry.Node with redaction according to opts.
|
||||
func BuildClusterNode(n Node, opts NodeOpts) cluster.Node {
|
||||
out := cluster.Node{
|
||||
UUID: n.UUID,
|
||||
Name: n.Name,
|
||||
Role: n.Role,
|
||||
ClientID: n.ClientID,
|
||||
SiteUrl: n.SiteUrl,
|
||||
Labels: n.Labels,
|
||||
CreatedAt: n.CreatedAt,
|
||||
UpdatedAt: n.UpdatedAt,
|
||||
out := n.Node
|
||||
|
||||
if !opts.IncludeAdvertiseUrl {
|
||||
out.AdvertiseUrl = ""
|
||||
}
|
||||
|
||||
if opts.IncludeAdvertiseUrl && n.AdvertiseUrl != "" {
|
||||
out.AdvertiseUrl = n.AdvertiseUrl
|
||||
}
|
||||
|
||||
if opts.IncludeDatabase {
|
||||
out.Database = &cluster.NodeDatabase{
|
||||
Name: n.Database.Name,
|
||||
User: n.Database.User,
|
||||
Driver: n.Database.Driver,
|
||||
RotatedAt: n.Database.RotatedAt,
|
||||
}
|
||||
if !opts.IncludeDatabase {
|
||||
out.Database = nil
|
||||
}
|
||||
|
||||
return out
|
||||
|
18
internal/service/cluster/request.go
Normal file
18
internal/service/cluster/request.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package cluster
|
||||
|
||||
// RegisterRequest represents the JSON payload sent to the Portal when a node
|
||||
// registers or refreshes its metadata.
|
||||
//
|
||||
// swagger:model RegisterRequest
|
||||
type RegisterRequest struct {
|
||||
NodeName string `json:"nodeName"`
|
||||
NodeUUID string `json:"nodeUUID,omitempty"`
|
||||
NodeRole string `json:"nodeRole,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
AdvertiseUrl string `json:"advertiseUrl,omitempty"`
|
||||
SiteUrl string `json:"siteUrl,omitempty"`
|
||||
ClientID string `json:"clientId,omitempty"`
|
||||
ClientSecret string `json:"clientSecret,omitempty"`
|
||||
RotateDatabase bool `json:"rotateDatabase,omitempty"`
|
||||
RotateSecret bool `json:"rotateSecret,omitempty"`
|
||||
}
|
@@ -68,6 +68,7 @@ type RegisterResponse struct {
|
||||
Node Node `json:"node"`
|
||||
Database RegisterDatabase `json:"database"`
|
||||
Secrets *RegisterSecrets `json:"secrets,omitempty"`
|
||||
JWKSUrl string `json:"jwksUrl,omitempty"`
|
||||
AlreadyRegistered bool `json:"alreadyRegistered"`
|
||||
AlreadyProvisioned bool `json:"alreadyProvisioned"`
|
||||
}
|
||||
|
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
@@ -22,6 +23,9 @@ func TestMain(m *testing.M) {
|
||||
// Close database connection.
|
||||
_ = c.CloseDb()
|
||||
|
||||
// Remove temporary SQLite files after running the tests.
|
||||
fs.PurgeTestDbFiles(".", false)
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
|
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/internal/photoprism"
|
||||
"github.com/photoprism/photoprism/internal/photoprism/get"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
@@ -23,7 +24,11 @@ func TestMain(m *testing.M) {
|
||||
get.SetConfig(c)
|
||||
photoprism.SetConfig(c)
|
||||
|
||||
// Run unit tests.
|
||||
code := m.Run()
|
||||
|
||||
// Remove temporary SQLite files after running the tests.
|
||||
fs.PurgeTestDbFiles(".", false)
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
@@ -17,6 +17,7 @@ const (
|
||||
MethodSession MethodType = "session"
|
||||
MethodOAuth2 MethodType = "oauth2"
|
||||
Method2FA MethodType = "2fa"
|
||||
MethodJWT MethodType = "jwt"
|
||||
)
|
||||
|
||||
// Method casts a string to a normalized method type.
|
||||
@@ -31,6 +32,8 @@ func Method(s string) MethodType {
|
||||
return MethodOAuth2
|
||||
case "2fa", "mfa", "otp", "totp":
|
||||
return Method2FA
|
||||
case "jwt", "jwks":
|
||||
return MethodJWT
|
||||
case "access_token":
|
||||
return MethodDefault
|
||||
default:
|
||||
@@ -57,6 +60,8 @@ func (t MethodType) Pretty() string {
|
||||
return "OAuth2"
|
||||
case Method2FA:
|
||||
return "2FA"
|
||||
case MethodJWT:
|
||||
return "JWT"
|
||||
default:
|
||||
return txt.UpperFirst(t.String())
|
||||
}
|
||||
@@ -110,3 +115,8 @@ func (t MethodType) IsDefault() bool {
|
||||
func (t MethodType) IsSession() bool {
|
||||
return t.String() == MethodSession.String()
|
||||
}
|
||||
|
||||
// IsJWT checks if this is the JSON Web Token (JWT) method.
|
||||
func (t MethodType) IsJWT() bool {
|
||||
return t.String() == MethodJWT.String()
|
||||
}
|
||||
|
@@ -211,6 +211,21 @@ func TestAuthorization(t *testing.T) {
|
||||
assert.Equal(t, AuthBearer, authType)
|
||||
assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", authToken)
|
||||
})
|
||||
t.Run("JWTToken", func(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = &http.Request{
|
||||
Header: make(http.Header),
|
||||
}
|
||||
|
||||
token := "eyJhbGciOiJFZERTQSIsImtpZCI6IjEyMyJ9.eyJpc3MiOiJwb3J0YWw6dGVzdCIsImF1ZCI6Im5vZGU6YWJjIiwiZXhwIjoxNzAwMDAwMDB9.dGVzdC1zaWduYXR1cmUtYnl0ZXM"
|
||||
c.Request.Header.Add(Auth, "Bearer "+token)
|
||||
|
||||
authType, authToken := Authorization(c)
|
||||
assert.Equal(t, AuthBearer, authType)
|
||||
assert.Equal(t, token, authToken)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBasicAuth(t *testing.T) {
|
||||
|
5
pkg/service/http/header/etag.go
Normal file
5
pkg/service/http/header/etag.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package header
|
||||
|
||||
// ETag is the response header containing an entity tag that allows
|
||||
// clients and caches to perform conditional requests.
|
||||
const ETag = "ETag"
|
@@ -19,7 +19,7 @@ func ID(s string) string {
|
||||
prev = r
|
||||
|
||||
switch r {
|
||||
case ' ', '"', '-', '+', '/', '=', '#', '$', '@', ':', ';', '_':
|
||||
case ' ', '"', '-', '+', '/', '=', '#', '$', '@', ':', ';', '_', '.':
|
||||
return r
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user