axe/internal/repository/refresh_token.go
2026-10-18 22:07:25 +03:00

93 lines
3.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}