axe/internal/repository/refresh_token.go

93 lines
3.6 KiB
Go
Raw Normal View History

2026-10-18 19:07:25 +00:00
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
}