Files
Bridge-and-Join-s/internal/lkgateway/firstrun.go
T

155 lines
9.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lkgateway
// Первичная настройка после установки. Пока в bj нет ни одного пользователя, установщик
// (cmd/bj-installer) оставляет файл с хешем одноразового кода, который показан на экране сервера.
// Все страницы, кроме этой, перенаправляются сюда: по коду создаётся первый администратор, файл
// удаляется, дальше обычный вход и раздел «Настройка». Установки без файла работают как раньше
// (открытый режим до создания первого пользователя).
import (
"context"
"encoding/json"
"html/template"
"log"
"net/http"
"net/url"
"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
NeedKey bool // лицензия ещё не активирована — предложить ввести ключ продукта здесь
ProductKey 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
d.NeedKey = globalRC != nil && !licenseStatus(globalRC).Valid
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"))
d.ProductKey = strings.ToUpper(strings.NewReplacer(" ", "").Replace(strings.TrimSpace(r.FormValue("product_key"))))
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)
// ключ продукта, если его не вводили на сервере: активируем сразу, чтобы модули и обновления заработали
if d.NeedKey && d.ProductKey != "" && globalRC != nil {
actx, acancel := context.WithTimeout(r.Context(), 30*time.Second)
err := LicenseActivate(actx, globalRC, d.ProductKey)
acancel()
if err != nil {
log.Printf("auth: первичная настройка: ключ продукта не принят: %v", err)
http.Redirect(w, r, "/admin/wizard?step=1&flash="+url.QueryEscape("Администратор создан. Ключ продукта не принят: "+err.Error()+". Проверьте его и введите ещё раз."), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/wizard?flash="+url.QueryEscape("Администратор создан, лицензия активирована. Мастер проведёт по остальным шагам — любой можно пропустить."), http.StatusSeeOther)
return
}
if d.NeedKey {
http.Redirect(w, r, "/admin/wizard?step=1&flash="+url.QueryEscape("Администратор создан. Начнём с лицензии: без ключа продукта модули и обновления недоступны (шаг можно пропустить)."), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/wizard", http.StatusSeeOther)
return
}
w.WriteHeader(http.StatusUnauthorized)
}
_ = firstRunTpl.Execute(w, d)
})
}
const firstRunHTML = `<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Первичная настройка · Bridge&amp;Join</title>
<style>
:root{--bg:#f4f5f9;--card:#fff;--text:#1a1d26;--muted:#6b7280;--accent:#3b4fd8;--err:#c62828;--border:#e3e5ee}
@media (prefers-color-scheme:dark){:root{--bg:#141721;--card:#1d2130;--text:#e6e8f0;--muted:#9aa0b3;--accent:#7c8cff;--border:#2b3044}}
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px}
.box{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:28px 30px;width:100%;max-width:420px;box-shadow:0 12px 40px rgba(0,0,0,.08)}
.brand{display:flex;align-items:center;gap:10px;font-size:20px;font-weight:700;margin-bottom:4px}.brand em{font-style:normal;color:var(--accent)}
.sub{color:var(--muted);font-size:12.5px;margin-bottom:14px}
label{display:block;font-size:12px;color:var(--muted);margin:10px 0 4px}
input{width:100%;box-sizing:border-box;padding:9px 11px;border:1px solid var(--border);border-radius:8px;background:transparent;color:var(--text);font-size:14px}
input:focus{outline:2px solid var(--accent);outline-offset:1px;border-color:transparent}
input.code{font-size:20px;letter-spacing:.12em;text-align:center}
button{margin-top:16px;width:100%;padding:10px;border:0;border-radius:8px;background:var(--accent);color:#fff;font-weight:600;font-size:14px;cursor:pointer}
.err{background:rgba(198,40,40,.1);color:var(--err);padding:8px 10px;border-radius:8px;font-size:13px;margin-top:12px}
.hint{color:var(--muted);font-size:12px;margin-top:12px}
code{font-size:12px}
</style></head><body><div class="box">
<div class="brand"><svg width="26" height="26" viewBox="0 0 26 26" fill="none"><path d="M5.5 16 C 9 8.5, 17 8.5, 20.5 16" stroke="#3b4fd8" stroke-width="1.9" stroke-linecap="round"/><circle cx="5.5" cy="16" r="3.1" fill="#3b4fd8"/><circle cx="20.5" cy="16" r="3.1" fill="#e8562a"/></svg><span>Bridge<em>&amp;Join</em></span></div>
<div class="sub">Первичная настройка</div>
<p class="hint" style="margin-top:0">Введите одноразовый код, который установщик показал на экране сервера, и создайте учётную запись администратора. Остальные настройки откроются сразу после этого.</p>
<form method="post" action="/first-run">
<label>Одноразовый код</label><input class="code" type="text" name="code" inputmode="numeric" autocomplete="one-time-code" placeholder="0000-0000" autofocus required>
<label>Логин администратора</label><input type="text" name="login" value="{{.Login}}" autocomplete="username" required>
<label>Имя</label><input type="text" name="name" value="{{.Name}}" autocomplete="name">
<label>Пароль, не короче 10 символов</label><input type="password" name="password" minlength="10" autocomplete="new-password" required>
<label>Повтор пароля</label><input type="password" name="password2" minlength="10" autocomplete="new-password" required>
{{if .NeedKey}}<label>Ключ продукта (BJ-XXXX-XXXX-XXXX) — если не вводили на сервере; можно позже в «Настройка → Лицензия»</label><input type="text" name="product_key" value="{{.ProductKey}}" placeholder="BJ-XXXX-XXXX-XXXX" autocomplete="off" style="text-transform:uppercase">{{end}}
<button type="submit">Создать администратора</button>
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
<div class="hint">Код потерян или истёк: на сервере выполните <code>sudo bj-installer first-run-code</code>.</div>
</form>
</div></body></html>`