3dc200c196
Статистика, карточка пользователя и работа с журналом для администратора
инстанса (AGENT.md 3.2, 7.18):
- GET /instance/stats: рост пользователей и сообщений по дням, активность,
размеры базы и файлов, топы серверов по участникам и сообщениям;
- GET /instance/users/{id}: профиль, активные устройства, серверы с ролями,
события безопасности и аудит по пользователю;
- DELETE /instance/users/{id}/sessions/{sid} и POST .../reset-2fa: отзыв
одного устройства и сброс второго фактора со step-up, аудитом и записью
в события безопасности; чужой ключ администратора не сбрасывается;
- журнал инстанса: фильтры по действию, актору, цели, серверу и датам,
пагинация с общим числом, список действий и выгрузка CSV (лимит 5/мин);
- список серверов: поиск по названию, владелец, главный сервер, пагинация;
- миграция 00025: нормализованное название сервера `name_lower` — SQLite
lower() не знает кириллицу, поэтому регистр приводит приложение (как для
текста сообщений), старые записи дополняются backfill'ом при старте.
203 lines
6.8 KiB
Go
203 lines
6.8 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"time"
|
|
)
|
|
|
|
// SessionTTL — время жизни сессии по умолчанию (AGENT.md 7.1).
|
|
const SessionTTL = 30 * 24 * time.Hour
|
|
|
|
// StepUpWindow — окно свежей аутентификации для чувствительных действий.
|
|
const StepUpWindow = 10 * time.Minute
|
|
|
|
type Session struct {
|
|
ID uint64
|
|
UserID uint64
|
|
TokenHash string
|
|
UserAgent string
|
|
IP string
|
|
CreatedAt time.Time
|
|
ExpiresAt time.Time
|
|
LastSeen time.Time
|
|
SteppedUpAt *time.Time
|
|
}
|
|
|
|
// SteppedUp сообщает, что сессия прошла step-up недавно (AGENT.md 7.1).
|
|
func (s *Session) SteppedUp(now time.Time) bool {
|
|
if s.SteppedUpAt == nil {
|
|
return false
|
|
}
|
|
return now.Sub(*s.SteppedUpAt) < StepUpWindow
|
|
}
|
|
|
|
type CreateSessionParams struct {
|
|
TokenHash string
|
|
UserAgent string
|
|
IP string
|
|
TTL time.Duration
|
|
}
|
|
|
|
func (s *Store) CreateSession(ctx context.Context, userID uint64, params CreateSessionParams) (*Session, error) {
|
|
if params.TTL <= 0 {
|
|
params.TTL = SessionTTL
|
|
}
|
|
now := s.now()
|
|
expires := now.Add(params.TTL)
|
|
|
|
_, err := s.writer.ExecContext(ctx, `
|
|
INSERT INTO sessions (id, user_id, token_hash, user_agent, ip, created_at, expires_at, last_seen)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
int64(s.NextID()), int64(userID), params.TokenHash, params.UserAgent, params.IP,
|
|
s.Timestamp(now), s.Timestamp(expires), s.Timestamp(now),
|
|
)
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
return nil, ErrConflict
|
|
}
|
|
return nil, err
|
|
}
|
|
return s.GetSessionByTokenHash(ctx, params.TokenHash)
|
|
}
|
|
|
|
func (s *Store) GetSessionByTokenHash(ctx context.Context, tokenHash string) (*Session, error) {
|
|
row := s.reader.QueryRowContext(ctx, `
|
|
SELECT id, user_id, token_hash, user_agent, ip, created_at, expires_at, last_seen, stepped_up_at
|
|
FROM sessions WHERE token_hash = ?`, tokenHash)
|
|
return scanSession(row)
|
|
}
|
|
|
|
func (s *Store) ListSessions(ctx context.Context, userID uint64) ([]Session, error) {
|
|
rows, err := s.reader.QueryContext(ctx, `
|
|
SELECT id, user_id, token_hash, user_agent, ip, created_at, expires_at, last_seen, stepped_up_at
|
|
FROM sessions WHERE user_id = ? AND expires_at > ? ORDER BY last_seen DESC`,
|
|
int64(userID), s.Now())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
sessions := make([]Session, 0, 4)
|
|
for rows.Next() {
|
|
session, err := scanSession(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sessions = append(sessions, *session)
|
|
}
|
|
return sessions, rows.Err()
|
|
}
|
|
|
|
// RotateSession заменяет хэш токена, сохраняя идентификатор сессии
|
|
// (защита от session fixation, AGENT.md 7.1).
|
|
func (s *Store) RotateSession(ctx context.Context, sessionID uint64, tokenHash string) error {
|
|
result, err := s.writer.ExecContext(ctx,
|
|
`UPDATE sessions SET token_hash = ?, last_seen = ? WHERE id = ?`,
|
|
tokenHash, s.Now(), int64(sessionID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MarkSteppedUp фиксирует успешный step-up для сессии.
|
|
func (s *Store) MarkSteppedUp(ctx context.Context, sessionID uint64) error {
|
|
result, err := s.writer.ExecContext(ctx,
|
|
`UPDATE sessions SET stepped_up_at = ?, last_seen = ? WHERE id = ?`,
|
|
s.Now(), s.Now(), int64(sessionID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) TouchSession(ctx context.Context, sessionID uint64) error {
|
|
_, err := s.writer.ExecContext(ctx, `UPDATE sessions SET last_seen = ? WHERE id = ?`, s.Now(), int64(sessionID))
|
|
return err
|
|
}
|
|
|
|
func (s *Store) DeleteSession(ctx context.Context, sessionID uint64) error {
|
|
_, err := s.writer.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, int64(sessionID))
|
|
return err
|
|
}
|
|
|
|
// DeleteSessionsForUser отзывает все сессии пользователя: используется при
|
|
// смене пароля и «выйти везде» (AGENT.md 7.1).
|
|
func (s *Store) DeleteSessionsForUser(ctx context.Context, userID uint64) error {
|
|
_, err := s.writer.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, int64(userID))
|
|
return err
|
|
}
|
|
|
|
// DeleteOtherSessions отзывает все сессии, кроме текущей.
|
|
func (s *Store) DeleteOtherSessions(ctx context.Context, userID, keepSessionID uint64) error {
|
|
_, err := s.writer.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ? AND id <> ?`,
|
|
int64(userID), int64(keepSessionID))
|
|
return err
|
|
}
|
|
|
|
// UserSession отдаёт сессию, только если она принадлежит пользователю: админ
|
|
// панели отзывает конкретное устройство (AGENT.md 7.18), и чужой
|
|
// идентификатор сессии не должен ничего отзывать.
|
|
func (s *Store) UserSession(ctx context.Context, userID, sessionID uint64) (*Session, error) {
|
|
row := s.reader.QueryRowContext(ctx, `
|
|
SELECT id, user_id, token_hash, user_agent, ip, created_at, expires_at, last_seen, stepped_up_at
|
|
FROM sessions WHERE id = ? AND user_id = ?`, int64(sessionID), int64(userID))
|
|
return scanSession(row)
|
|
}
|
|
|
|
// DeleteUserSession отзывает одно устройство пользователя. Возвращает false,
|
|
// если сессии с таким идентификатором у него нет.
|
|
func (s *Store) DeleteUserSession(ctx context.Context, userID, sessionID uint64) (bool, error) {
|
|
result, err := s.writer.ExecContext(ctx,
|
|
`DELETE FROM sessions WHERE id = ? AND user_id = ?`, int64(sessionID), int64(userID))
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
affected, err := result.RowsAffected()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return affected > 0, nil
|
|
}
|
|
|
|
// DeleteExpiredSessions вызывается cleanup-джобой (AGENT.md 6.4).
|
|
func (s *Store) DeleteExpiredSessions(ctx context.Context) (int64, error) {
|
|
result, err := s.writer.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at <= ?`, s.Now())
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected()
|
|
}
|
|
|
|
func scanSession(scanner interface{ Scan(...any) error }) (*Session, error) {
|
|
var (
|
|
session Session
|
|
createdAt string
|
|
expiresAt string
|
|
lastSeen string
|
|
steppedUpAt sql.NullString
|
|
)
|
|
err := scanner.Scan(
|
|
&session.ID, &session.UserID, &session.TokenHash, &session.UserAgent, &session.IP,
|
|
&createdAt, &expiresAt, &lastSeen, &steppedUpAt,
|
|
)
|
|
if err != nil {
|
|
return nil, mapError(err)
|
|
}
|
|
session.CreatedAt = parseTimestamp(createdAt)
|
|
session.ExpiresAt = parseTimestamp(expiresAt)
|
|
session.LastSeen = parseTimestamp(lastSeen)
|
|
if steppedUpAt.Valid {
|
|
value := parseTimestamp(steppedUpAt.String)
|
|
session.SteppedUpAt = &value
|
|
}
|
|
return &session, nil
|
|
}
|