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 }