Files
glchat/internal/store/totp.go
T
grendervill a5205cc054 feat(store,permissions): схема Фазы 1, доступ к данным и движок прав
- миграция 00002: users, sessions, totp_secrets, webauthn_credentials,
  security_events, guilds, guild_members, roles, member_roles, channels,
  channel_overrides, audit_log + дефолтные instance_settings (AGENT.md 6.1)
- internal/store: Snowflake-идентификаторы, CRUD пользователей и сессий
  (ротация, step-up, logout-all), TOTP и события безопасности, серверы,
  участники, роли, комнаты и оверрайды, настройки инстанса и аудит
- internal/permissions: 37 прав битмаской, вычисление по правилам §6.2
  (баз role @user → оверрайды ролей → оверрайд пользователя → ADMINISTRATOR),
  иерархия ролей и участников, тайм-ауты, обход для инстанс-админа, LRU-кэш
  с инвалидацией
- internal/source: адаптер permissions.Source поверх store
- тесты: 18 unit-тестов прав + интеграционный набор на реальной SQLite
  (приватная комната, модератор, владелец, инстанс-админ, тайм-аут)
- golangci: обоснованное исключение gosec для пакета store (конверсии
  Snowflake и сборка SQL из константных шаблонов)
2026-09-19 21:24:17 +03:00

156 lines
4.9 KiB
Go

package store
import (
"context"
"database/sql"
"encoding/json"
"time"
)
// TOTPSecret — секрет второго фактора: в БД лежит зашифрованным (AGENT.md 9.2),
// резервные коды хранятся только хэшами.
type TOTPSecret struct {
UserID uint64
SecretEncrypted string
Enabled bool
RecoveryCodeHashs []string
ConfirmedAt *time.Time
CreatedAt time.Time
}
// UpsertTOTPSecret создаёт или заменяет неподтверждённый секрет.
func (s *Store) UpsertTOTPSecret(ctx context.Context, userID uint64, secretEncrypted string) error {
_, err := s.writer.ExecContext(ctx, `
INSERT INTO totp_secrets (user_id, secret_encrypted, enabled, recovery_codes_json, created_at)
VALUES (?, ?, 0, '[]', ?)
ON CONFLICT (user_id) DO UPDATE SET
secret_encrypted = excluded.secret_encrypted,
enabled = 0,
recovery_codes_json = '[]',
confirmed_at = NULL,
created_at = excluded.created_at`,
int64(userID), secretEncrypted, s.Now())
return err
}
func (s *Store) GetTOTPSecret(ctx context.Context, userID uint64) (*TOTPSecret, error) {
var (
secret TOTPSecret
enabled int
codes string
confirmedAt sql.NullString
createdAt string
)
err := s.reader.QueryRowContext(ctx, `
SELECT user_id, secret_encrypted, enabled, recovery_codes_json, confirmed_at, created_at
FROM totp_secrets WHERE user_id = ?`, int64(userID)).
Scan(&secret.UserID, &secret.SecretEncrypted, &enabled, &codes, &confirmedAt, &createdAt)
if err != nil {
return nil, mapError(err)
}
secret.Enabled = enabled == 1
if err := json.Unmarshal([]byte(codes), &secret.RecoveryCodeHashs); err != nil {
secret.RecoveryCodeHashs = nil
}
if confirmedAt.Valid {
value := parseTimestamp(confirmedAt.String)
secret.ConfirmedAt = &value
}
secret.CreatedAt = parseTimestamp(createdAt)
return &secret, nil
}
// EnableTOTP подтверждает секрет и сохраняет хэши резервных кодов.
func (s *Store) EnableTOTP(ctx context.Context, userID uint64, recoveryCodeHashes []string) error {
encoded, err := json.Marshal(recoveryCodeHashes)
if err != nil {
return err
}
result, err := s.writer.ExecContext(ctx, `
UPDATE totp_secrets SET enabled = 1, recovery_codes_json = ?, confirmed_at = ?
WHERE user_id = ?`, string(encoded), s.Now(), int64(userID))
if err != nil {
return err
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
return ErrNotFound
}
return nil
}
// ConsumeRecoveryCode удаляет использованный резервный код.
func (s *Store) ConsumeRecoveryCode(ctx context.Context, userID uint64, remaining []string) error {
encoded, err := json.Marshal(remaining)
if err != nil {
return err
}
_, err = s.writer.ExecContext(ctx, `UPDATE totp_secrets SET recovery_codes_json = ? WHERE user_id = ?`,
string(encoded), int64(userID))
return err
}
func (s *Store) DeleteTOTPSecret(ctx context.Context, userID uint64) error {
_, err := s.writer.ExecContext(ctx, `DELETE FROM totp_secrets WHERE user_id = ?`, int64(userID))
return err
}
// RecordSecurityEvent пишет событие безопасности (AGENT.md 6.1).
func (s *Store) RecordSecurityEvent(ctx context.Context, userID *uint64, eventType, ip, userAgent, metadata string) error {
var userValue any
if userID != nil {
userValue = int64(*userID)
}
if metadata == "" {
metadata = "{}"
}
_, err := s.writer.ExecContext(ctx, `
INSERT INTO security_events (id, user_id, type, ip, user_agent, metadata_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
int64(s.NextID()), userValue, eventType, ip, userAgent, metadata, s.Now())
return err
}
// ListSecurityEvents возвращает последние события пользователя.
func (s *Store) ListSecurityEvents(ctx context.Context, userID uint64, limit int) ([]SecurityEvent, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
rows, err := s.reader.QueryContext(ctx, `
SELECT id, user_id, type, ip, user_agent, metadata_json, created_at
FROM security_events WHERE user_id = ? ORDER BY id DESC LIMIT ?`, int64(userID), limit)
if err != nil {
return nil, err
}
defer rows.Close()
events := make([]SecurityEvent, 0, limit)
for rows.Next() {
var (
event SecurityEvent
userValue sql.NullInt64
createdAt string
metadataRaw string
)
if err := rows.Scan(&event.ID, &userValue, &event.Type, &event.IP, &event.UserAgent, &metadataRaw, &createdAt); err != nil {
return nil, err
}
if userValue.Valid {
event.UserID = uint64(userValue.Int64)
}
event.Metadata = json.RawMessage(metadataRaw)
event.CreatedAt = parseTimestamp(createdAt)
events = append(events, event)
}
return events, rows.Err()
}
type SecurityEvent struct {
ID uint64
UserID uint64
Type string
IP string
UserAgent string
Metadata json.RawMessage
CreatedAt time.Time
}