feat(cli): включение 2FA инстанс-админа командой totp-setup

2FA обязательна для инстанс-администраторов, но до её включения вход закрыт —
получался замкнутый круг. Добавлены команды обслуживания:

- `glchat totp-setup --email <admin>`: создаёт секрет, подтверждает его кодом,
  печатает секрет, otpauth-ссылку и 8 резервных кодов (каждый одноразовый);
- `glchat totp-reset --email <admin>`: удаляет секрет при потере устройства;
- код `auth.2fa_enrollment_required` с подсказкой, какую команду выполнить;
- установщик: флаг `--admin-2fa` для автоматического включения (по умолчанию
  печатает подсказку, чтобы секреты не оседали в логах установки).

Проверено сквозным прогоном локально: bootstrap → 403 на входе без 2FA →
totp-setup → вход с TOTP-кодом → профиль, серверы, комнаты, роли, участники,
аудит, создание сервера админом, 429 на шестой попытке входа, секретов в логах
нет.
This commit is contained in:
2026-09-19 21:53:35 +03:00
parent c059cd9f51
commit bf0d130ee8
23 changed files with 1115 additions and 269 deletions
+88
View File
@@ -2,9 +2,13 @@ package main
import (
"context"
"errors"
"flag"
"fmt"
"os"
"time"
"github.com/pquerna/otp/totp"
"glchat/internal/auth"
"glchat/internal/bootstrap"
@@ -23,6 +27,10 @@ func runCLI(args []string) int {
return cliResetPassword(args[1:])
case "make-admin", "remove-admin":
return cliSetInstanceAdmin(args[0], args[1:])
case "totp-setup":
return cliTOTPSetup(args[1:])
case "totp-reset":
return cliTOTPReset(args[1:])
default:
fmt.Fprintf(os.Stderr, "glchat: неизвестная команда %q\n", args[0])
return 2
@@ -136,6 +144,86 @@ func cliResetPassword(args []string) int {
})
}
// cliTOTPSetup включает 2FA для пользователя (обычно инстанс-администратора):
// 2FA обязательна для админов (AGENT.md 7.19), а до её включения вход закрыт,
// поэтому первичная настройка выполняется командой обслуживания.
func cliTOTPSetup(args []string) int {
flags := flag.NewFlagSet("totp-setup", flag.ContinueOnError)
email := flags.String("email", "", "email пользователя (обычно администратора инстанса)")
positional := flags.Args()
if err := flags.Parse(args); err != nil {
return 2
}
if *email == "" && len(positional) > 0 {
*email = positional[0]
}
if *email == "" {
fmt.Fprintln(os.Stderr, "glchat totp-setup: укажите --email")
return 2
}
return withServices(func(ctx context.Context, st *store.Store, authService *auth.Service, _ config.Config) error {
user, err := authService.UserByEmail(ctx, *email)
if err != nil {
return fmt.Errorf("пользователь с таким email не найден")
}
setup, err := authService.SetupTOTP(ctx, user.ID)
if err != nil {
if errors.Is(err, auth.ErrTOTPAlreadyEnabled) {
return fmt.Errorf("2FA уже включена: сбросьте секрет через glchat totp-reset")
}
return err
}
// Код подтверждения считаем из секрета: команда выполняется на сервере,
// где секрет и так доступен (расшифровка идёт внутри сервиса).
code, err := totp.GenerateCode(setup.Secret, time.Now())
if err != nil {
return fmt.Errorf("сгенерировать код подтверждения: %w", err)
}
codes, err := authService.EnableTOTP(ctx, user.ID, code)
if err != nil {
return err
}
fmt.Printf("2FA включена для %s\n", user.Username)
fmt.Printf("секрет: %s\n", setup.Secret)
fmt.Printf("otpauth: %s\n", setup.URL)
fmt.Println("резервные коды (каждый одноразовый, сохраните вне сервера):")
for _, recovery := range codes {
fmt.Printf(" %s\n", recovery)
}
return nil
})
}
// cliTOTPReset удаляет секрет 2FA (например, при потере устройства и кодов).
func cliTOTPReset(args []string) int {
flags := flag.NewFlagSet("totp-reset", flag.ContinueOnError)
email := flags.String("email", "", "email пользователя")
positional := flags.Args()
if err := flags.Parse(args); err != nil {
return 2
}
if *email == "" && len(positional) > 0 {
*email = positional[0]
}
if *email == "" {
fmt.Fprintln(os.Stderr, "glchat totp-reset: укажите --email")
return 2
}
return withServices(func(ctx context.Context, st *store.Store, authService *auth.Service, _ config.Config) error {
user, err := authService.UserByEmail(ctx, *email)
if err != nil {
return fmt.Errorf("пользователь с таким email не найден")
}
if err := st.DeleteTOTPSecret(ctx, user.ID); err != nil {
return err
}
fmt.Printf("секрет 2FA для %s удалён; включите заново командой totp-setup\n", user.Username)
return nil
})
}
func cliSetInstanceAdmin(command string, args []string) int {
flags := flag.NewFlagSet(command, flag.ContinueOnError)
email := flags.String("email", "", "email пользователя")
+3 -2
View File
@@ -37,10 +37,11 @@ func main() {
os.Exit(healthcheck())
}
// Подкоманды обслуживания (AGENT.md 10.7): bootstrap-admin, reset-password,
// make-admin, remove-admin. Остальные команды реализует shell-обёртка glchat.
// make-admin, remove-admin, totp-setup, totp-reset. Остальные команды
// реализует shell-обёртка glchat.
if len(os.Args) > 1 {
switch os.Args[1] {
case "bootstrap-admin", "reset-password", "make-admin", "remove-admin":
case "bootstrap-admin", "reset-password", "make-admin", "remove-admin", "totp-setup", "totp-reset":
os.Exit(runCLI(os.Args[1:]))
}
}