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) {
|
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
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
// 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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user