Lock bootstrap admin token until password is changed.
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:
+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
|
||||
|
||||
Reference in New Issue
Block a user