Lock bootstrap admin token until password is changed (#6)
Closes #2.
This commit was merged in pull request #6.
This commit is contained in:
+17
-13
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+20
-8
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user