package lkgateway // Первичная настройка после установки. Пока в bj нет ни одного пользователя, установщик // (cmd/bj-installer) оставляет файл с хешем одноразового кода, который показан на экране сервера. // Все страницы, кроме этой, перенаправляются сюда: по коду создаётся первый администратор, файл // удаляется, дальше обычный вход и раздел «Настройка». Установки без файла работают как раньше // (открытый режим до создания первого пользователя). import ( "encoding/json" "html/template" "log" "net/http" "os" "strings" "time" "golang.org/x/crypto/bcrypt" ) const firstRunFile = "/var/lib/bj/.bj/first-run.json" type firstRunState struct { CodeHash string `json:"code_hash"` CreatedAt time.Time `json:"created_at"` } // firstRunPending — ждём первичной настройки: пользователей нет, установщик оставил код. func firstRunPending() bool { if globalUsers.Count() > 0 { return false } _, err := os.Stat(firstRunFile) return err == nil } type firstRunData struct { Error, Login, Name string } var firstRunTpl = template.Must(template.New("first-run").Parse(firstRunHTML)) func registerFirstRun(mux *http.ServeMux) { mux.HandleFunc("/first-run", func(w http.ResponseWriter, r *http.Request) { if !firstRunPending() { http.Redirect(w, r, "/login", http.StatusSeeOther) return } var d firstRunData w.Header().Set("Cache-Control", "no-store") if r.Method == http.MethodPost { ip := clientIP(r) key := "first-run|" + ip d.Login, d.Name = strings.TrimSpace(r.FormValue("login")), strings.TrimSpace(r.FormValue("name")) pw, pw2 := r.FormValue("password"), r.FormValue("password2") code := strings.NewReplacer("-", "", " ", "").Replace(strings.TrimSpace(r.FormValue("code"))) var st firstRunState raw, readErr := os.ReadFile(firstRunFile) if readErr == nil { _ = json.Unmarshal(raw, &st) } switch { case loginFailCount(key) >= 8: d.Error = "Слишком много неудачных попыток. Подождите 15 минут." case readErr != nil || st.CodeHash == "" || bcrypt.CompareHashAndPassword([]byte(st.CodeHash), []byte(code)) != nil: loginFailAdd(key) time.Sleep(700 * time.Millisecond) log.Printf("auth: первичная настройка: неверный код с %s", ip) d.Error = "Неверный одноразовый код" case d.Login == "" || strings.ContainsAny(d.Login, " /\\@\"'<>"): d.Error = "Логин пустой или содержит недопустимые символы" case len(pw) < 10 || pw != pw2: d.Error = "Пароль не короче 10 символов и должен совпадать с повтором" default: hash, err := hashPassword(pw) if err != nil { d.Error = err.Error() break } now := time.Now() u := &User{Login: d.Login, Name: d.Name, Role: "admin", Source: "local", PassHash: hash, Active: true, CreatedAt: now, LastLogin: now} if err := globalUsers.Put(u); err != nil { d.Error = "Не удалось сохранить пользователя: " + err.Error() break } _ = os.Remove(firstRunFile) loginFailReset(key) s := globalSessions.New(u.Login, ip, r.UserAgent()) http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: s.Token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: r.TLS != nil, MaxAge: int(sessionMaxLife.Seconds())}) log.Printf("auth: первичная настройка: создан администратор %s с %s", u.Login, ip) http.Redirect(w, r, "/admin/setup", http.StatusSeeOther) return } w.WriteHeader(http.StatusUnauthorized) } _ = firstRunTpl.Execute(w, d) }) } const firstRunHTML = `
Введите одноразовый код, который установщик показал на экране сервера, и создайте учётную запись администратора. Остальные настройки откроются сразу после этого.