Files
Bridge-and-Join-s/cmd/bj-license-server/zetitkey.go
T
zuevav 86aa452ed6 feat(license): сервер лицензий lic.zetit.ru — кабинет, онлайн-активация и защита
- bj-license-server: активация по коду и продление токенов (API /v1), кабинет
  администратора (клиенты, лицензии, установки, журнал, администраторы)
- защита: доверенные IP установок, jail fail2ban api/admin, раздел «Защита»
  с режимом обороны, белым списком и сроками блокировок (root-помощник fw-apply)
- доступ к кабинету: свой адрес вместо /admin, вход только из белого списка,
  второй фактор — TOTP или подтверждение в «ЗЕТИТ Ключ» (push/код)
- deploy/license: unit-файлы, fail2ban, path/timer помощника; контракт API в docs

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 19:41:11 +03:00

390 lines
15 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 main
// Второй фактор через «ЗЕТИТ Ключ» — так же, как вход в ZETIT Connect. После пароля сервер лицензий
// просит Ключ подтвердить вход: push-уведомление на телефон администратора или код из приложения.
// Статус опрашивает только сервер; вход разрешается лишь при approved (push) или success (код).
// API: Zetit_key/docs/EXTERNAL_APP_2FA.md, способ 1 (приложение с X-Connect-Key).
//
// Действует для администраторов с указанным e-mail, когда интеграция включена в разделе «Защита».
// Остальные входят по паролю и, если подключён, по коду приложения-аутентификатора (access.go).
// Аварийно выключить по SSH: bj-license-server admin-config -zetit-key off (сервер остановлен).
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
const (
zetitKeyDefaultURL = "https://key.zetit.ru/api/v1/connect-auth"
zkPendingTTL = 5 * time.Minute // push живёт 60 секунд, код из приложения можно ввести и позже
)
// ZetitKeyConf — настройки интеграции с «ЗЕТИТ Ключ».
type ZetitKeyConf struct {
Enabled bool `json:"enabled,omitempty"`
URL string `json:"url,omitempty"` // по умолчанию https://key.zetit.ru/api/v1/connect-auth
AppKey string `json:"app_key,omitempty"` // X-Connect-Key приложения из панели Ключа
}
func (c ZetitKeyConf) active() bool { return c.Enabled && c.AppKey != "" }
var zkClient = &http.Client{Timeout: 8 * time.Second}
func zkCall(c ZetitKeyConf, method, path string, body, out any) error {
var rd io.Reader
if body != nil {
b, _ := json.Marshal(body)
rd = bytes.NewReader(b)
}
req, err := http.NewRequest(method, strings.TrimRight(pickStr(c.URL, zetitKeyDefaultURL), "/")+path, rd)
if err != nil {
return err
}
req.Header.Set("X-Connect-Key", c.AppKey)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := zkClient.Do(req)
if err != nil {
return fmt.Errorf("сервер недоступен: %v", err)
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("ответ %d: %s", resp.StatusCode, clip(string(data), 200))
}
if out != nil {
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("ответ не разобран: %v", err)
}
}
return nil
}
type zkLookupResult struct {
Exists bool `json:"exists"`
Has2FA bool `json:"has_2fa"`
FullName string `json:"full_name"`
}
type zkVerifyResult struct {
RequestID string `json:"request_id"`
Status string `json:"status"`
NeedsSetup bool `json:"needs_setup"`
}
func zkLookup(c ZetitKeyConf, email string) (zkLookupResult, error) {
var r zkLookupResult
return r, zkCall(c, http.MethodGet, "/lookup?email="+url.QueryEscape(email), nil, &r)
}
func zkStart(c ZetitKeyConf, email, ip string) (zkVerifyResult, error) {
var r zkVerifyResult
return r, zkCall(c, http.MethodPost, "/verify", map[string]string{"email": email, "source_ip": ip}, &r)
}
func zkStatus(c ZetitKeyConf, requestID string) (string, error) {
var r zkVerifyResult
err := zkCall(c, http.MethodGet, "/verify/"+url.PathEscape(requestID), nil, &r)
return r.Status, err
}
func zkCode(c ZetitKeyConf, email, code string) (bool, error) {
var r struct {
Success bool `json:"success"`
}
err := zkCall(c, http.MethodPost, "/verify-totp", map[string]string{"email": email, "code": code}, &r)
return r.Success, err
}
// ---- вход с подтверждением ----
// pendingLogin — пароль проверен, ждём подтверждения в Ключе.
type pendingLogin struct {
Token, Login, Email, IP, RequestID, Next string
Created time.Time
}
type pendingLogins struct {
mu sync.Mutex
m map[string]*pendingLogin
}
var zkPending = &pendingLogins{m: map[string]*pendingLogin{}}
func (p *pendingLogins) put(pl *pendingLogin) {
p.mu.Lock()
defer p.mu.Unlock()
for k, v := range p.m {
if time.Since(v.Created) > zkPendingTTL {
delete(p.m, k)
}
}
p.m[pl.Token] = pl
}
// get — ожидающий вход по токену с того же адреса.
func (p *pendingLogins) get(token, ip string) *pendingLogin {
p.mu.Lock()
defer p.mu.Unlock()
pl := p.m[token]
if pl == nil || pl.IP != ip || time.Since(pl.Created) > zkPendingTTL {
return nil
}
return pl
}
func (p *pendingLogins) drop(token string) {
p.mu.Lock()
delete(p.m, token)
p.mu.Unlock()
}
func (srv *server) zetitKey() ZetitKeyConf {
var c ZetitKeyConf
srv.st.read(func(s *State) { c = s.ZetitKey })
return c
}
// finishLogin — второй фактор пройден (или не требуется): сессия и переход в кабинет.
func (srv *server) finishLogin(w http.ResponseWriter, r *http.Request, login, ip, next string, totpStep int64, how string) {
srv.fails.reset(ip + "|" + strings.ToLower(login))
sess := srv.sessions.create(login)
_ = srv.st.write(func(s *State) error {
if u := s.user(login); u != nil {
u.LastLogin = time.Now()
sess.Login = u.Login
if totpStep > 0 {
u.TOTPLast = totpStep
}
}
s.addEvent("login", "", "", login, ip, "вход в кабинет"+how)
return nil
})
srv.setSessionCookie(w, r, sess.Token)
http.Redirect(w, r, next, http.StatusSeeOther)
}
func (srv *server) renderLoginErr(w http.ResponseWriter, login, next, msg string, status int) {
p := page{Title: "Вход", Err: msg, Data: map[string]string{"Next": next, "Login": login}}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_ = srv.tpl.ExecuteTemplate(w, "login", p)
}
// loginReject — отказ на втором факторе: счётчик неудач, строка для fail2ban, журнал.
func (srv *server) loginReject(w http.ResponseWriter, login, ip, next, eventText, msg string) {
srv.fails.add(ip + "|" + strings.ToLower(login))
srv.fails.add(ip)
log.Printf("login failed: user=%q ip=%s", login, ip) // строку ловит fail2ban (deploy/license/fail2ban)
time.Sleep(600 * time.Millisecond)
_ = srv.st.write(func(s *State) error {
s.addEvent("login_failed", "", "", login, ip, eventText)
return nil
})
srv.renderLoginErr(w, login, next, msg, http.StatusUnauthorized)
}
func (srv *server) renderLogin2FA(w http.ResponseWriter, pl *pendingLogin, errMsg string) {
left := 60 - int(time.Since(pl.Created).Seconds())
if left < 0 {
left = 0
}
p := page{Title: "Подтверждение входа", Err: errMsg, Data: map[string]any{"Token": pl.Token, "Email": pl.Email, "Left": left}}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = srv.tpl.ExecuteTemplate(w, "login2fa", p)
}
// login2FAStatus — GET /admin/login/2fa/status?t=…: статус push, опрос идёт с сервера.
func (srv *server) login2FAStatus(w http.ResponseWriter, r *http.Request) {
pl := zkPending.get(r.URL.Query().Get("t"), clientIP(r))
status := "expired"
if pl != nil && time.Since(pl.Created) <= 70*time.Second {
if st, err := zkStatus(srv.zetitKey(), pl.RequestID); err == nil {
status = st
} else {
status = "pending" // сбой связи — страница спросит ещё раз
}
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, map[string]string{"status": status})
}
// login2FAFinish — POST /admin/login/2fa/finish: страница увидела ответ, сервер перепроверяет его сам.
func (srv *server) login2FAFinish(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
ip := clientIP(r)
pl := zkPending.get(r.PostFormValue("t"), ip)
if pl == nil {
srv.renderLoginErr(w, "", "/admin/", "Время подтверждения истекло, войдите заново.", http.StatusUnauthorized)
return
}
st, err := zkStatus(srv.zetitKey(), pl.RequestID)
switch {
case err != nil:
srv.renderLogin2FA(w, pl, "ЗЕТИТ Ключ не ответил: "+err.Error()+". Введите код из приложения.")
case st == "approved":
zkPending.drop(pl.Token)
srv.finishLogin(w, r, pl.Login, ip, pl.Next, 0, " (ЗЕТИТ Ключ, push)")
case st == "denied":
zkPending.drop(pl.Token)
srv.loginReject(w, pl.Login, ip, pl.Next, "вход отклонён в ЗЕТИТ Ключ", "Вход отклонён в приложении «ЗЕТИТ Ключ».")
case st == "expired":
srv.renderLogin2FA(w, pl, "Время подтверждения истекло. Введите код из приложения или войдите заново.")
default:
srv.renderLogin2FA(w, pl, "Вход ещё не подтверждён в приложении.")
}
}
// login2FACode — POST /admin/login/2fa/code: код из приложения «ЗЕТИТ Ключ».
func (srv *server) login2FACode(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
ip := clientIP(r)
pl := zkPending.get(r.PostFormValue("t"), ip)
if pl == nil {
srv.renderLoginErr(w, "", "/admin/", "Время подтверждения истекло, войдите заново.", http.StatusUnauthorized)
return
}
if srv.fails.blocked(ip+"|"+strings.ToLower(pl.Login)) || srv.fails.blocked(ip) {
zkPending.drop(pl.Token)
srv.renderLoginErr(w, pl.Login, pl.Next, "Слишком много неудачных попыток. Вход заблокирован на "+durRu(int64(srv.fails.window()/time.Second))+".", http.StatusTooManyRequests)
return
}
ok, err := zkCode(srv.zetitKey(), pl.Email, strings.TrimSpace(r.PostFormValue("code")))
switch {
case err != nil:
srv.renderLogin2FA(w, pl, "Не удалось проверить код: "+err.Error())
case ok:
zkPending.drop(pl.Token)
srv.finishLogin(w, r, pl.Login, ip, pl.Next, 0, " (ЗЕТИТ Ключ, код)")
default:
srv.fails.add(ip + "|" + strings.ToLower(pl.Login))
srv.fails.add(ip)
log.Printf("login failed: user=%q ip=%s", pl.Login, ip)
_ = srv.st.write(func(s *State) error {
s.addEvent("login_failed", "", "", pl.Login, ip, "неверный код ЗЕТИТ Ключ")
return nil
})
srv.renderLogin2FA(w, pl, "Код неверен или уже использован.")
}
}
// ---- настройки ----
func validEmail(e string) bool {
at := strings.Index(e, "@")
return len(e) <= 120 && at > 0 && at < len(e)-3 && strings.Contains(e[at:], ".") && !strings.ContainsAny(e, " <>\"'")
}
// userEmail — POST /admin/users/{login}/email: e-mail администратора в «ЗЕТИТ Ключ».
func (srv *server) userEmail(w http.ResponseWriter, r *http.Request, sess *session) {
login, email := r.PathValue("login"), strings.TrimSpace(r.PostFormValue("email"))
if email != "" && !validEmail(email) {
back(w, r, "/admin/users", "", "неверный e-mail")
return
}
var bad string
err := srv.st.write(func(s *State) error {
u := s.user(login)
if u == nil {
return fmt.Errorf("администратор не найден")
}
if strings.EqualFold(u.Login, sess.Login) && email == "" && s.ZetitKey.active() {
bad = "вход через ЗЕТИТ Ключ включён: без e-mail вы войдёте только по паролю. Сначала выключите интеграцию в «Защите»."
return nil
}
u.Email = email
s.addEvent("user_email", "", "", sess.Login, clientIP(r), "e-mail администратора "+u.Login+": "+pickStr(email, "удалён"))
return nil
})
if err != nil || bad != "" {
back(w, r, "/admin/users", "", pickStr(bad, fmt.Sprint(err)))
return
}
back(w, r, "/admin/users", "E-mail "+login+" сохранён", "")
}
// securityZetitKey — POST /admin/security/zetit-key: адрес API, ключ приложения, включение, проверка.
func (srv *server) securityZetitKey(w http.ResponseWriter, r *http.Request, sess *session) {
op := r.PostFormValue("op")
var conf ZetitKeyConf
var myEmail string
srv.st.read(func(s *State) {
conf = s.ZetitKey
if u := s.user(sess.Login); u != nil {
myEmail = u.Email
}
})
if u := strings.TrimSpace(r.PostFormValue("url")); u != "" {
pu, err := url.Parse(u)
if err != nil || pu.Scheme != "https" || pu.Host == "" {
back(w, r, "/admin/security", "", "адрес API ЗЕТИТ Ключ: https://…")
return
}
conf.URL = strings.TrimRight(u, "/")
}
if k := strings.TrimSpace(r.PostFormValue("app_key")); k != "" {
conf.AppKey = k
}
check := func() (string, bool) {
switch {
case conf.AppKey == "":
return "не задан ключ приложения", false
case myEmail == "":
return "укажите свой e-mail на странице «Администраторы»", false
}
lk, err := zkLookup(conf, myEmail)
switch {
case err != nil:
return "ЗЕТИТ Ключ: " + err.Error(), false
case !lk.Exists:
return myEmail + " не найден в ЗЕТИТ Ключ", false
case !lk.Has2FA:
return myEmail + " найден в ЗЕТИТ Ключ, но приложение не привязано", false
}
return "ЗЕТИТ Ключ отвечает: " + myEmail + pickStr(" ("+lk.FullName+")", "") + " найден, приложение привязано", true
}
msg, ok := "", true
if op == "test" || r.PostFormValue("enabled") == "1" {
msg, ok = check()
}
switch {
case op == "test":
case r.PostFormValue("enabled") == "1" && !ok:
back(w, r, "/admin/security", "", "Не включено: "+msg)
return
default:
conf.Enabled = r.PostFormValue("enabled") == "1"
}
err := srv.st.write(func(s *State) error {
s.ZetitKey = conf
state := "выключен"
if conf.Enabled {
state = "включён"
}
s.addEvent("zetit_key", "", "", sess.Login, clientIP(r), "настройки ЗЕТИТ Ключ сохранены, вход через Ключ "+state)
return nil
})
switch {
case err != nil:
back(w, r, "/admin/security", "", err.Error())
case op == "test" && !ok:
back(w, r, "/admin/security", "", "Проверка: "+msg)
case op == "test":
back(w, r, "/admin/security", "Проверка: "+msg, "")
case conf.Enabled:
back(w, r, "/admin/security", "Вход через ЗЕТИТ Ключ включён. "+msg+".", "")
default:
back(w, r, "/admin/security", "Настройки ЗЕТИТ Ключ сохранены, вход через Ключ выключен.", "")
}
}