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>
513 lines
19 KiB
Go
513 lines
19 KiB
Go
package main
|
|
|
|
// Доступ к кабинету: свой адрес вместо /admin, вход только из белого списка администраторов и
|
|
// второй фактор (TOTP, RFC 6238).
|
|
//
|
|
// Маршруты кабинета внутри остаются /admin/…: adminGate переписывает внешний адрес (State.AdminPath)
|
|
// во внутренний, на прямые обращения к /admin при своём адресе отвечает 404 и подменяет Location в
|
|
// ответах. Ссылки в шаблонах строятся функцией {{adm}}. При AdminAllowOnly кабинет и страница входа
|
|
// видны только адресам белого списка администраторов, остальным — 404. API /v1 не затрагивается.
|
|
//
|
|
// По SSH: bj-license-server admin-info — адрес и настройки доступа; fw-apply -off — аварийно открыть
|
|
// кабинет всем и выключить оборону; admin-config — изменить доступ при остановленном сервере.
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha1"
|
|
"encoding/base32"
|
|
"encoding/binary"
|
|
"flag"
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"rsc.io/qr"
|
|
)
|
|
|
|
const (
|
|
adminDefault = "/admin"
|
|
adminOpenFile = "admin-open" // создаёт fw-apply -off: кабинет открыт всем до повторного включения ограничения
|
|
totpIssuer = "BJ License (lic.zetit.ru)"
|
|
publicHost = "lic.zetit.ru"
|
|
)
|
|
|
|
var adminSlugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{7,63}$`)
|
|
|
|
// adminBase — внешний адрес кабинета: "/admin" или "/<AdminPath>".
|
|
func (srv *server) adminBase() string {
|
|
base := adminDefault
|
|
srv.st.read(func(s *State) {
|
|
if s.AdminPath != "" {
|
|
base = "/" + s.AdminPath
|
|
}
|
|
})
|
|
return base
|
|
}
|
|
|
|
func (srv *server) adminOpen() bool {
|
|
_, err := os.Stat(filepath.Join(srv.dataDir(), adminOpenFile))
|
|
return err == nil
|
|
}
|
|
|
|
// adminIPBlocked — кабинет ограничен белым списком, а адрес в него не входит.
|
|
func (srv *server) adminIPBlocked(ip string) bool {
|
|
blocked := false
|
|
srv.st.read(func(s *State) { blocked = s.AdminAllowOnly && !s.allowCovers(ip, false) })
|
|
return blocked && !srv.adminOpen()
|
|
}
|
|
|
|
// locRewriter — Location: /admin/… в ответах кабинета заменяется текущим адресом кабинета.
|
|
type locRewriter struct {
|
|
http.ResponseWriter
|
|
srv *server
|
|
done bool
|
|
}
|
|
|
|
func (lw *locRewriter) fix() {
|
|
if lw.done {
|
|
return
|
|
}
|
|
lw.done = true
|
|
h := lw.Header()
|
|
if l := h.Get("Location"); l == adminDefault || strings.HasPrefix(l, adminDefault+"/") || strings.HasPrefix(l, adminDefault+"?") {
|
|
h.Set("Location", lw.srv.adminBase()+strings.TrimPrefix(l, adminDefault))
|
|
}
|
|
}
|
|
|
|
func (lw *locRewriter) WriteHeader(code int) {
|
|
lw.fix()
|
|
lw.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func (lw *locRewriter) Write(b []byte) (int, error) {
|
|
lw.fix()
|
|
return lw.ResponseWriter.Write(b)
|
|
}
|
|
|
|
// adminGate — внешний адрес кабинета, 404 на /admin при своём адресе, ограничение белым списком.
|
|
func (srv *server) adminGate(h http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
base, p := srv.adminBase(), r.URL.Path
|
|
under := func(prefix string) bool { return p == prefix || strings.HasPrefix(p, prefix+"/") }
|
|
switch {
|
|
case base != adminDefault && under(adminDefault):
|
|
http.NotFound(w, r)
|
|
return
|
|
case base != adminDefault && under(base):
|
|
r2 := r.Clone(r.Context())
|
|
r2.URL.Path = adminDefault + strings.TrimPrefix(p, base)
|
|
r2.URL.RawPath = ""
|
|
r = r2
|
|
case !under(adminDefault):
|
|
h.ServeHTTP(w, r) // API, healthz, главная
|
|
return
|
|
}
|
|
if srv.adminIPBlocked(clientIP(r)) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
h.ServeHTTP(&locRewriter{ResponseWriter: w, srv: srv}, r)
|
|
})
|
|
}
|
|
|
|
func (srv *server) setSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
|
|
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: token, Path: srv.adminBase(), HttpOnly: true, Secure: isHTTPS(r),
|
|
SameSite: http.SameSiteStrictMode, MaxAge: int(sessionMax.Seconds())})
|
|
}
|
|
|
|
// ---- TOTP ----
|
|
|
|
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
|
|
|
|
func newTOTPSecret() string {
|
|
b := make([]byte, 20)
|
|
_, _ = rand.Read(b)
|
|
return b32.EncodeToString(b)
|
|
}
|
|
|
|
func totpAt(secret string, counter int64) (string, bool) {
|
|
key, err := b32.DecodeString(strings.ToUpper(secret))
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
var msg [8]byte
|
|
binary.BigEndian.PutUint64(msg[:], uint64(counter))
|
|
m := hmac.New(sha1.New, key)
|
|
m.Write(msg[:])
|
|
sum := m.Sum(nil)
|
|
off := sum[len(sum)-1] & 0x0f
|
|
v := (binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff) % 1000000
|
|
return fmt.Sprintf("%06d", v), true
|
|
}
|
|
|
|
// totpCheck — код текущего, предыдущего или следующего 30-секундного шага, ещё не использованный
|
|
// (шаг больше last). Возвращает шаг, чтобы запомнить его и не принять тот же код повторно.
|
|
func totpCheck(secret, code string, last int64, now time.Time) (int64, bool) {
|
|
code = strings.ReplaceAll(strings.TrimSpace(code), " ", "")
|
|
if len(code) != 6 {
|
|
return 0, false
|
|
}
|
|
step := now.Unix() / 30
|
|
for d := int64(-1); d <= 1; d++ {
|
|
c := step + d
|
|
if c <= last {
|
|
continue
|
|
}
|
|
if want, ok := totpAt(secret, c); ok && hmac.Equal([]byte(want), []byte(code)) {
|
|
return c, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func totpURI(login, secret string) string {
|
|
return "otpauth://totp/" + url.PathEscape(totpIssuer+":"+login) + "?secret=" + secret +
|
|
"&issuer=" + url.QueryEscape(totpIssuer) + "&algorithm=SHA1&digits=6&period=30"
|
|
}
|
|
|
|
// qrSVG — QR-код строкой SVG (с полем в 4 модуля).
|
|
func qrSVG(text string) template.HTML {
|
|
c, err := qr.Encode(text, qr.M)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
n := c.Size + 8
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" width="220" height="220" shape-rendering="crispEdges" role="img" aria-label="QR-код для приложения"><rect width="%d" height="%d" fill="#fff"/><path fill="#000" d="`, n, n, n, n)
|
|
for y := 0; y < c.Size; y++ {
|
|
for x := 0; x < c.Size; x++ {
|
|
if c.Black(x, y) {
|
|
fmt.Fprintf(&b, "M%d %dh1v1h-1z", x+4, y+4)
|
|
}
|
|
}
|
|
}
|
|
b.WriteString(`"/></svg>`)
|
|
return template.HTML(b.String())
|
|
}
|
|
|
|
func groupBy4(s string) string {
|
|
var parts []string
|
|
for i := 0; i < len(s); i += 4 {
|
|
parts = append(parts, s[i:min(i+4, len(s))])
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
// ---- страницы ----
|
|
|
|
type totpPageData struct {
|
|
Enabled bool
|
|
SecretGrouped string
|
|
URI template.URL
|
|
QR template.HTML
|
|
}
|
|
|
|
// meTOTPPage — GET /admin/me/2fa: подключение или отключение второго фактора.
|
|
func (srv *server) meTOTPPage(w http.ResponseWriter, r *http.Request, sess *session) {
|
|
var d totpPageData
|
|
srv.st.read(func(s *State) {
|
|
if u := s.user(sess.Login); u != nil {
|
|
d.Enabled = u.TOTPSecret != ""
|
|
}
|
|
})
|
|
if !d.Enabled {
|
|
srv.sessions.mu.Lock()
|
|
if sess.PendingTOTP == "" {
|
|
sess.PendingTOTP = newTOTPSecret()
|
|
}
|
|
secret := sess.PendingTOTP
|
|
srv.sessions.mu.Unlock()
|
|
uri := totpURI(sess.Login, secret)
|
|
d.SecretGrouped, d.URI, d.QR = groupBy4(secret), template.URL(uri), qrSVG(uri)
|
|
}
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
srv.render(w, r, sess, "totp", "Второй фактор", "users", d)
|
|
}
|
|
|
|
func (srv *server) meTOTPEnable(w http.ResponseWriter, r *http.Request, sess *session) {
|
|
srv.sessions.mu.Lock()
|
|
secret := sess.PendingTOTP
|
|
srv.sessions.mu.Unlock()
|
|
if secret == "" {
|
|
back(w, r, "/admin/me/2fa", "", "обновите страницу и добавьте ключ в приложение заново")
|
|
return
|
|
}
|
|
step, ok := totpCheck(secret, r.PostFormValue("code"), 0, time.Now())
|
|
if !ok {
|
|
back(w, r, "/admin/me/2fa", "", "код не подошёл: проверьте, что время на телефоне точное, и введите текущий код")
|
|
return
|
|
}
|
|
err := srv.st.write(func(s *State) error {
|
|
u := s.user(sess.Login)
|
|
if u == nil {
|
|
return fmt.Errorf("администратор не найден")
|
|
}
|
|
u.TOTPSecret, u.TOTPLast = secret, step
|
|
s.addEvent("totp_on", "", "", sess.Login, clientIP(r), "подключён второй фактор входа")
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
back(w, r, "/admin/me/2fa", "", err.Error())
|
|
return
|
|
}
|
|
srv.sessions.mu.Lock()
|
|
sess.PendingTOTP = ""
|
|
srv.sessions.mu.Unlock()
|
|
back(w, r, "/admin/me/2fa", "Второй фактор подключён: при входе теперь нужен код из приложения.", "")
|
|
}
|
|
|
|
func (srv *server) meTOTPDisable(w http.ResponseWriter, r *http.Request, sess *session) {
|
|
var bad string
|
|
err := srv.st.write(func(s *State) error {
|
|
u := s.user(sess.Login)
|
|
if u == nil || u.TOTPSecret == "" {
|
|
bad = "второй фактор не подключён"
|
|
return nil
|
|
}
|
|
if _, ok := totpCheck(u.TOTPSecret, r.PostFormValue("code"), 0, time.Now()); !ok {
|
|
bad = "код не подошёл"
|
|
return nil
|
|
}
|
|
u.TOTPSecret, u.TOTPLast = "", 0
|
|
s.addEvent("totp_off", "", "", sess.Login, clientIP(r), "второй фактор входа отключён")
|
|
return nil
|
|
})
|
|
if err != nil || bad != "" {
|
|
back(w, r, "/admin/me/2fa", "", pickStr(bad, fmt.Sprint(err)))
|
|
return
|
|
}
|
|
back(w, r, "/admin/me/2fa", "Второй фактор отключён.", "")
|
|
}
|
|
|
|
// userTOTPReset — POST /admin/users/{login}/2fa-reset: сброс второго фактора другого администратора.
|
|
func (srv *server) userTOTPReset(w http.ResponseWriter, r *http.Request, sess *session) {
|
|
login := r.PathValue("login")
|
|
if strings.EqualFold(login, sess.Login) {
|
|
back(w, r, "/admin/users", "", "свой второй фактор отключается на странице «Мой второй фактор» с кодом из приложения")
|
|
return
|
|
}
|
|
err := srv.st.write(func(s *State) error {
|
|
u := s.user(login)
|
|
if u == nil {
|
|
return fmt.Errorf("администратор не найден")
|
|
}
|
|
u.TOTPSecret, u.TOTPLast = "", 0
|
|
s.addEvent("totp_reset", "", "", sess.Login, clientIP(r), "сброшен второй фактор администратора "+u.Login)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
back(w, r, "/admin/users", "", err.Error())
|
|
return
|
|
}
|
|
srv.sessions.dropUser(login, "")
|
|
back(w, r, "/admin/users", "Второй фактор "+login+" сброшен, его сессии завершены. При следующем входе хватит пароля.", "")
|
|
}
|
|
|
|
// securityAccess — POST /admin/security/access: адрес кабинета и ограничение белым списком.
|
|
func (srv *server) securityAccess(w http.ResponseWriter, r *http.Request, sess *session) {
|
|
op, myIP := r.PostFormValue("op"), clientIP(r)
|
|
oldBase := srv.adminBase()
|
|
var bad, okMsg string
|
|
err := srv.st.write(func(s *State) error {
|
|
switch op {
|
|
case "path", "path-random", "path-default":
|
|
slug := strings.ToLower(strings.Trim(strings.TrimSpace(r.PostFormValue("path")), "/"))
|
|
switch {
|
|
case op == "path-random":
|
|
slug = "k" + randHex(12)
|
|
case op == "path-default" || slug == "admin":
|
|
slug = ""
|
|
case !adminSlugRe.MatchString(slug):
|
|
bad = "адрес: от 8 до 64 символов — латинские строчные буквы, цифры и дефис"
|
|
return nil
|
|
}
|
|
if slug == s.AdminPath {
|
|
bad = "адрес не изменился"
|
|
return nil
|
|
}
|
|
s.AdminPath = slug
|
|
shown := adminDefault
|
|
if slug != "" {
|
|
shown = "/" + slug
|
|
}
|
|
s.addEvent("admin_path", "", "", sess.Login, myIP, "адрес кабинета изменён на "+shown)
|
|
okMsg = "Новый адрес кабинета: https://" + publicHost + shown + "/ — сохраните его в закладки. Прежний адрес больше не открывается."
|
|
case "allow-only-on":
|
|
if n, ok := normalizeNet(myIP); ok && !s.allowCovers(myIP, false) {
|
|
s.AllowIPs = append(s.AllowIPs, &AllowIP{Net: n, Comment: "добавлен автоматически при ограничении кабинета", AddedBy: sess.Login, AddedAt: time.Now()})
|
|
s.addEvent("fw_allow_add", "", "", sess.Login, myIP, "IP "+n+" добавлен в белый список (ограничение кабинета)")
|
|
}
|
|
s.AdminAllowOnly = true
|
|
s.addEvent("admin_allow_only_on", "", "", sess.Login, myIP, "кабинет доступен только из белого списка администраторов")
|
|
_ = os.Remove(filepath.Join(srv.dataDir(), adminOpenFile))
|
|
okMsg = "Кабинет открывается только с адресов белого списка, остальным — «не найдено»."
|
|
case "allow-only-off":
|
|
s.AdminAllowOnly = false
|
|
s.addEvent("admin_allow_only_off", "", "", sess.Login, myIP, "ограничение кабинета белым списком снято")
|
|
okMsg = "Ограничение снято: страница входа доступна с любого адреса."
|
|
default:
|
|
bad = "неизвестная команда"
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil || bad != "" {
|
|
back(w, r, "/admin/security", "", pickStr(bad, fmt.Sprint(err)))
|
|
return
|
|
}
|
|
if newBase := srv.adminBase(); newBase != oldBase {
|
|
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: oldBase, HttpOnly: true, Secure: isHTTPS(r), MaxAge: -1})
|
|
srv.setSessionCookie(w, r, sess.Token)
|
|
}
|
|
back(w, r, "/admin/security", okMsg, "")
|
|
}
|
|
|
|
// ---- консоль ----
|
|
|
|
type multiFlag []string
|
|
|
|
func (m *multiFlag) String() string { return strings.Join(*m, ",") }
|
|
func (m *multiFlag) Set(v string) error { *m = append(*m, v); return nil }
|
|
|
|
// adminConfig — `bj-license-server admin-config`: доступ к кабинету из консоли (сервер остановлен).
|
|
func adminConfig(args []string) {
|
|
fs := flag.NewFlagSet("admin-config", flag.ExitOnError)
|
|
data := fs.String("data", "/var/lib/bj-license/state.json", "файл состояния")
|
|
var allow multiFlag
|
|
fs.Var(&allow, "allow", "добавить в белый список администраторов: IP[=комментарий] (флаг можно повторять)")
|
|
path := fs.String("path", "", "адрес кабинета: свой (8–64 символа a-z, 0-9, дефис), random — сгенерировать, admin — стандартный")
|
|
only := fs.String("allow-only", "", "кабинет только из белого списка: on | off")
|
|
reset := fs.String("reset-2fa", "", "сбросить второй фактор администратора (логин)")
|
|
zkFlag := fs.String("zetit-key", "", "вход через ЗЕТИТ Ключ: off — выключить (аварийно)")
|
|
_ = fs.Parse(args)
|
|
st, err := openStore(*data)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
err = st.write(func(s *State) error {
|
|
now := time.Now()
|
|
for _, a := range allow {
|
|
ipPart, comment, _ := strings.Cut(a, "=")
|
|
n, ok := normalizeNet(ipPart)
|
|
if !ok {
|
|
return fmt.Errorf("неверный адрес %q", ipPart)
|
|
}
|
|
exists := false
|
|
for _, x := range s.AllowIPs {
|
|
exists = exists || x.Net == n
|
|
}
|
|
if !exists {
|
|
s.AllowIPs = append(s.AllowIPs, &AllowIP{Net: n, Comment: strings.TrimSpace(comment), AddedBy: "консоль", AddedAt: now})
|
|
s.addEvent("fw_allow_add", "", "", "консоль", "", "IP "+n+" добавлен в белый список")
|
|
}
|
|
}
|
|
switch p := strings.ToLower(strings.Trim(*path, "/")); {
|
|
case p == "":
|
|
case p == "admin":
|
|
s.AdminPath = ""
|
|
s.addEvent("admin_path", "", "", "консоль", "", "адрес кабинета: стандартный /admin")
|
|
case p == "random":
|
|
s.AdminPath = "k" + randHex(12)
|
|
s.addEvent("admin_path", "", "", "консоль", "", "адрес кабинета изменён")
|
|
case adminSlugRe.MatchString(p):
|
|
s.AdminPath = p
|
|
s.addEvent("admin_path", "", "", "консоль", "", "адрес кабинета изменён")
|
|
default:
|
|
return fmt.Errorf("адрес: 8–64 символа, латинские строчные буквы, цифры и дефис")
|
|
}
|
|
switch *only {
|
|
case "":
|
|
case "on":
|
|
if len(s.AllowIPs) == 0 {
|
|
return fmt.Errorf("белый список пуст — ограничение закроет кабинет всем")
|
|
}
|
|
s.AdminAllowOnly = true
|
|
s.addEvent("admin_allow_only_on", "", "", "консоль", "", "кабинет доступен только из белого списка администраторов")
|
|
case "off":
|
|
s.AdminAllowOnly = false
|
|
s.addEvent("admin_allow_only_off", "", "", "консоль", "", "ограничение кабинета белым списком снято")
|
|
default:
|
|
return fmt.Errorf("-allow-only: on или off")
|
|
}
|
|
switch *zkFlag {
|
|
case "":
|
|
case "off":
|
|
s.ZetitKey.Enabled = false
|
|
s.addEvent("zetit_key", "", "", "консоль", "", "вход через ЗЕТИТ Ключ выключен")
|
|
default:
|
|
return fmt.Errorf("-zetit-key: только off")
|
|
}
|
|
if *reset != "" {
|
|
u := s.user(*reset)
|
|
if u == nil {
|
|
return fmt.Errorf("администратор %s не найден", *reset)
|
|
}
|
|
u.TOTPSecret, u.TOTPLast = "", 0
|
|
s.addEvent("totp_reset", "", "", "консоль", "", "сброшен второй фактор администратора "+u.Login)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
if *only == "on" {
|
|
_ = os.Remove(filepath.Join(filepath.Dir(*data), adminOpenFile))
|
|
}
|
|
printAccess(st)
|
|
}
|
|
|
|
// adminInfo — `bj-license-server admin-info`: адрес и настройки доступа (только чтение).
|
|
func adminInfo(args []string) {
|
|
fs := flag.NewFlagSet("admin-info", flag.ExitOnError)
|
|
data := fs.String("data", "/var/lib/bj-license/state.json", "файл состояния")
|
|
_ = fs.Parse(args)
|
|
st, err := openStore(*data)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
printAccess(st)
|
|
}
|
|
|
|
func printAccess(st *store) {
|
|
_, openErr := os.Stat(filepath.Join(filepath.Dir(st.path), adminOpenFile))
|
|
st.read(func(s *State) {
|
|
path := adminDefault
|
|
if s.AdminPath != "" {
|
|
path = "/" + s.AdminPath
|
|
}
|
|
fmt.Printf("Адрес кабинета: https://%s%s/\n", publicHost, path)
|
|
switch {
|
|
case s.AdminAllowOnly && openErr == nil:
|
|
fmt.Println("Только из белого списка: включено, но кабинет открыт аварийно (fw-apply -off)")
|
|
case s.AdminAllowOnly:
|
|
fmt.Println("Только из белого списка: включено")
|
|
default:
|
|
fmt.Println("Только из белого списка: выключено")
|
|
}
|
|
fmt.Println("Белый список администраторов:")
|
|
for _, a := range s.AllowIPs {
|
|
fmt.Printf(" %-20s %s\n", a.Net, a.Comment)
|
|
}
|
|
if s.ZetitKey.Enabled {
|
|
fmt.Println("Вход через ЗЕТИТ Ключ: включён (" + pickStr(s.ZetitKey.URL, zetitKeyDefaultURL) + ")")
|
|
} else {
|
|
fmt.Println("Вход через ЗЕТИТ Ключ: выключен")
|
|
}
|
|
fmt.Println("Администраторы (второй фактор):")
|
|
for _, u := range s.Users {
|
|
state := "не подключён"
|
|
if u.TOTPSecret != "" {
|
|
state = "подключён"
|
|
}
|
|
fmt.Printf(" %-24s TOTP %s, e-mail %s\n", u.Login, state, pickStr(u.Email, "не указан"))
|
|
}
|
|
})
|
|
}
|