mirror of
https://github.com/photoprism/photoprism.git
synced 2025-09-26 21:01:58 +08:00
155 lines
4.1 KiB
Go
155 lines
4.1 KiB
Go
package commands
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/urfave/cli/v2"
|
|
|
|
clusterjwt "github.com/photoprism/photoprism/internal/auth/jwt"
|
|
"github.com/photoprism/photoprism/internal/config"
|
|
"github.com/photoprism/photoprism/pkg/clean"
|
|
)
|
|
|
|
// AuthJWTInspectCommand inspects and verifies portal-issued JWTs.
|
|
var AuthJWTInspectCommand = &cli.Command{
|
|
Name: "inspect",
|
|
Usage: "Decodes and verifies a portal JWT",
|
|
ArgsUsage: "<token>",
|
|
Flags: []cli.Flag{
|
|
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, Usage: "read token from file"},
|
|
&cli.StringFlag{Name: "expect-audience", Usage: "expected audience (e.g., node:<uuid>)"},
|
|
&cli.StringSliceFlag{Name: "require-scope", Usage: "require specific scope(s)"},
|
|
&cli.BoolFlag{Name: "skip-verify", Usage: "decode without signature verification"},
|
|
JsonFlag(),
|
|
},
|
|
Action: authJWTInspectAction,
|
|
}
|
|
|
|
// authJWTInspectAction decodes and optionally verifies a portal-issued JWT.
|
|
func authJWTInspectAction(ctx *cli.Context) error {
|
|
return CallWithDependencies(ctx, func(conf *config.Config) error {
|
|
if err := requirePortal(conf); err != nil {
|
|
return err
|
|
}
|
|
|
|
token, err := readTokenInput(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
header, claims, err := decodeJWTClaims(token)
|
|
if err != nil {
|
|
return cli.Exit(err, 1)
|
|
}
|
|
|
|
var verified bool
|
|
tokenScopes := clean.Scopes(claims.Scope)
|
|
|
|
if !ctx.Bool("skip-verify") {
|
|
expected := clusterjwt.ExpectedClaims{}
|
|
if clusterUUID := strings.TrimSpace(conf.ClusterUUID()); clusterUUID != "" {
|
|
expected.Issuer = fmt.Sprintf("portal:%s", clusterUUID)
|
|
} else if portal := strings.TrimSpace(conf.PortalUrl()); portal != "" {
|
|
expected.Issuer = strings.TrimRight(portal, "/")
|
|
}
|
|
|
|
if expectAud := strings.TrimSpace(ctx.String("expect-audience")); expectAud != "" {
|
|
expected.Audience = expectAud
|
|
} else if len(claims.Audience) > 0 {
|
|
expected.Audience = claims.Audience[0]
|
|
}
|
|
|
|
if required := ctx.StringSlice("require-scope"); len(required) > 0 {
|
|
scopes, scopeErr := normalizeScopes(required)
|
|
if scopeErr != nil {
|
|
return scopeErr
|
|
}
|
|
expected.Scope = scopes
|
|
} else {
|
|
expected.Scope = tokenScopes
|
|
}
|
|
|
|
if _, err := verifyPortalToken(conf, token, expected); err != nil {
|
|
return cli.Exit(err, 1)
|
|
}
|
|
verified = true
|
|
}
|
|
|
|
if ctx.Bool("json") {
|
|
payload := map[string]any{
|
|
"token": token,
|
|
"verified": verified,
|
|
"header": header,
|
|
"claims": claims,
|
|
}
|
|
return printJSON(payload)
|
|
}
|
|
|
|
fmt.Println()
|
|
fmt.Println("JWT header:")
|
|
for k, v := range header {
|
|
fmt.Printf(" %s: %v\n", k, v)
|
|
}
|
|
|
|
fmt.Println("\nJWT claims:")
|
|
fmt.Printf(" issuer: %s\n", claims.Issuer)
|
|
fmt.Printf(" subject: %s\n", claims.Subject)
|
|
fmt.Printf(" audience: %s\n", strings.Join(claims.Audience, " "))
|
|
fmt.Printf(" scope: %s\n", strings.Join(tokenScopes, " "))
|
|
if claims.IssuedAt != nil {
|
|
fmt.Printf(" issuedAt: %s\n", claims.IssuedAt.Time.UTC().Format(time.RFC3339))
|
|
}
|
|
if claims.ExpiresAt != nil {
|
|
fmt.Printf(" expiresAt: %s\n", claims.ExpiresAt.Time.UTC().Format(time.RFC3339))
|
|
}
|
|
if claims.NotBefore != nil {
|
|
fmt.Printf(" notBefore: %s\n", claims.NotBefore.Time.UTC().Format(time.RFC3339))
|
|
}
|
|
if claims.ID != "" {
|
|
fmt.Printf(" jti: %s\n", claims.ID)
|
|
}
|
|
|
|
if verified {
|
|
fmt.Println("\nSignature: verified")
|
|
} else {
|
|
fmt.Println("\nSignature: not verified (skipped)")
|
|
}
|
|
|
|
fmt.Printf("\nToken:\n%s\n\n", token)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// readTokenInput loads the token from CLI args, file, or STDIN.
|
|
func readTokenInput(ctx *cli.Context) (string, error) {
|
|
if file := strings.TrimSpace(ctx.String("file")); file != "" {
|
|
data, err := os.ReadFile(file)
|
|
if err != nil {
|
|
return "", cli.Exit(err, 1)
|
|
}
|
|
return strings.TrimSpace(string(data)), nil
|
|
}
|
|
|
|
if ctx.Args().Len() == 0 {
|
|
return "", cli.Exit(errors.New("token argument required"), 2)
|
|
}
|
|
|
|
token := strings.TrimSpace(ctx.Args().First())
|
|
if token == "-" {
|
|
data, err := io.ReadAll(os.Stdin)
|
|
if err != nil {
|
|
return "", cli.Exit(err, 1)
|
|
}
|
|
token = strings.TrimSpace(string(data))
|
|
}
|
|
if token == "" {
|
|
return "", cli.Exit(errors.New("token argument required"), 2)
|
|
}
|
|
return token, nil
|
|
}
|