mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 00:34:13 +01:00
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -16,78 +18,132 @@ import (
|
||||
"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.
|
||||
// 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. By default, only cluster and vision resources are
|
||||
// eligible, but nodes may opt in to additional scopes via PHOTOPRISM_JWT_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 {
|
||||
// Check if token may be a JWT.
|
||||
if !shouldAttemptJWT(c, authToken) {
|
||||
return nil
|
||||
}
|
||||
|
||||
conf := get.Config()
|
||||
|
||||
if conf == nil || conf.IsPortal() {
|
||||
// Determine whether JWT authentication is possible
|
||||
// based on the local config and client IP address.
|
||||
if !shouldAllowJWT(conf, clientIP) {
|
||||
return nil
|
||||
}
|
||||
|
||||
requiredScope := resource.String()
|
||||
expected := expectedClaimsFor(conf, requiredScope)
|
||||
|
||||
// verifyTokenFromPortal handles cryptographic validation (signature, issuer,
|
||||
// audience, temporal claims) and enforces that the token includes any scopes
|
||||
// listed in expected.Scope. Local authorization still happens below so nodes
|
||||
// can apply their own allow-list semantics.
|
||||
claims := verifyTokenFromPortal(c.Request.Context(), authToken, expected, jwtIssuerCandidates(conf))
|
||||
|
||||
if claims == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if config allows resource access to be authorized with JWT.
|
||||
allowedScopes := conf.JWTAllowedScopes()
|
||||
if !acl.ScopeAttrPermits(allowedScopes, resource, perms) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if token allows access to specified resource.
|
||||
tokenScopes := acl.ScopeAttr(claims.Scope)
|
||||
if !acl.ScopeAttrPermits(tokenScopes, resource, perms) {
|
||||
return nil
|
||||
}
|
||||
|
||||
claims.Scope = tokenScopes.String()
|
||||
|
||||
return sessionFromJWTClaims(claims, clientIP)
|
||||
}
|
||||
|
||||
// shouldAttemptJWT reports whether JWT verification should run for the supplied
|
||||
// request context and token.
|
||||
func shouldAttemptJWT(c *gin.Context, token string) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if token == "" || strings.Count(token, ".") != 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// shouldAllowJWT reports whether the current node configuration permits JWT
|
||||
// authentication for the request originating from clientIP.
|
||||
func shouldAllowJWT(conf *config.Config, clientIP string) bool {
|
||||
if conf == nil || conf.IsPortal() {
|
||||
return false
|
||||
}
|
||||
|
||||
if conf.JWKSUrl() == "" {
|
||||
return nil
|
||||
return false
|
||||
}
|
||||
|
||||
requiredScopes := []string{"cluster"}
|
||||
if resource == acl.ResourceVision {
|
||||
requiredScopes = []string{"vision"}
|
||||
cidr := strings.TrimSpace(conf.ClusterCIDR())
|
||||
if cidr == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
ip := net.ParseIP(clientIP)
|
||||
_, block, err := net.ParseCIDR(cidr)
|
||||
if err != nil || ip == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return block.Contains(ip)
|
||||
}
|
||||
|
||||
// expectedClaimsFor builds the ExpectedClaims used to validate JWTs for the
|
||||
// current node and required scope.
|
||||
func expectedClaimsFor(conf *config.Config, requiredScope string) clusterjwt.ExpectedClaims {
|
||||
expected := clusterjwt.ExpectedClaims{
|
||||
Audience: fmt.Sprintf("node:%s", conf.NodeUUID()),
|
||||
Scope: requiredScopes,
|
||||
JWKSURL: conf.JWKSUrl(),
|
||||
}
|
||||
|
||||
issuers := jwtIssuerCandidates(conf)
|
||||
if requiredScope != "" {
|
||||
expected.Scope = []string{requiredScope}
|
||||
}
|
||||
|
||||
return expected
|
||||
}
|
||||
|
||||
// verifyTokenFromPortal checks the token against each candidate issuer and
|
||||
// returns the verified claims on success.
|
||||
func verifyTokenFromPortal(ctx context.Context, token string, expected clusterjwt.ExpectedClaims, issuers []string) *clusterjwt.Claims {
|
||||
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 = get.VerifyJWT(ctx, authToken, expected)
|
||||
claims, err := get.VerifyJWT(ctx, token, expected)
|
||||
if err == nil {
|
||||
break
|
||||
return claims
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil
|
||||
} else if claims == nil {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sessionFromJWTClaims constructs a Session populated with fields derived from
|
||||
// the verified JWT claims.
|
||||
func sessionFromJWTClaims(claims *clusterjwt.Claims, clientIP string) *entity.Session {
|
||||
sess := &entity.Session{
|
||||
Status: http.StatusOK,
|
||||
ClientUID: claims.Subject,
|
||||
|
||||
Reference in New Issue
Block a user