mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
feat(auth): split session and public-link JWT secrets, rotating sessions on id migration
This commit is contained in:
parent
3fff973ab6
commit
037a5fd446
@ -28,6 +28,7 @@ const (
|
||||
UIAuthorizationHeader = "X-ND-Authorization"
|
||||
UIClientUniqueIDHeader = "X-ND-Client-Unique-Id"
|
||||
JWTSecretKey = "JWTSecret"
|
||||
JWTPublicSecretKey = "JWTPublicSecret"
|
||||
JWTIssuer = "ND"
|
||||
DefaultSessionTimeout = 48 * time.Hour
|
||||
CookieExpiry = 365 * 24 * 3600 // One year
|
||||
|
||||
@ -19,35 +19,41 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
once sync.Once
|
||||
// TokenAuth signs UI/API session tokens. Rotated by the id migration so stale sessions die.
|
||||
TokenAuth *jwtauth.JWTAuth
|
||||
// PublicTokenAuth signs public-link tokens (artwork, share streams), on a separate secret that survives the rotation.
|
||||
PublicTokenAuth *jwtauth.JWTAuth
|
||||
)
|
||||
|
||||
// Init creates a JWTAuth object from the secret stored in the DB.
|
||||
// If the secret is not found, it will create a new one and store it in the DB.
|
||||
// Init creates the JWTAuth objects from the secrets stored in the DB.
|
||||
// Missing or undecryptable secrets are regenerated and stored.
|
||||
func Init(ds model.DataStore) {
|
||||
once.Do(func() {
|
||||
ctx := context.TODO()
|
||||
log.Info("Setting Session Timeout", "value", conf.Server.SessionTimeout)
|
||||
|
||||
secret, err := ds.Property(ctx).Get(consts.JWTSecretKey)
|
||||
if err != nil || secret == "" {
|
||||
log.Info(ctx, "Creating new JWT secret, used for encrypting UI sessions")
|
||||
secret = createNewSecret(ctx, ds)
|
||||
} else {
|
||||
if secret, err = utils.Decrypt(ctx, getEncKey(), secret); err != nil {
|
||||
log.Error(ctx, "Could not decrypt JWT secret, creating a new one", err)
|
||||
secret = createNewSecret(ctx, ds)
|
||||
}
|
||||
}
|
||||
|
||||
TokenAuth = jwtauth.New("HS256", []byte(secret), nil)
|
||||
TokenAuth = jwtauth.New("HS256", []byte(loadOrCreateSecret(ctx, ds, consts.JWTSecretKey)), nil)
|
||||
PublicTokenAuth = jwtauth.New("HS256", []byte(loadOrCreateSecret(ctx, ds, consts.JWTPublicSecretKey)), nil)
|
||||
})
|
||||
}
|
||||
|
||||
func loadOrCreateSecret(ctx context.Context, ds model.DataStore, key string) string {
|
||||
secret, err := ds.Property(ctx).Get(key)
|
||||
if err != nil || secret == "" {
|
||||
log.Info(ctx, "Creating new JWT secret", "key", key)
|
||||
return createNewSecret(ctx, ds, key)
|
||||
}
|
||||
if secret, err = utils.Decrypt(ctx, getEncKey(), secret); err != nil {
|
||||
log.Error(ctx, "Could not decrypt JWT secret, creating a new one", "key", key, err)
|
||||
return createNewSecret(ctx, ds, key)
|
||||
}
|
||||
return secret
|
||||
}
|
||||
|
||||
func CreatePublicToken(claims Claims) (string, error) {
|
||||
claims.Issuer = consts.JWTIssuer
|
||||
_, token, err := TokenAuth.Encode(claims.ToMap())
|
||||
_, token, err := PublicTokenAuth.Encode(claims.ToMap())
|
||||
return token, err
|
||||
}
|
||||
|
||||
@ -56,7 +62,7 @@ func CreateExpiringPublicToken(exp time.Time, claims Claims) (string, error) {
|
||||
if !exp.IsZero() {
|
||||
claims.ExpiresAt = exp
|
||||
}
|
||||
_, token, err := TokenAuth.Encode(claims.ToMap())
|
||||
_, token, err := PublicTokenAuth.Encode(claims.ToMap())
|
||||
return token, err
|
||||
}
|
||||
|
||||
@ -91,6 +97,15 @@ func Validate(tokenStr string) (Claims, error) {
|
||||
return ClaimsFromToken(token), nil
|
||||
}
|
||||
|
||||
// ValidatePublic verifies a public-link token against the public secret.
|
||||
func ValidatePublic(tokenStr string) (Claims, error) {
|
||||
token, err := jwtauth.VerifyToken(PublicTokenAuth, tokenStr)
|
||||
if err != nil {
|
||||
return Claims{}, err
|
||||
}
|
||||
return ClaimsFromToken(token), nil
|
||||
}
|
||||
|
||||
func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
|
||||
u, err := ds.User(ctx).FindFirstAdmin()
|
||||
if err != nil {
|
||||
@ -107,14 +122,14 @@ func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
|
||||
return request.WithUser(ctx, *u)
|
||||
}
|
||||
|
||||
func createNewSecret(ctx context.Context, ds model.DataStore) string {
|
||||
func createNewSecret(ctx context.Context, ds model.DataStore, key string) string {
|
||||
secret := id.NewRandom()
|
||||
encSecret, err := utils.Encrypt(ctx, getEncKey(), secret)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Could not encrypt JWT secret", err)
|
||||
return secret
|
||||
}
|
||||
if err := ds.Property(ctx).Put(consts.JWTSecretKey, encSecret); err != nil {
|
||||
if err := ds.Property(ctx).Put(key, encSecret); err != nil {
|
||||
log.Error(ctx, "Could not save JWT secret in DB", err)
|
||||
}
|
||||
return secret
|
||||
|
||||
@ -89,6 +89,51 @@ var _ = Describe("Auth", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Session/Public secret split", func() {
|
||||
claims := func() map[string]any {
|
||||
return map[string]any{"iss": "issuer", "exp": time.Now().Add(1 * time.Minute).Unix()}
|
||||
}
|
||||
|
||||
It("verifies a session token via Validate but not ValidatePublic", func() {
|
||||
_, tokenStr, err := auth.TokenAuth.Encode(claims())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
_, err = auth.Validate(tokenStr)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
_, err = auth.ValidatePublic(tokenStr)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("verifies a public token via ValidatePublic but not Validate", func() {
|
||||
_, tokenStr, err := auth.PublicTokenAuth.Encode(claims())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
_, err = auth.ValidatePublic(tokenStr)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
_, err = auth.Validate(tokenStr)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("decodes public tokens minted by CreatePublicToken via PublicTokenAuth", func() {
|
||||
tokenStr, err := auth.CreatePublicToken(auth.Claims{ID: "art-1"})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
claims, err := auth.ValidatePublic(tokenStr)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(claims.ID).To(Equal("art-1"))
|
||||
_, err = auth.Validate(tokenStr)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("decodes expiring public tokens minted by CreateExpiringPublicToken via PublicTokenAuth", func() {
|
||||
exp := time.Now().Add(1 * time.Hour)
|
||||
tokenStr, err := auth.CreateExpiringPublicToken(exp, auth.Claims{ID: "art-2"})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
claims, err := auth.ValidatePublic(tokenStr)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(claims.ID).To(Equal("art-2"))
|
||||
_, err = auth.Validate(tokenStr)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("TouchToken", func() {
|
||||
It("updates the expiration time", func() {
|
||||
yesterday := time.Now().Add(-oneDay)
|
||||
|
||||
@ -175,7 +175,7 @@ var _ = Describe("Public URL Utilities", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ShareURL = "https://share.example.com"
|
||||
// Initialize JWT auth for token generation
|
||||
auth.TokenAuth = jwtauth.New("HS256", []byte("test secret"), nil)
|
||||
auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test secret"), nil)
|
||||
})
|
||||
|
||||
It("generates a URL with the artwork token", func() {
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
@ -72,10 +73,22 @@ func upUniformCanonicalIds(ctx context.Context, tx *sql.Tx) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := rotateSessionSecret(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, "DROP TABLE _id_map")
|
||||
return err
|
||||
}
|
||||
|
||||
// rotateSessionSecret renames the session JWT secret to the public key, so public-link tokens
|
||||
// keep verifying while auth.Init mints a fresh session secret, killing every stale session.
|
||||
func rotateSessionSecret(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`UPDATE property SET id = ? WHERE id = ? AND NOT EXISTS (SELECT 1 FROM property WHERE id = ?)`,
|
||||
consts.JWTPublicSecretKey, consts.JWTSecretKey, consts.JWTPublicSecretKey)
|
||||
return err
|
||||
}
|
||||
|
||||
// buildIDMap stages old->new pairs for every id that changes, indexed for the update joins.
|
||||
func buildIDMap(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx,
|
||||
|
||||
@ -26,6 +26,8 @@ var _ = Describe("upUniformCanonicalIds", func() {
|
||||
uuidOld = "f47ac10b-58cc-4372-a567-0e02b2c3d479" // uuid -> re-encoded
|
||||
uuidNew = "7rke2SAWaicSeSYzkhww6R"
|
||||
shareID = "aB3xY9kQz1" // exempt family
|
||||
|
||||
sessionSecret = "encrypted-session-secret-sentinel"
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
@ -89,6 +91,7 @@ var _ = Describe("upUniformCanonicalIds", func() {
|
||||
// malformed JSON in both a plugin list and a playlist rule: must pass through byte-for-byte
|
||||
seed(`INSERT INTO plugin VALUES ('broken', 'not-json')`)
|
||||
seed(`INSERT INTO playlist VALUES (?, ?, '{broken')`, hashID, hashID)
|
||||
seed(`INSERT INTO property VALUES (?, ?)`, consts.JWTSecretKey, sessionSecret)
|
||||
})
|
||||
|
||||
JustBeforeEach(func() {
|
||||
@ -204,4 +207,27 @@ var _ = Describe("upUniformCanonicalIds", func() {
|
||||
Expect(rescanCount()).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
propCount := func(key string) int {
|
||||
var count int
|
||||
ExpectWithOffset(1, db.QueryRow(`SELECT count(*) FROM property WHERE id = ?`, key).Scan(&count)).To(Succeed())
|
||||
return count
|
||||
}
|
||||
|
||||
It("rotates the session secret to the public key", func() {
|
||||
Expect(propCount(consts.JWTSecretKey)).To(Equal(0))
|
||||
Expect(get(`SELECT value FROM property WHERE id = '` + consts.JWTPublicSecretKey + `'`)).To(Equal(sessionSecret))
|
||||
})
|
||||
|
||||
Context("with no stored session secret", func() {
|
||||
BeforeEach(func() {
|
||||
_, err := db.Exec(`DELETE FROM property WHERE id = ?`, consts.JWTSecretKey)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("is a no-op", func() {
|
||||
Expect(propCount(consts.JWTSecretKey)).To(Equal(0))
|
||||
Expect(propCount(consts.JWTPublicSecretKey)).To(Equal(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -225,7 +225,7 @@ func decodeArtworkURL(artworkURL string) model.ArtworkID {
|
||||
}
|
||||
|
||||
// Decode JWT token
|
||||
token, err := auth.TokenAuth.Decode(tokenPart)
|
||||
token, err := auth.PublicTokenAuth.Decode(tokenPart)
|
||||
Expect(err).ToNot(HaveOccurred(), "Failed to decode JWT token")
|
||||
|
||||
c := auth.ClaimsFromToken(token)
|
||||
|
||||
@ -68,7 +68,7 @@ func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func decodeArtworkID(tokenString string) (model.ArtworkID, error) {
|
||||
token, err := auth.TokenAuth.Decode(tokenString)
|
||||
token, err := auth.PublicTokenAuth.Decode(tokenString)
|
||||
if err != nil {
|
||||
return model.ArtworkID{}, err
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ import (
|
||||
|
||||
var _ = Describe("decodeArtworkID", func() {
|
||||
BeforeEach(func() {
|
||||
auth.TokenAuth = jwtauth.New("HS256", []byte("super secret"), nil)
|
||||
auth.PublicTokenAuth = jwtauth.New("HS256", []byte("super secret"), nil)
|
||||
})
|
||||
|
||||
It("fails to decode an invalid token", func() {
|
||||
|
||||
@ -111,8 +111,8 @@ func (pub *Router) mapShareToM3U(r *http.Request, s model.Share) *model.Share {
|
||||
// admin flag) and grants access to nothing beyond the share it belongs to; the
|
||||
// stream handler still verifies the share exists, is unexpired, and that the
|
||||
// track is actually a member of it. An attacker who can forge these tokens
|
||||
// necessarily already holds the signing secret, which also signs real user
|
||||
// sessions, so that scenario is out of scope for the share boundary specifically.
|
||||
// necessarily already holds the public-link signing secret, a full-server
|
||||
// compromise that is out of scope for the share boundary specifically.
|
||||
func encodeMediafileShare(s model.Share, id string) string {
|
||||
claims := auth.Claims{
|
||||
ID: id,
|
||||
|
||||
@ -107,7 +107,7 @@ func shareContainsTrack(share *model.Share, mediaFileID string) bool {
|
||||
// public-share capability, not an auth credential; see encodeMediafileShare for
|
||||
// why a JWT is used here.
|
||||
func decodeStreamInfo(tokenString string) (shareTrackInfo, error) {
|
||||
c, err := auth.Validate(tokenString)
|
||||
c, err := auth.ValidatePublic(tokenString)
|
||||
if err != nil {
|
||||
return shareTrackInfo{}, err
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@ func (m *mockStreamer) NewStream(_ context.Context, _ *model.MediaFile, r stream
|
||||
|
||||
var _ = Describe("decodeStreamInfo", func() {
|
||||
BeforeEach(func() {
|
||||
auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
})
|
||||
|
||||
It("decodes a valid token with all fields", func() {
|
||||
@ -81,7 +81,7 @@ var _ = Describe("decodeStreamInfo", func() {
|
||||
|
||||
var _ = Describe("encodeMediafileShare", func() {
|
||||
BeforeEach(func() {
|
||||
auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
})
|
||||
|
||||
It("includes the share ID in the token", func() {
|
||||
@ -113,7 +113,7 @@ var _ = Describe("handleStream", func() {
|
||||
var pub *Router
|
||||
|
||||
BeforeEach(func() {
|
||||
auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
ds = &tests.MockDataStore{}
|
||||
shareRepo = &tests.MockShareRepo{}
|
||||
ds.MockedShare = shareRepo
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user