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:
@@ -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
@@ -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:]))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user