Lock bootstrap admin token until password is changed.
Format / gofmt (push) Successful in 7s
CI / Build (push) Successful in 14s
Format / gofmt (pull_request) Successful in 7s
CI / Build (pull_request) Successful in 13s
CI / Go Tests (push) Successful in 50s
CI / Go Tests (pull_request) Successful in 48s

Closes #2: bootstrap JWTs cannot mutate admin APIs except change-password, production requires ADMIN_PASSWORD on first boot, admin binds loopback.
This commit is contained in:
s1d3sw1ped_bot
2026-09-01 03:54:09 +00:00
parent 6aabb76c16
commit 13070a275d
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) {
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