93 lines
3.6 KiB
Go
93 lines
3.6 KiB
Go
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
|
||
}
|