225 lines
6.3 KiB
Go
225 lines
6.3 KiB
Go
|
|
package auth
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/pquerna/otp"
|
||
|
|
"github.com/pquerna/otp/totp"
|
||
|
|
|
||
|
|
"glchat/internal/crypto"
|
||
|
|
"glchat/internal/store"
|
||
|
|
)
|
||
|
|
|
||
|
|
// TOTPSetup — данные для подключения второго фактора: секрет показывается
|
||
|
|
// один раз, в БД хранится зашифрованным (AGENT.md 9.2).
|
||
|
|
type TOTPSetup struct {
|
||
|
|
Secret string
|
||
|
|
URL string
|
||
|
|
Recovery []string
|
||
|
|
IssuerName string
|
||
|
|
Account string
|
||
|
|
}
|
||
|
|
|
||
|
|
const (
|
||
|
|
recoveryCodeCount = 8
|
||
|
|
totpIssuer = "glchat"
|
||
|
|
totpPeriod = 30
|
||
|
|
totpSkew = 1
|
||
|
|
)
|
||
|
|
|
||
|
|
// SetupTOTP создаёт новый секрет (не включая 2FA до подтверждения кодом).
|
||
|
|
func (s *Service) SetupTOTP(ctx context.Context, userID uint64) (*TOTPSetup, error) {
|
||
|
|
user, err := s.store.GetUser(ctx, userID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
existing, err := s.store.GetTOTPSecret(ctx, userID)
|
||
|
|
if err == nil && existing.Enabled {
|
||
|
|
return nil, ErrTOTPAlreadyEnabled
|
||
|
|
}
|
||
|
|
if err != nil && !errors.Is(err, store.ErrNotFound) {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
key, err := totp.Generate(totp.GenerateOpts{
|
||
|
|
Issuer: totpIssuer,
|
||
|
|
AccountName: user.Username,
|
||
|
|
Period: totpPeriod,
|
||
|
|
SecretSize: 20,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("generate totp secret: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
encrypted, err := s.masterKey.Encrypt(key.Secret())
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("encrypt totp secret: %w", err)
|
||
|
|
}
|
||
|
|
if err := s.store.UpsertTOTPSecret(ctx, userID, encrypted); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return &TOTPSetup{
|
||
|
|
Secret: key.Secret(),
|
||
|
|
URL: key.URL(),
|
||
|
|
IssuerName: totpIssuer,
|
||
|
|
Account: user.Username,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// EnableTOTP подтверждает секрет кодом и выдаёт резервные коды (в БД — хэши).
|
||
|
|
func (s *Service) EnableTOTP(ctx context.Context, userID uint64, code string) ([]string, error) {
|
||
|
|
secret, err := s.store.GetTOTPSecret(ctx, userID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, ErrNoTOTPSecret
|
||
|
|
}
|
||
|
|
if secret.Enabled {
|
||
|
|
return nil, ErrTOTPAlreadyEnabled
|
||
|
|
}
|
||
|
|
if err := s.verifyTOTPSecret(secret.SecretEncrypted, code); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
codes := make([]string, 0, recoveryCodeCount)
|
||
|
|
hashes := make([]string, 0, recoveryCodeCount)
|
||
|
|
for i := 0; i < recoveryCodeCount; i++ {
|
||
|
|
raw, err := crypto.NewRecoveryCode()
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
hash, err := s.totpHasher.Hash(strings.ToUpper(raw))
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
codes = append(codes, raw)
|
||
|
|
hashes = append(hashes, hash)
|
||
|
|
}
|
||
|
|
if err := s.store.EnableTOTP(ctx, userID, hashes); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
_ = s.store.RecordSecurityEvent(ctx, &userID, "2fa_change", "", "", `{"enabled":true}`)
|
||
|
|
return codes, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// DisableTOTP выключает второй фактор (после проверки пароля вызывающим кодом)
|
||
|
|
// и запрещает это инстанс-администратору (AGENT.md 7.19).
|
||
|
|
func (s *Service) DisableTOTP(ctx context.Context, userID uint64) error {
|
||
|
|
user, err := s.store.GetUser(ctx, userID)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if user.IsInstanceAdmin {
|
||
|
|
return ErrInstanceAdminTOTP
|
||
|
|
}
|
||
|
|
if err := s.store.DeleteTOTPSecret(ctx, userID); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
_ = s.store.RecordSecurityEvent(ctx, &userID, "2fa_change", "", "", `{"enabled":false}`)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// RegenerateRecoveryCodes заменяет резервные коды (пароль проверяет API).
|
||
|
|
func (s *Service) RegenerateRecoveryCodes(ctx context.Context, userID uint64, code string) ([]string, error) {
|
||
|
|
secret, err := s.store.GetTOTPSecret(ctx, userID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, ErrNoTOTPSecret
|
||
|
|
}
|
||
|
|
if !secret.Enabled {
|
||
|
|
return nil, ErrNoTOTPSecret
|
||
|
|
}
|
||
|
|
if err := s.verifyTOTP(ctx, secret, code); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
codes := make([]string, 0, recoveryCodeCount)
|
||
|
|
hashes := make([]string, 0, recoveryCodeCount)
|
||
|
|
for i := 0; i < recoveryCodeCount; i++ {
|
||
|
|
raw, err := crypto.NewRecoveryCode()
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
hash, err := s.totpHasher.Hash(strings.ToUpper(raw))
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
codes = append(codes, raw)
|
||
|
|
hashes = append(hashes, hash)
|
||
|
|
}
|
||
|
|
if err := s.store.EnableTOTP(ctx, userID, hashes); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return codes, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// TOTPEnabled сообщает состояние второго фактора.
|
||
|
|
func (s *Service) TOTPEnabled(ctx context.Context, userID uint64) (bool, error) {
|
||
|
|
secret, err := s.store.GetTOTPSecret(ctx, userID)
|
||
|
|
if errors.Is(err, store.ErrNotFound) {
|
||
|
|
return false, nil
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return false, err
|
||
|
|
}
|
||
|
|
return secret.Enabled, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// verifyTOTP проверяет код TOTP или резервный код.
|
||
|
|
func (s *Service) verifyTOTP(ctx context.Context, secret *store.TOTPSecret, code string) error {
|
||
|
|
normalized := strings.TrimSpace(code)
|
||
|
|
if normalized == "" {
|
||
|
|
return ErrTOTPInvalid
|
||
|
|
}
|
||
|
|
// Резервный код: одноразовый, после использования удаляется.
|
||
|
|
if len(normalized) == 14 && strings.Count(normalized, "-") == 2 {
|
||
|
|
remaining, ok := s.consumeRecoveryCode(secret, normalized)
|
||
|
|
if !ok {
|
||
|
|
return ErrTOTPInvalid
|
||
|
|
}
|
||
|
|
if err := s.store.ConsumeRecoveryCode(ctx, secret.UserID, remaining); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
if err := s.verifyTOTPSecret(secret.SecretEncrypted, normalized); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) consumeRecoveryCode(secret *store.TOTPSecret, code string) ([]string, bool) {
|
||
|
|
upper := strings.ToUpper(strings.TrimSpace(code))
|
||
|
|
remaining := make([]string, 0, len(secret.RecoveryCodeHashs))
|
||
|
|
matched := false
|
||
|
|
for _, hash := range secret.RecoveryCodeHashs {
|
||
|
|
if !matched && s.totpHasher.Verify(upper, hash) == nil {
|
||
|
|
matched = true
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
remaining = append(remaining, hash)
|
||
|
|
}
|
||
|
|
if !matched {
|
||
|
|
return secret.RecoveryCodeHashs, false
|
||
|
|
}
|
||
|
|
return remaining, true
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) verifyTOTPSecret(encrypted, code string) error {
|
||
|
|
plain, err := s.masterKey.Decrypt(encrypted)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("decrypt totp secret: %w", err)
|
||
|
|
}
|
||
|
|
valid, err := totp.ValidateCustom(strings.TrimSpace(code), plain, time.Now().UTC(), totp.ValidateOpts{
|
||
|
|
Period: totpPeriod,
|
||
|
|
Skew: totpSkew,
|
||
|
|
Digits: otp.DigitsSix,
|
||
|
|
Algorithm: otp.AlgorithmSHA1,
|
||
|
|
})
|
||
|
|
if err != nil || !valid {
|
||
|
|
return ErrTOTPInvalid
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|