Lock bootstrap admin token until password is changed (#6)
Format / gofmt (push) Successful in 17s
CI / Build (push) Successful in 28s
CI / Go Tests (push) Successful in 1m2s

Closes #2.
This commit was merged in pull request #6.
This commit is contained in:
s1d3sw1ped_bot
2026-08-31 22:57:37 -05:00
13 changed files with 445 additions and 40 deletions
+17 -13
View File
@@ -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) { 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.Route("/api", func(r chi.Router) {
r.Use(jwtMgr.Middleware) r.Use(jwtMgr.Middleware)
r.Use(bootstrapLock(st))
// Health (mirrors original /api style) // Health (mirrors original /api style)
r.Get("/", func(w http.ResponseWriter, r *http.Request) { 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). // 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), // Until a non-default password is stored, the default password is accepted and the
// login with "password" succeeds but the response includes mustChangePassword:true // JWT is a bootstrap token (mutating admin API other than change-password is 403).
// (skipped in PROXY_MODE=development for local dev convenience). // After /users/me/password, subsequent logins use the stored bcrypt hash.
// After the admin sets a new password (via /users/me/password), it is bcrypt-hashed and stored in DB. // Email is ignored (always the built-in admin). No 2FA/TOTP.
// Subsequent logins use the stored hash. Email is ignored (always the built-in admin).
// No 2FA/TOTP.
r.Post("/login", func(w http.ResponseWriter, r *http.Request) { r.Post("/login", func(w http.ResponseWriter, r *http.Request) {
var payload struct { var payload struct {
Password string `json:"password"` 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) http.Error(w, err.Error(), 400)
return return
} }
u, ok := st.GetUserByEmail("admin@example.com") u, ok := st.GetUserByEmail(adminEmail)
if !ok { if !ok {
// fallback to seeded values if lookup fails u = store.User{ID: 1, Email: adminEmail, Name: "Admin", Roles: []string{"admin"}}
u = store.User{ID: 1, Email: "admin@example.com", Name: "Admin", Roles: []string{"admin"}}
} }
mustChange := false mustChange := false
authed := false authed := false
bootstrap := false
if u.Password == "" { if u.Password == "" {
// initial / not yet set: default "password" is accepted if payload.Password == defaultAdminPassword {
if payload.Password == "password" {
authed = true authed = true
bootstrap = true
mustChange = !config.IsDevelopment() mustChange = !config.IsDevelopment()
} }
} else if isBcryptPrefix(u.Password) { } 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 authed = true
} }
} else if u.Password == payload.Password { } else if u.Password == payload.Password {
// legacy plain (should not happen after first change)
authed = true authed = true
} }
if !authed { if !authed {
http.Error(w, "invalid credentials", 401) http.Error(w, "invalid credentials", 401)
return 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 { if err != nil {
http.Error(w, "token error", 500) http.Error(w, "token error", 500)
return return
+1 -1
View File
@@ -14,7 +14,7 @@ import (
func (e *apiTestEnv) loginToken(t *testing.T) string { func (e *apiTestEnv) loginToken(t *testing.T) string {
t.Helper() 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 { if resp.StatusCode != http.StatusOK {
t.Fatalf("login: %d %s", resp.StatusCode, readBody(resp)) t.Fatalf("login: %d %s", resp.StatusCode, readBody(resp))
} }
+104
View File
@@ -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)
})
}
}
+167
View File
@@ -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)
}
}
+6 -3
View File
@@ -21,15 +21,18 @@ func applyUmaskFromEnv() {
} }
// adminListenAddr resolves the admin API/UI listen address from env. // 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 { func adminListenAddr() string {
port := "8081" port := "8081"
if p := os.Getenv("ADMIN_PORT"); p != "" { if p := os.Getenv("ADMIN_PORT"); p != "" {
port = p port = p
} }
if os.Getenv("DISABLE_IPV6") == "1" { host := os.Getenv("ADMIN_HOST")
return "0.0.0.0:" + port 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. // proxyHTTPSListenAddr resolves the TLS proxy listen address from env.
+9 -8
View File
@@ -11,27 +11,27 @@ func TestAdminListenAddr(t *testing.T) {
expect string expect string
}{ }{
{ {
name: "defaults ipv6", name: "defaults loopback",
env: map[string]string{}, env: map[string]string{},
expect: ":8081", expect: "127.0.0.1:8081",
}, },
{ {
name: "custom port", name: "custom port",
env: map[string]string{"ADMIN_PORT": "9090"}, 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{ env: map[string]string{
"DISABLE_IPV6": "1", "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{ env: map[string]string{
"ADMIN_PORT": "3000", "ADMIN_HOST": "0.0.0.0",
"DISABLE_IPV6": "1", "ADMIN_PORT": "3000",
}, },
expect: "0.0.0.0:3000", expect: "0.0.0.0:3000",
}, },
@@ -39,6 +39,7 @@ func TestAdminListenAddr(t *testing.T) {
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Setenv("ADMIN_PORT", "") t.Setenv("ADMIN_PORT", "")
t.Setenv("ADMIN_HOST", "")
t.Setenv("DISABLE_IPV6", "") t.Setenv("DISABLE_IPV6", "")
for k, v := range tc.env { for k, v := range tc.env {
t.Setenv(k, v) t.Setenv(k, v)
+16 -6
View File
@@ -60,8 +60,9 @@ func TestIsAdminCanManage(t *testing.T) {
func TestSingleAdminLoginDevModeSkipsMustChange(t *testing.T) { func TestSingleAdminLoginDevModeSkipsMustChange(t *testing.T) {
t.Setenv("PROXY_MODE", "development") t.Setenv("PROXY_MODE", "development")
t.Setenv("ADMIN_PASSWORD", "")
env := newAPITestServer(t) 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 { if resp.StatusCode != 200 {
t.Fatalf("default login failed: %d", resp.StatusCode) t.Fatalf("default login failed: %d", resp.StatusCode)
} }
@@ -76,12 +77,13 @@ func TestSingleAdminLoginDevModeSkipsMustChange(t *testing.T) {
} }
func TestSingleAdminLogin(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) env := newAPITestServer(t)
st := env.Store st := env.Store
// initial default "password" login returns mustChangePassword resp := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": defaultAdminPassword})
resp := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": "password"})
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
t.Fatalf("default login failed: %d", resp.StatusCode) t.Fatalf("default login failed: %d", resp.StatusCode)
} }
@@ -91,9 +93,12 @@ func TestSingleAdminLogin(t *testing.T) {
MustChangePassword bool `json:"mustChangePassword"` MustChangePassword bool `json:"mustChangePassword"`
} }
json.NewDecoder(resp.Body).Decode(&lr) 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) t.Errorf("default login resp bad: %+v", lr)
} }
if lr.MustChangePassword {
t.Error("development bootstrap login should not set mustChangePassword")
}
// wrong default fails // wrong default fails
respBad := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": "wrong"}) 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) // use the returned token to change password (initial case)
token := lr.Token token := lr.Token
authH := func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+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 { if chResp.StatusCode != 200 {
t.Fatalf("initial pw change failed: %d", chResp.StatusCode) 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 // after change, login with new pw succeeds, no mustChange
resp2 := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": "newpass123"}) resp2 := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": "newpass123"})
if resp2.StatusCode != 200 { if resp2.StatusCode != 200 {
+3
View File
@@ -40,6 +40,9 @@ func run(ctx context.Context) error {
if err != nil { if err != nil {
return err return err
} }
if err := applyInitialAdminPassword(st); err != nil {
return err
}
eng := proxy.NewEngine(st) eng := proxy.NewEngine(st)
eng.ReloadFromStore() eng.ReloadFromStore()
+68
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"testing" "testing"
"time" "time"
@@ -70,3 +71,70 @@ func TestRun_EnsureDataDirsFailure(t *testing.T) {
t.Fatal("expected error when data dirs cannot be created") 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")
}
}
+6
View File
@@ -37,6 +37,9 @@ func newTestServer(t *testing.T, mode testServerMode) *apiTestEnv {
if _, ok := os.LookupEnv("PROXY_MODE"); !ok { if _, ok := os.LookupEnv("PROXY_MODE"); !ok {
t.Setenv("PROXY_MODE", "development") t.Setenv("PROXY_MODE", "development")
} }
if _, ok := os.LookupEnv("ADMIN_PASSWORD"); !ok {
t.Setenv("ADMIN_PASSWORD", testAdminPassword)
}
tmp := t.TempDir() tmp := t.TempDir()
config.ResetForTest() config.ResetForTest()
t.Setenv("DATA_DIR", tmp) t.Setenv("DATA_DIR", tmp)
@@ -47,6 +50,9 @@ func newTestServer(t *testing.T, mode testServerMode) *apiTestEnv {
if err != nil { if err != nil {
t.Fatalf("store: %v", err) t.Fatalf("store: %v", err)
} }
if err := applyInitialAdminPassword(st); err != nil {
t.Fatalf("admin password: %v", err)
}
eng := proxy.NewEngine(st) eng := proxy.NewEngine(st)
eng.ReloadFromStore() eng.ReloadFromStore()
cm := certificate.NewManager(st) cm := certificate.NewManager(st)
+2 -1
View File
@@ -5,7 +5,8 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- "80:80" - "80:80"
- "81:81" # Admin binds 127.0.0.1; do not publish :81 on untrusted networks.
# - "81:81"
- "443:443" - "443:443"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
+20 -8
View File
@@ -22,10 +22,11 @@ var (
// Claims for our JWT (minimal, matching original style). // Claims for our JWT (minimal, matching original style).
type Claims struct { type Claims struct {
UserID int `json:"user_id"` UserID int `json:"user_id"`
Email string `json:"email"` Email string `json:"email"`
Name string `json:"name"` Name string `json:"name"`
Roles []string `json:"roles"` Roles []string `json:"roles"`
Bootstrap bool `json:"bootstrap,omitempty"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
@@ -95,15 +96,26 @@ func NewJWTManager(secret string) *JWTManager {
// GenerateToken creates a JWT for the given user (1 hour expiry like typical). // 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) { 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() now := time.Now()
if roles == nil { if roles == nil {
roles = []string{} roles = []string{}
} }
claims := Claims{ claims := Claims{
UserID: userID, UserID: userID,
Email: email, Email: email,
Name: name, Name: name,
Roles: roles, Roles: roles,
Bootstrap: bootstrap,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(1 * time.Hour)), ExpiresAt: jwt.NewNumericDate(now.Add(1 * time.Hour)),
IssuedAt: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now),
+26
View File
@@ -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) { func TestJWTManager_EmptySecretRandomRoundtrip(t *testing.T) {
m := NewJWTManager("") // empty -> random in-memory secret m := NewJWTManager("") // empty -> random in-memory secret
token, _ := m.GenerateToken(1, "a@b", "A", nil) token, _ := m.GenerateToken(1, "a@b", "A", nil)