13070a275d
Closes #2: bootstrap JWTs cannot mutate admin APIs except change-password, production requires ADMIN_PASSWORD on first boot, admin binds loopback.
105 lines
2.7 KiB
Go
105 lines
2.7 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|