86aa452ed6
- 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>
390 lines
15 KiB
Go
390 lines
15 KiB
Go
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", "Настройки ЗЕТИТ Ключ сохранены, вход через Ключ выключен.", "")
|
||
}
|
||
}
|