a5205cc054
- миграция 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 из константных шаблонов)
156 lines
4.9 KiB
Go
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
|
|
}
|