package repository import ( "context" "fmt" "time" ) // RefreshTokenRepo — сессии клиентов (refresh-токены). // В БД лежит только sha256-хеш: утечка дампа не даёт рабочих токенов. type RefreshTokenRepo struct { db Querier } // NewRefreshTokenRepo создаёт репозиторий refresh-сессий. func NewRefreshTokenRepo(db Querier) *RefreshTokenRepo { return &RefreshTokenRepo{db: db} } // RefreshToken — запись о выпущенной сессии. type RefreshToken struct { ID string UserID string ExpiresAt time.Time RevokedAt *time.Time CreatedAt time.Time UserAgent string IP string } // Create — выпустить сессию. user_agent/ip пишем для пользователя: // где и с чего устройства он вошёл (для будущего списка сессий). func (r *RefreshTokenRepo) Create(ctx context.Context, id, userID, tokenHash string, expiresAt time.Time, userAgent, ip string) error { _, err := r.db.Exec(ctx, `INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at, user_agent, ip) VALUES ($1, $2, $3, $4, NULLIF($5, ''), NULLIF($6, ''))`, id, userID, tokenHash, expiresAt, userAgent, ip, ) return classify(err) } // GetByHash — найти сессию по хешу токена. func (r *RefreshTokenRepo) GetByHash(ctx context.Context, tokenHash string) (*RefreshToken, error) { var t RefreshToken err := r.db.QueryRow(ctx, `SELECT id, user_id, expires_at, revoked_at, created_at, COALESCE(user_agent, ''), COALESCE(ip, '') FROM refresh_tokens WHERE token_hash = $1`, tokenHash, ).Scan(&t.ID, &t.UserID, &t.ExpiresAt, &t.RevokedAt, &t.CreatedAt, &t.UserAgent, &t.IP) if err != nil { return nil, classify(fmt.Errorf("refresh token: %w", err)) } return &t, nil } // RevokeByHash — погасить одну сессию (logout). Отзыв идемпотентен: // уже отозванный или несуществующий токен не считается ошибкой. // Возвращает, была ли строка найдена. func (r *RefreshTokenRepo) RevokeByHash(ctx context.Context, tokenHash string) (bool, error) { tag, err := r.db.Exec(ctx, `UPDATE refresh_tokens SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL`, tokenHash) if err != nil { return false, classify(err) } return tag.RowsAffected() > 0, nil } // RevokeAllForUser — закрыть все сессии пользователя: нужно при деактивации // аккаунта и при обнаружении повторного использования отозванного токена // (признак, что секретом кто-то ещё пользуется). func (r *RefreshTokenRepo) RevokeAllForUser(ctx context.Context, userID string) (int64, error) { tag, err := r.db.Exec(ctx, `UPDATE refresh_tokens SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL`, userID) if err != nil { return 0, classify(err) } return tag.RowsAffected(), nil } // DeleteExpired — удалить протухшие и отозванные записи. // Взрослые строки в таблице сессий не нужны никому. func (r *RefreshTokenRepo) DeleteExpired(ctx context.Context) (int64, error) { tag, err := r.db.Exec(ctx, `DELETE FROM refresh_tokens WHERE expires_at < now() OR revoked_at < now() - interval '7 days'`) if err != nil { return 0, classify(err) } return tag.RowsAffected(), nil }