diff --git a/cmd/helix-proxy/api.go b/cmd/helix-proxy/api.go index 781856b..a158e18 100644 --- a/cmd/helix-proxy/api.go +++ b/cmd/helix-proxy/api.go @@ -46,6 +46,7 @@ func streamsAPIResponseFrom(st store.Store, eng *proxy.Engine) []streamAPIRespon func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.Manager, jwtMgr *auth.JWTManager) { r.Route("/api", func(r chi.Router) { r.Use(jwtMgr.Middleware) + r.Use(bootstrapLock(st)) // Health (mirrors original /api style) r.Get("/", func(w http.ResponseWriter, r *http.Request) { @@ -1091,12 +1092,10 @@ func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.M }) // Login for the single admin account (no registration, no multi-user). - // Default password is "password". On first use (when no password has been set in DB yet), - // login with "password" succeeds but the response includes mustChangePassword:true - // (skipped in PROXY_MODE=development for local dev convenience). - // After the admin sets a new password (via /users/me/password), it is bcrypt-hashed and stored in DB. - // Subsequent logins use the stored hash. Email is ignored (always the built-in admin). - // No 2FA/TOTP. + // Until a non-default password is stored, the default password is accepted and the + // JWT is a bootstrap token (mutating admin API other than change-password is 403). + // After /users/me/password, subsequent logins use the stored bcrypt hash. + // Email is ignored (always the built-in admin). No 2FA/TOTP. r.Post("/login", func(w http.ResponseWriter, r *http.Request) { var payload struct { Password string `json:"password"` @@ -1105,17 +1104,17 @@ func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.M http.Error(w, err.Error(), 400) return } - u, ok := st.GetUserByEmail("admin@example.com") + u, ok := st.GetUserByEmail(adminEmail) if !ok { - // fallback to seeded values if lookup fails - u = store.User{ID: 1, Email: "admin@example.com", Name: "Admin", Roles: []string{"admin"}} + u = store.User{ID: 1, Email: adminEmail, Name: "Admin", Roles: []string{"admin"}} } mustChange := false authed := false + bootstrap := false if u.Password == "" { - // initial / not yet set: default "password" is accepted - if payload.Password == "password" { + if payload.Password == defaultAdminPassword { authed = true + bootstrap = true mustChange = !config.IsDevelopment() } } else if isBcryptPrefix(u.Password) { @@ -1123,14 +1122,19 @@ func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.M authed = true } } else if u.Password == payload.Password { - // legacy plain (should not happen after first change) authed = true } if !authed { http.Error(w, "invalid credentials", 401) return } - token, err := jwtMgr.GenerateToken(u.ID, u.Email, u.Name, u.Roles) + var token string + var err error + if bootstrap { + token, err = jwtMgr.GenerateBootstrapToken(u.ID, u.Email, u.Name, u.Roles) + } else { + token, err = jwtMgr.GenerateToken(u.ID, u.Email, u.Name, u.Roles) + } if err != nil { http.Error(w, "token error", 500) return diff --git a/cmd/helix-proxy/api_test.go b/cmd/helix-proxy/api_test.go index f971a79..a31f7dc 100644 --- a/cmd/helix-proxy/api_test.go +++ b/cmd/helix-proxy/api_test.go @@ -14,7 +14,7 @@ import ( func (e *apiTestEnv) loginToken(t *testing.T) string { t.Helper() - resp := apiPost(t, e.BaseURL+"/api/login", map[string]any{"password": "password"}, "") + resp := apiPost(t, e.BaseURL+"/api/login", map[string]any{"password": testLoginPassword()}, "") if resp.StatusCode != http.StatusOK { t.Fatalf("login: %d %s", resp.StatusCode, readBody(resp)) } diff --git a/cmd/helix-proxy/bootstrap.go b/cmd/helix-proxy/bootstrap.go new file mode 100644 index 0000000..48910a9 --- /dev/null +++ b/cmd/helix-proxy/bootstrap.go @@ -0,0 +1,104 @@ +package main + +import ( + "fmt" + "log/slog" + "net/http" + "os" + "strings" + + "helix-proxy/internal/auth" + "helix-proxy/internal/config" + "helix-proxy/internal/store" + + "golang.org/x/crypto/bcrypt" +) + +const ( + adminEmail = "admin@example.com" + defaultAdminPassword = "password" + testAdminPassword = "helix-test-admin" +) + +// applyInitialAdminPassword sets the admin hash from ADMIN_PASSWORD on first boot. +// Production refuses to start if the password is still unset. Development may +// leave the password empty (bootstrap lock) when ADMIN_PASSWORD is not set. +func applyInitialAdminPassword(st store.Store) error { + u, ok := st.GetUserByEmail(adminEmail) + if !ok { + return fmt.Errorf("admin user missing") + } + if u.Password != "" { + return nil + } + + envPW := os.Getenv("ADMIN_PASSWORD") + if envPW != "" { + if envPW == defaultAdminPassword { + return fmt.Errorf("ADMIN_PASSWORD cannot be the well-known default") + } + h, err := bcrypt.GenerateFromPassword([]byte(envPW), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("hash ADMIN_PASSWORD: %w", err) + } + u.Password = string(h) + if err := st.UpdateUser(u); err != nil { + return err + } + slog.Info("admin password set from ADMIN_PASSWORD") + return nil + } + + if config.IsDevelopment() { + slog.Warn("admin password unset; bootstrap lock active until a non-default password is stored") + return nil + } + return fmt.Errorf("ADMIN_PASSWORD is required on first boot; the default password is not permitted") +} + +func testLoginPassword() string { + if p := os.Getenv("ADMIN_PASSWORD"); p != "" { + return p + } + return defaultAdminPassword +} + +func adminPasswordUnset(st store.Store) bool { + u, ok := st.GetUserByEmail(adminEmail) + return !ok || u.Password == "" +} + +func bootstrapPathAllowed(r *http.Request) bool { + switch r.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + return true + } + p := strings.TrimSuffix(r.URL.Path, "/") + if r.Method == http.MethodPost && (p == "/api/login" || p == "/api/users/me/password") { + return true + } + return false +} + +// bootstrapLock rejects mutating admin API (except login + change-password) +// while the default password is still in effect, or when the JWT is a bootstrap token. +func bootstrapLock(st store.Store) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if bootstrapPathAllowed(r) { + next.ServeHTTP(w, r) + return + } + claims, ok := auth.GetUserFromContext(r.Context()) + if !ok { + next.ServeHTTP(w, r) + return + } + if claims.Bootstrap || adminPasswordUnset(st) { + http.Error(w, "password change required", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/cmd/helix-proxy/bootstrap_test.go b/cmd/helix-proxy/bootstrap_test.go new file mode 100644 index 0000000..f16edfc --- /dev/null +++ b/cmd/helix-proxy/bootstrap_test.go @@ -0,0 +1,167 @@ +package main + +import ( + "net/http" + "strings" + "testing" + + "helix-proxy/internal/config" + "helix-proxy/internal/store" +) + +func newBootstrapAPIServer(t *testing.T) *apiTestEnv { + t.Helper() + t.Setenv("PROXY_MODE", "development") + t.Setenv("ADMIN_PASSWORD", "") + return newAPITestServer(t) +} + +func TestBootstrapLock_MutatingAdminForbidden(t *testing.T) { + env := newBootstrapAPIServer(t) + resp := apiPost(t, env.BaseURL+"/api/login", map[string]any{"password": defaultAdminPassword}, "") + if resp.StatusCode != http.StatusOK { + t.Fatalf("bootstrap login: %d %s", resp.StatusCode, readBody(resp)) + } + var out struct { + Token string `json:"token"` + } + decodeJSON(t, resp, &out) + if out.Token == "" { + t.Fatal("empty bootstrap token") + } + tok := out.Token + + mutating := []struct { + method string + path string + body any + }{ + {http.MethodPost, "/api/proxy-hosts", map[string]any{ + "domainNames": []string{"boot.example"}, + "forwardHost": "127.0.0.1", + "forwardPort": 8080, + "forwardScheme": "http", + }}, + {http.MethodPost, "/api/settings", map[string]any{"default_site": "404"}}, + {http.MethodPost, "/api/access-lists", map[string]any{"name": "boot"}}, + {http.MethodPost, "/api/streams", map[string]any{"incomingPort": 9000, "forwardingHost": "127.0.0.1", "forwardingPort": 9001}}, + } + for _, tc := range mutating { + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + r := apiRequest(t, tc.method, env.BaseURL+tc.path, tok, tc.body) + assertStatus(t, r, http.StatusForbidden) + }) + } + + assertStatus(t, apiGet(t, env.BaseURL+"/api/users/me", tok), http.StatusOK) + assertStatus(t, apiGet(t, env.BaseURL+"/api/proxy-hosts", tok), http.StatusOK) +} + +func TestBootstrapLock_ChangePasswordThenDefaultFails(t *testing.T) { + env := newBootstrapAPIServer(t) + resp := apiPost(t, env.BaseURL+"/api/login", map[string]any{"password": defaultAdminPassword}, "") + if resp.StatusCode != http.StatusOK { + t.Fatalf("bootstrap login: %d %s", resp.StatusCode, readBody(resp)) + } + var out struct { + Token string `json:"token"` + } + decodeJSON(t, resp, &out) + + ch := apiPost(t, env.BaseURL+"/api/users/me/password", map[string]any{ + "currentPassword": defaultAdminPassword, + "newPassword": "changed-after-bootstrap", + }, out.Token) + assertStatus(t, ch, http.StatusOK) + + fail := apiPost(t, env.BaseURL+"/api/login", map[string]any{"password": defaultAdminPassword}, "") + assertStatus(t, fail, http.StatusUnauthorized) + + okLogin := apiPost(t, env.BaseURL+"/api/login", map[string]any{"password": "changed-after-bootstrap"}, "") + if okLogin.StatusCode != http.StatusOK { + t.Fatalf("new password login: %d %s", okLogin.StatusCode, readBody(okLogin)) + } + var out2 struct { + Token string `json:"token"` + } + decodeJSON(t, okLogin, &out2) + create := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{ + "domainNames": []string{"after-change.example"}, + "forwardHost": "127.0.0.1", + "forwardPort": 8080, + "forwardScheme": "http", + }, out2.Token) + if create.StatusCode != http.StatusCreated { + t.Fatalf("full token after change should mutate: %d %s", create.StatusCode, readBody(create)) + } + _ = create.Body.Close() + + locked := apiPost(t, env.BaseURL+"/api/settings", map[string]any{"default_site": "404"}, out.Token) + assertStatus(t, locked, http.StatusForbidden) +} + +func TestDevTestsBootWithAdminPassword(t *testing.T) { + t.Setenv("PROXY_MODE", "development") + env := newAPITestServer(t) + if adminPasswordUnset(env.Store) { + t.Fatal("test env should store ADMIN_PASSWORD hash") + } + token := env.loginToken(t) + create := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{ + "domainNames": []string{"dev-admin.example"}, + "forwardHost": "127.0.0.1", + "forwardPort": 8080, + "forwardScheme": "http", + }, token) + if create.StatusCode != http.StatusCreated { + t.Fatalf("dev ADMIN_PASSWORD token should be fully privileged: %d %s", create.StatusCode, readBody(create)) + } + _ = create.Body.Close() + + fail := apiPost(t, env.BaseURL+"/api/login", map[string]any{"password": defaultAdminPassword}, "") + assertStatus(t, fail, http.StatusUnauthorized) +} + +func TestApplyInitialAdminPassword_RejectsDefault(t *testing.T) { + t.Setenv("PROXY_MODE", "development") + t.Setenv("ADMIN_PASSWORD", defaultAdminPassword) + tmp := t.TempDir() + config.ResetForTest() + t.Setenv("DATA_DIR", tmp) + if err := config.EnsureDataDirs(); err != nil { + t.Fatal(err) + } + st, err := store.New() + if err != nil { + t.Fatal(err) + } + if c, ok := st.(interface{ Close() error }); ok { + t.Cleanup(func() { _ = c.Close() }) + } + err = applyInitialAdminPassword(st) + if err == nil || !strings.Contains(err.Error(), "default") { + t.Fatalf("expected default ADMIN_PASSWORD error, got %v", err) + } +} + +func TestApplyInitialAdminPassword_ProductionRequiresEnv(t *testing.T) { + t.Setenv("PROXY_MODE", "production") + t.Setenv("ADMIN_PASSWORD", "") + tmp := t.TempDir() + config.ResetForTest() + t.Setenv("DATA_DIR", tmp) + if err := config.EnsureDataDirs(); err != nil { + t.Fatal(err) + } + st, err := store.New() + if err != nil { + t.Fatal(err) + } + if c, ok := st.(interface{ Close() error }); ok { + t.Cleanup(func() { _ = c.Close() }) + } + err = applyInitialAdminPassword(st) + if err == nil || !strings.Contains(err.Error(), "ADMIN_PASSWORD") { + t.Fatalf("expected refuse without ADMIN_PASSWORD, got %v", err) + } +} diff --git a/cmd/helix-proxy/listen_config.go b/cmd/helix-proxy/listen_config.go index 5864389..a98f611 100644 --- a/cmd/helix-proxy/listen_config.go +++ b/cmd/helix-proxy/listen_config.go @@ -21,15 +21,18 @@ func applyUmaskFromEnv() { } // adminListenAddr resolves the admin API/UI listen address from env. +// Defaults to loopback so the admin port is not published on all interfaces. +// Override host with ADMIN_HOST (e.g. 0.0.0.0) when the UI must be reachable off-box. func adminListenAddr() string { port := "8081" if p := os.Getenv("ADMIN_PORT"); p != "" { port = p } - if os.Getenv("DISABLE_IPV6") == "1" { - return "0.0.0.0:" + port + host := os.Getenv("ADMIN_HOST") + if host == "" { + host = "127.0.0.1" } - return net.JoinHostPort("", port) + return net.JoinHostPort(host, port) } // proxyHTTPSListenAddr resolves the TLS proxy listen address from env. diff --git a/cmd/helix-proxy/listen_config_test.go b/cmd/helix-proxy/listen_config_test.go index c094009..3a2b029 100644 --- a/cmd/helix-proxy/listen_config_test.go +++ b/cmd/helix-proxy/listen_config_test.go @@ -11,27 +11,27 @@ func TestAdminListenAddr(t *testing.T) { expect string }{ { - name: "defaults ipv6", + name: "defaults loopback", env: map[string]string{}, - expect: ":8081", + expect: "127.0.0.1:8081", }, { name: "custom port", env: map[string]string{"ADMIN_PORT": "9090"}, - expect: ":9090", + expect: "127.0.0.1:9090", }, { - name: "disable ipv6", + name: "disable ipv6 still loopback", env: map[string]string{ "DISABLE_IPV6": "1", }, - expect: "0.0.0.0:8081", + expect: "127.0.0.1:8081", }, { - name: "custom port ipv4 only", + name: "ADMIN_HOST override", env: map[string]string{ - "ADMIN_PORT": "3000", - "DISABLE_IPV6": "1", + "ADMIN_HOST": "0.0.0.0", + "ADMIN_PORT": "3000", }, expect: "0.0.0.0:3000", }, @@ -39,6 +39,7 @@ func TestAdminListenAddr(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Setenv("ADMIN_PORT", "") + t.Setenv("ADMIN_HOST", "") t.Setenv("DISABLE_IPV6", "") for k, v := range tc.env { t.Setenv(k, v) diff --git a/cmd/helix-proxy/main_test.go b/cmd/helix-proxy/main_test.go index 7e66e2a..3b694f9 100644 --- a/cmd/helix-proxy/main_test.go +++ b/cmd/helix-proxy/main_test.go @@ -60,8 +60,9 @@ func TestIsAdminCanManage(t *testing.T) { func TestSingleAdminLoginDevModeSkipsMustChange(t *testing.T) { t.Setenv("PROXY_MODE", "development") + t.Setenv("ADMIN_PASSWORD", "") env := newAPITestServer(t) - resp := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": "password"}) + resp := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": defaultAdminPassword}) if resp.StatusCode != 200 { t.Fatalf("default login failed: %d", resp.StatusCode) } @@ -76,12 +77,13 @@ func TestSingleAdminLoginDevModeSkipsMustChange(t *testing.T) { } func TestSingleAdminLogin(t *testing.T) { - t.Setenv("PROXY_MODE", "") + // Bootstrap path is development-only; production refuses to start without ADMIN_PASSWORD. + t.Setenv("PROXY_MODE", "development") + t.Setenv("ADMIN_PASSWORD", "") env := newAPITestServer(t) st := env.Store - // initial default "password" login returns mustChangePassword - resp := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": "password"}) + resp := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": defaultAdminPassword}) if resp.StatusCode != 200 { t.Fatalf("default login failed: %d", resp.StatusCode) } @@ -91,9 +93,12 @@ func TestSingleAdminLogin(t *testing.T) { MustChangePassword bool `json:"mustChangePassword"` } json.NewDecoder(resp.Body).Decode(&lr) - if lr.Token == "" || lr.User["id"] != float64(1) || !lr.MustChangePassword { + if lr.Token == "" || lr.User["id"] != float64(1) { t.Errorf("default login resp bad: %+v", lr) } + if lr.MustChangePassword { + t.Error("development bootstrap login should not set mustChangePassword") + } // wrong default fails respBad := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": "wrong"}) @@ -104,11 +109,16 @@ func TestSingleAdminLogin(t *testing.T) { // use the returned token to change password (initial case) token := lr.Token authH := func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+token) } - chResp := postJSONAuth(t, env.BaseURL+"/api/users/me/password", map[string]any{"currentPassword": "password", "newPassword": "newpass123"}, authH) + chResp := postJSONAuth(t, env.BaseURL+"/api/users/me/password", map[string]any{"currentPassword": defaultAdminPassword, "newPassword": "newpass123"}, authH) if chResp.StatusCode != 200 { t.Fatalf("initial pw change failed: %d", chResp.StatusCode) } + respOld := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": defaultAdminPassword}) + if respOld.StatusCode != 401 { + t.Errorf("default password after change should 401: %d", respOld.StatusCode) + } + // after change, login with new pw succeeds, no mustChange resp2 := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": "newpass123"}) if resp2.StatusCode != 200 { diff --git a/cmd/helix-proxy/run.go b/cmd/helix-proxy/run.go index 1816691..7967da6 100644 --- a/cmd/helix-proxy/run.go +++ b/cmd/helix-proxy/run.go @@ -40,6 +40,9 @@ func run(ctx context.Context) error { if err != nil { return err } + if err := applyInitialAdminPassword(st); err != nil { + return err + } eng := proxy.NewEngine(st) eng.ReloadFromStore() diff --git a/cmd/helix-proxy/run_test.go b/cmd/helix-proxy/run_test.go index 7686311..4d4cd82 100644 --- a/cmd/helix-proxy/run_test.go +++ b/cmd/helix-proxy/run_test.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "strconv" + "strings" "testing" "time" @@ -70,3 +71,70 @@ func TestRun_EnsureDataDirsFailure(t *testing.T) { t.Fatal("expected error when data dirs cannot be created") } } + +func TestRun_ProductionRefusesDefaultPassword(t *testing.T) { + t.Setenv("PROXY_MODE", "production") + t.Setenv("ADMIN_PASSWORD", "") + t.Setenv("DISABLE_IPV6", "1") + t.Setenv("RENEWAL_INITIAL_DELAY", "1h") + + tmp := t.TempDir() + config.ResetForTest() + t.Setenv("DATA_DIR", tmp) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + err := run(ctx) + if err == nil { + t.Fatal("expected non-dev first boot without ADMIN_PASSWORD to refuse to start") + } + if !strings.Contains(err.Error(), "ADMIN_PASSWORD") { + t.Fatalf("error should mention ADMIN_PASSWORD: %v", err) + } +} + +func TestRun_ProductionWithAdminPasswordStarts(t *testing.T) { + t.Setenv("PROXY_MODE", "production") + t.Setenv("ADMIN_PASSWORD", "prod-admin-not-default") + t.Setenv("DISABLE_IPV6", "1") + t.Setenv("RENEWAL_INITIAL_DELAY", "1h") + + adminPort := pickFreePort(t) + httpPort := pickFreePort(t) + httpsPort := pickFreePort(t) + t.Setenv("ADMIN_PORT", strconv.Itoa(adminPort)) + t.Setenv("PROXY_HTTP_PORT", strconv.Itoa(httpPort)) + t.Setenv("PROXY_HTTPS_PORT", strconv.Itoa(httpsPort)) + + tmp := t.TempDir() + config.ResetForTest() + t.Setenv("DATA_DIR", tmp) + + ctx, cancel := context.WithCancel(context.Background()) + errCh := make(chan error, 1) + go func() { + errCh <- run(ctx) + }() + + adminAddr := "127.0.0.1:" + strconv.Itoa(adminPort) + waitForTCP(t, adminAddr, 5*time.Second) + + resp, err := http.Get("http://" + adminAddr + "/api/") + if err != nil { + t.Fatalf("admin health: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("admin health status: %d", resp.StatusCode) + } + _ = resp.Body.Close() + + cancel() + select { + case err := <-errCh: + if err != nil { + t.Fatalf("run returned error: %v", err) + } + case <-time.After(15 * time.Second): + t.Fatal("run did not exit after context cancel") + } +} diff --git a/cmd/helix-proxy/test_server_test.go b/cmd/helix-proxy/test_server_test.go index c14f9b4..a9eea4c 100644 --- a/cmd/helix-proxy/test_server_test.go +++ b/cmd/helix-proxy/test_server_test.go @@ -37,6 +37,9 @@ func newTestServer(t *testing.T, mode testServerMode) *apiTestEnv { if _, ok := os.LookupEnv("PROXY_MODE"); !ok { t.Setenv("PROXY_MODE", "development") } + if _, ok := os.LookupEnv("ADMIN_PASSWORD"); !ok { + t.Setenv("ADMIN_PASSWORD", testAdminPassword) + } tmp := t.TempDir() config.ResetForTest() t.Setenv("DATA_DIR", tmp) @@ -47,6 +50,9 @@ func newTestServer(t *testing.T, mode testServerMode) *apiTestEnv { if err != nil { t.Fatalf("store: %v", err) } + if err := applyInitialAdminPassword(st); err != nil { + t.Fatalf("admin password: %v", err) + } eng := proxy.NewEngine(st) eng.ReloadFromStore() cm := certificate.NewManager(st) diff --git a/docker-compose.yml b/docker-compose.yml index ea50a0f..6a123cf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,8 @@ services: restart: unless-stopped ports: - "80:80" - - "81:81" + # Admin binds 127.0.0.1; do not publish :81 on untrusted networks. + # - "81:81" - "443:443" volumes: - ./data:/app/data diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 3584a2a..2685de5 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -22,10 +22,11 @@ var ( // Claims for our JWT (minimal, matching original style). type Claims struct { - UserID int `json:"user_id"` - Email string `json:"email"` - Name string `json:"name"` - Roles []string `json:"roles"` + UserID int `json:"user_id"` + Email string `json:"email"` + Name string `json:"name"` + Roles []string `json:"roles"` + Bootstrap bool `json:"bootstrap,omitempty"` jwt.RegisteredClaims } @@ -95,15 +96,26 @@ func NewJWTManager(secret string) *JWTManager { // GenerateToken creates a JWT for the given user (1 hour expiry like typical). func (m *JWTManager) GenerateToken(userID int, email, name string, roles []string) (string, error) { + return m.signToken(userID, email, name, roles, false) +} + +// GenerateBootstrapToken issues a JWT that cannot call mutating admin APIs +// until the default password has been changed. +func (m *JWTManager) GenerateBootstrapToken(userID int, email, name string, roles []string) (string, error) { + return m.signToken(userID, email, name, roles, true) +} + +func (m *JWTManager) signToken(userID int, email, name string, roles []string, bootstrap bool) (string, error) { now := time.Now() if roles == nil { roles = []string{} } claims := Claims{ - UserID: userID, - Email: email, - Name: name, - Roles: roles, + UserID: userID, + Email: email, + Name: name, + Roles: roles, + Bootstrap: bootstrap, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(now.Add(1 * time.Hour)), IssuedAt: jwt.NewNumericDate(now), diff --git a/internal/auth/jwt_test.go b/internal/auth/jwt_test.go index 1589d78..39f1d14 100644 --- a/internal/auth/jwt_test.go +++ b/internal/auth/jwt_test.go @@ -32,6 +32,32 @@ func TestJWTManager_GenerateValidate_Roundtrip(t *testing.T) { } } +func TestJWTManager_BootstrapClaim(t *testing.T) { + m := NewJWTManager("test-secret-123") + token, err := m.GenerateBootstrapToken(1, "admin.com", "Admin", []string{"admin"}) + if err != nil { + t.Fatal(err) + } + claims, err := m.ValidateToken(token) + if err != nil { + t.Fatal(err) + } + if !claims.Bootstrap { + t.Error("bootstrap token missing claim") + } + full, err := m.GenerateToken(1, "admin.com", "Admin", []string{"admin"}) + if err != nil { + t.Fatal(err) + } + fullClaims, err := m.ValidateToken(full) + if err != nil { + t.Fatal(err) + } + if fullClaims.Bootstrap { + t.Error("normal token should not be bootstrap") + } +} + func TestJWTManager_EmptySecretRandomRoundtrip(t *testing.T) { m := NewJWTManager("") // empty -> random in-memory secret token, _ := m.GenerateToken(1, "a@b", "A", nil)