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
|
|||
|
|
}
|