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