mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-10-26 05:50:38 +00:00
fix(backend,helm): stop shipping a shared JWT signing key in docker-compose (#135)
* fix(auth): stop shipping a shared JWT signing key in docker-compose * fix(auth): add persistent random generated jwt key * fix coverage * refactor(auth): drop unreachable error branches in JWT key generation
This commit is contained in:
+6
-1
@@ -46,11 +46,16 @@ RUN apk update && apk upgrade --no-cache && \
|
||||
apk --no-cache add ca-certificates wget
|
||||
|
||||
RUN addgroup -g 1000 garageui && \
|
||||
adduser -D -u 1000 -G garageui garageui
|
||||
adduser -D -u 1000 -G garageui garageui && \
|
||||
mkdir -p /app/data && chown garageui:garageui /app/data
|
||||
|
||||
COPY --from=backend-builder --chown=garageui:garageui /app/garage-ui .
|
||||
COPY --from=frontend-builder --chown=garageui:garageui /app/frontend/dist ./frontend/dist
|
||||
|
||||
# Without a configured key, one is generated here on first start and reused.
|
||||
# Mount a volume on /app/data to keep it when the container is recreated.
|
||||
ENV GARAGE_UI_AUTH_JWT_PRIVATE_KEY_PATH=/app/data/jwt-key.pem
|
||||
|
||||
USER garageui
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -43,7 +43,15 @@ type UserInfo struct {
|
||||
|
||||
// NewAuthService creates a new authentication service
|
||||
func NewAuthService(authCfg *config.AuthConfig, serverCfg *config.ServerConfig) (*Service, error) {
|
||||
jwtService, err := NewJWTServiceWithKey(authCfg.JWTPrivKey)
|
||||
keyPEM := authCfg.JWTPrivKey
|
||||
if keyPEM == "" && authCfg.JWTPrivKeyPath != "" {
|
||||
var err error
|
||||
if keyPEM, err = loadOrCreateKeyFile(authCfg.JWTPrivKeyPath); err != nil {
|
||||
logger.Warn().Err(err).Str("path", authCfg.JWTPrivKeyPath).Msg("Cannot load or save the JWT signing key, using a random key; sessions will not survive restarts")
|
||||
}
|
||||
}
|
||||
|
||||
jwtService, err := NewJWTServiceWithKey(keyPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize JWT service: %w", err)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -220,6 +222,37 @@ func TestNewAuthService_OIDCDisabled_DoesNotInitProvider(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuthService_JWTKeyPath_SavesAndReusesKey(t *testing.T) {
|
||||
cfg := &config.AuthConfig{JWTPrivKeyPath: filepath.Join(t.TempDir(), "jwt-key.pem")}
|
||||
|
||||
first, err := NewAuthService(cfg, &config.ServerConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("first NewAuthService: %v", err)
|
||||
}
|
||||
second, err := NewAuthService(cfg, &config.ServerConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("second NewAuthService: %v", err)
|
||||
}
|
||||
if !first.jwtService.publicKey.Equal(second.jwtService.publicKey) {
|
||||
t.Error("second start used a different key instead of the saved one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuthService_JWTKeyPathUnusable_FallsBackToRandomKey(t *testing.T) {
|
||||
blocker := filepath.Join(t.TempDir(), "not-a-dir")
|
||||
if err := os.WriteFile(blocker, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc, err := NewAuthService(&config.AuthConfig{JWTPrivKeyPath: filepath.Join(blocker, "jwt-key.pem")}, &config.ServerConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuthService should fall back, got: %v", err)
|
||||
}
|
||||
if svc.jwtService.privateKey == nil {
|
||||
t.Error("no signing key after fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuthService_OIDCEnabled_DiscoversProvider(t *testing.T) {
|
||||
disco := newDiscoveryServer(t)
|
||||
|
||||
|
||||
@@ -6,10 +6,15 @@ import (
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/pkg/logger"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
@@ -40,6 +45,10 @@ type SessionClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// publishedComposeKey shipped in docker-compose.yml up to 0.13.0 (issue #132).
|
||||
// It is public, so it is refused even when an operator configures it.
|
||||
var publishedComposeKey, _ = parseEd25519PrivateKeyFromPEM("-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIH0ZHqIV7MEyVsxNYc5TA/a0qaBxgq3ntlFS3w1F03MS\n-----END PRIVATE KEY-----\n")
|
||||
|
||||
func NewJWTService() (*JWTService, error) {
|
||||
return NewJWTServiceWithKey("")
|
||||
}
|
||||
@@ -55,6 +64,13 @@ func NewJWTServiceWithKey(privateKeyPEM string) (*JWTService, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Ed25519 private key: %w", err)
|
||||
}
|
||||
if privateKey.Equal(publishedComposeKey) {
|
||||
logger.Warn().Msg("GARAGE_UI_AUTH_JWT_PRIVATE_KEY is the public example key from docker-compose.yml, anyone could forge sessions with it. Ignoring it and using a random key; sessions will not survive restarts until you set your own key")
|
||||
privateKey = nil
|
||||
}
|
||||
}
|
||||
|
||||
if privateKey != nil {
|
||||
publicKey = privateKey.Public().(ed25519.PublicKey)
|
||||
} else {
|
||||
// Generate a new Ed25519 key pair if no key is provided
|
||||
@@ -73,6 +89,30 @@ func NewJWTServiceWithKey(privateKeyPEM string) (*JWTService, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// loadOrCreateKeyFile returns the PEM key stored at path. On first use it
|
||||
// generates a key and saves it there, so sessions survive restarts. The
|
||||
// parent directory must already exist.
|
||||
func loadOrCreateKeyFile(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
return string(data), nil
|
||||
}
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
seed := make([]byte, ed25519.SeedSize)
|
||||
rand.Read(seed) // never fails since Go 1.24
|
||||
// Marshalling an Ed25519 key cannot fail.
|
||||
der, _ := x509.MarshalPKCS8PrivateKey(ed25519.NewKeyFromSeed(seed))
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
|
||||
if err := os.WriteFile(path, keyPEM, 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
logger.Info().Str("path", path).Msg("Generated a new JWT signing key")
|
||||
return string(keyPEM), nil
|
||||
}
|
||||
|
||||
func parseEd25519PrivateKeyFromPEM(privateKeyPEM string) (ed25519.PrivateKey, error) {
|
||||
block, _ := pem.Decode([]byte(privateKeyPEM))
|
||||
if block == nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -118,6 +119,66 @@ func TestNewJWTService_AutoGeneratesKeyPair(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The key that shipped in docker-compose.yml (issue #132) is public, so a
|
||||
// token an attacker signs with it must not validate.
|
||||
func TestNewJWTServiceWithKey_RejectsPublishedComposeKey(t *testing.T) {
|
||||
const published = "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIH0ZHqIV7MEyVsxNYc5TA/a0qaBxgq3ntlFS3w1F03MS\n-----END PRIVATE KEY-----\n"
|
||||
svc, err := NewJWTServiceWithKey(published)
|
||||
if err != nil {
|
||||
t.Fatalf("NewJWTServiceWithKey: %v", err)
|
||||
}
|
||||
attackerKey, err := parseEd25519PrivateKeyFromPEM(published)
|
||||
if err != nil {
|
||||
t.Fatalf("parse published key: %v", err)
|
||||
}
|
||||
forged, err := jwt.NewWithClaims(jwt.SigningMethodEdDSA, SessionClaims{
|
||||
Username: "forged-admin",
|
||||
AuthMethod: "admin",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
},
|
||||
}).SignedString(attackerKey)
|
||||
if err != nil {
|
||||
t.Fatalf("sign forged token: %v", err)
|
||||
}
|
||||
if _, err := svc.ValidateToken(forged); err == nil {
|
||||
t.Fatal("token signed with the published compose key was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrCreateKeyFile_GeneratesOnceThenReuses(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "jwt-key.pem")
|
||||
|
||||
first, err := loadOrCreateKeyFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("first call: %v", err)
|
||||
}
|
||||
if _, err := parseEd25519PrivateKeyFromPEM(first); err != nil {
|
||||
t.Fatalf("generated key does not parse: %v", err)
|
||||
}
|
||||
second, err := loadOrCreateKeyFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("second call: %v", err)
|
||||
}
|
||||
if first != second {
|
||||
t.Fatal("second call generated a new key instead of reusing the saved one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrCreateKeyFile_MissingParentDirFails(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "missing", "jwt-key.pem")
|
||||
if _, err := loadOrCreateKeyFile(path); err == nil {
|
||||
t.Fatal("expected an error when the parent directory does not exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrCreateKeyFile_UnreadablePathFails(t *testing.T) {
|
||||
// A directory exists but cannot be read as a key, so nothing is generated.
|
||||
if _, err := loadOrCreateKeyFile(t.TempDir()); err == nil {
|
||||
t.Fatal("expected an error for a directory path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewJWTServiceWithKey_EmptyStringAutoGenerates(t *testing.T) {
|
||||
svc, err := NewJWTServiceWithKey("")
|
||||
if err != nil {
|
||||
|
||||
@@ -50,11 +50,12 @@ type GarageConfig struct {
|
||||
|
||||
// AuthConfig contains authentication configuration
|
||||
type AuthConfig struct {
|
||||
Admin AdminAuthConfig `mapstructure:"admin"`
|
||||
OIDC OIDCConfig `mapstructure:"oidc"`
|
||||
Token TokenAuthConfig `mapstructure:"token"`
|
||||
JWTPrivKey string `mapstructure:"jwt_private_key"` // Ed25519 private key in PEM format for JWT signing (64 bytes)
|
||||
MetricsPublic bool `mapstructure:"metrics_public"` // Expose Prometheus metrics at top-level /metrics without auth
|
||||
Admin AdminAuthConfig `mapstructure:"admin"`
|
||||
OIDC OIDCConfig `mapstructure:"oidc"`
|
||||
Token TokenAuthConfig `mapstructure:"token"`
|
||||
JWTPrivKey string `mapstructure:"jwt_private_key"` // Ed25519 private key in PEM format for JWT signing (64 bytes)
|
||||
JWTPrivKeyPath string `mapstructure:"jwt_private_key_path"` // Where to save a generated key when JWTPrivKey is empty; empty means a new key per start
|
||||
MetricsPublic bool `mapstructure:"metrics_public"` // Expose Prometheus metrics at top-level /metrics without auth
|
||||
}
|
||||
|
||||
// AdminAuthConfig contains admin authentication settings
|
||||
@@ -311,6 +312,7 @@ func bindEnvVars() {
|
||||
viper.BindEnv("auth.admin.username", "GARAGE_UI_AUTH_ADMIN_USERNAME")
|
||||
viper.BindEnv("auth.admin.password", "GARAGE_UI_AUTH_ADMIN_PASSWORD")
|
||||
viper.BindEnv("auth.jwt_private_key", "GARAGE_UI_AUTH_JWT_PRIVATE_KEY")
|
||||
viper.BindEnv("auth.jwt_private_key_path", "GARAGE_UI_AUTH_JWT_PRIVATE_KEY_PATH")
|
||||
viper.BindEnv("auth.metrics_public", "GARAGE_UI_AUTH_METRICS_PUBLIC")
|
||||
|
||||
// Token auth config
|
||||
|
||||
@@ -57,6 +57,13 @@ auth:
|
||||
# The key is a 64-byte Ed25519 private key
|
||||
jwt_private_key: "" # Leave empty to auto-generate, or provide PEM-encoded Ed25519 private key
|
||||
|
||||
# When jwt_private_key is empty, generate a key on first start, save it to this
|
||||
# file and reuse it afterwards, so tokens survive restarts. Empty keeps the
|
||||
# per-start key. The directory must exist. The Docker image sets
|
||||
# /app/data/jwt-key.pem.
|
||||
# Env: GARAGE_UI_AUTH_JWT_PRIVATE_KEY_PATH
|
||||
jwt_private_key_path: ""
|
||||
|
||||
# Expose Prometheus metrics at top-level /metrics WITHOUT authentication.
|
||||
# Needed for Prometheus to scrape when auth (admin/token/oidc) is enabled.
|
||||
# WARNING: exposes operational cluster telemetry (no object data or secrets)
|
||||
|
||||
+8
-7
@@ -20,6 +20,8 @@ services:
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./config.yaml:/app/config.yaml
|
||||
# Holds the JWT signing key generated on first start
|
||||
- garage-ui-data:/app/data
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
depends_on:
|
||||
@@ -34,10 +36,9 @@ services:
|
||||
GARAGE_UI_SERVER_PORT: "8080"
|
||||
GARAGE_UI_SERVER_ENVIRONMENT: "production"
|
||||
|
||||
# JWT Configuration
|
||||
# IMPORTANT: Replace this with your own Ed25519 private key in production!
|
||||
# Generate with: openssl genpkey -algorithm ED25519
|
||||
GARAGE_UI_AUTH_JWT_PRIVATE_KEY: |
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEIH0ZHqIV7MEyVsxNYc5TA/a0qaBxgq3ntlFS3w1F03MS
|
||||
-----END PRIVATE KEY-----
|
||||
# JWT signing key: generated on first start and saved in the
|
||||
# garage-ui-data volume. To use your own instead, mount it and set
|
||||
# GARAGE_UI_AUTH_JWT_PRIVATE_KEY_FILE: /path/to/jwt-key.pem
|
||||
|
||||
volumes:
|
||||
garage-ui-data:
|
||||
|
||||
Reference in New Issue
Block a user