Досі доступ давав машинний токен зі змінної збірки — одні права на всіх і жодного способу відрізнити, хто що зробив. Тепер продукт уміє впустити людину. Сервер: - argon2id для паролів, власний HS256 JWT (15 хв) + refresh-сесія в httpOnly-кукі на 30 днів з ротацією при кожному обміні - Principal зводить людину й машинний токен до одного набору прав; права читаються з БД на кожному запиті, а не з claims, щоб відкликана роль не жила до кінця TTL - 9 ендпоїнтів: auth/login|refresh|logout|password, me, team CRUD, roles - netpulse-user — CLI для першого власника: публічна реєстрація в B2B це дірка, а «перший через веб, поки нікого немає» — нечесна гонка - міграція 0012: три RLS-політики винятку для шляху входу (без них вхід неможливий за побудовою — щоб знайти користувача за email, треба знати тенант, який відомий лише після пошуку) і login_attempts для тротлінгу Фронтенд: - сторінка входу з вибором організації, access-токен у замиканні модуля замість localStorage, тихе відновлення сесії по кукі - один refresh на всі паралельні запити: інакше ротація зробила б усі, крім першого, недійсними й викинула б людину на вхід - дії без права не показуються; полотно нередаговане для глядача - мобільний адаптив: висувна бічна панель, інспектор нижнім аркушем 11 нових тестів (37 у httpapi), go vet і tsc чисто, живий прогін з 8 кроків проти netpulse_it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
534 lines
18 KiB
Go
534 lines
18 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"errors"
|
||
"fmt"
|
||
"net"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/netpulse/netpulse/server/internal/auth"
|
||
)
|
||
|
||
// RefreshTTL — скільки живе сесія без повторного входу.
|
||
//
|
||
// 30 днів — компроміс між «щодня вводити пароль» і «звільнений
|
||
// співробітник місяць має доступ». Друге лікується відкликанням сесій
|
||
// при видаленні членства, а не коротшим строком.
|
||
const RefreshTTL = 30 * 24 * time.Hour
|
||
|
||
var (
|
||
ErrBadCredentials = errors.New("невірний email або пароль")
|
||
ErrNoMembership = errors.New("користувач не належить жодному тенанту")
|
||
ErrSessionInvalid = errors.New("сесія недійсна")
|
||
)
|
||
|
||
type User struct {
|
||
ID string `json:"id"`
|
||
Email string `json:"email"`
|
||
FullName string `json:"full_name,omitempty"`
|
||
Locale string `json:"locale"`
|
||
Timezone string `json:"timezone"`
|
||
MFA bool `json:"mfa_enabled"`
|
||
}
|
||
|
||
// TenantMembership — до якого тенанта і з якою роллю належить користувач.
|
||
type TenantMembership struct {
|
||
TenantID string `json:"tenant_id"`
|
||
TenantName string `json:"tenant_name"`
|
||
TenantSlug string `json:"tenant_slug"`
|
||
RoleKey string `json:"role_key"`
|
||
RoleName string `json:"role_name"`
|
||
}
|
||
|
||
// TeamMember — рядок сторінки «Команда».
|
||
type TeamMember struct {
|
||
UserID string `json:"user_id"`
|
||
Email string `json:"email"`
|
||
FullName string `json:"full_name,omitempty"`
|
||
RoleID string `json:"role_id"`
|
||
RoleKey string `json:"role_key"`
|
||
RoleName string `json:"role_name"`
|
||
LastLogin *time.Time `json:"last_login_at,omitempty"`
|
||
AcceptedAt *time.Time `json:"accepted_at,omitempty"`
|
||
ScopeGroups []string `json:"scope_group_ids,omitempty"`
|
||
}
|
||
|
||
type Role struct {
|
||
ID string `json:"id"`
|
||
Key string `json:"key"`
|
||
Name string `json:"name"`
|
||
Description string `json:"description,omitempty"`
|
||
IsSystem bool `json:"is_system"`
|
||
Permissions []string `json:"permissions"`
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Вхід
|
||
// ---------------------------------------------------------------------
|
||
|
||
// AuthenticateUser перевіряє email і пароль.
|
||
//
|
||
// Запити йдуть БЕЗ tenant-контексту: у момент входу тенант ще невідомий,
|
||
// його називає саме членство. Політики з міграції 0012 дозволяють це
|
||
// рівно тоді, коли app.tenant_id порожній.
|
||
func (s *Store) AuthenticateUser(ctx context.Context, email, password string, ip net.IP, userAgent string) (*User, []TenantMembership, error) {
|
||
var (
|
||
u User
|
||
hash *string
|
||
)
|
||
|
||
err := s.pool.QueryRow(ctx, `
|
||
SELECT id::text, email::text, COALESCE(full_name,''), locale, timezone,
|
||
mfa_enabled, password_hash
|
||
FROM core.users
|
||
WHERE email = $1 AND deleted_at IS NULL
|
||
`, email).Scan(&u.ID, &u.Email, &u.FullName, &u.Locale, &u.Timezone, &u.MFA, &hash)
|
||
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
// Спалюємо стільки ж часу, скільки справжня перевірка: інакше
|
||
// швидка відповідь видає, що такого email немає, і перебір
|
||
// перетворюється на збір списку зареєстрованих адрес.
|
||
auth.DummyVerify(password)
|
||
s.recordLogin(ctx, email, ip, userAgent, false, "no_user", "")
|
||
return nil, nil, ErrBadCredentials
|
||
}
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
|
||
if hash == nil || *hash == "" {
|
||
// Користувач лише через SSO — локального пароля немає.
|
||
auth.DummyVerify(password)
|
||
s.recordLogin(ctx, email, ip, userAgent, false, "no_password", u.ID)
|
||
return nil, nil, ErrBadCredentials
|
||
}
|
||
|
||
if err := auth.VerifyPassword(*hash, password); err != nil {
|
||
s.recordLogin(ctx, email, ip, userAgent, false, "bad_password", u.ID)
|
||
return nil, nil, ErrBadCredentials
|
||
}
|
||
|
||
memberships, err := s.userMemberships(ctx, u.ID)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
if len(memberships) == 0 {
|
||
s.recordLogin(ctx, email, ip, userAgent, false, "no_membership", u.ID)
|
||
return nil, nil, ErrNoMembership
|
||
}
|
||
|
||
s.recordLogin(ctx, email, ip, userAgent, true, "", u.ID)
|
||
|
||
if _, err := s.pool.Exec(ctx,
|
||
`UPDATE core.users SET last_login_at = now() WHERE id = $1`, u.ID); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
|
||
return &u, memberships, nil
|
||
}
|
||
|
||
func (s *Store) userMemberships(ctx context.Context, userID string) ([]TenantMembership, error) {
|
||
rows, err := s.pool.Query(ctx, `
|
||
SELECT t.id::text, t.name, t.slug::text, r.key::text, r.name
|
||
FROM core.memberships m
|
||
JOIN core.tenants t ON t.id = m.tenant_id
|
||
JOIN core.roles r ON r.id = m.role_id
|
||
WHERE m.user_id = $1 AND t.deleted_at IS NULL
|
||
ORDER BY t.name
|
||
`, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var out []TenantMembership
|
||
for rows.Next() {
|
||
var m TenantMembership
|
||
if err := rows.Scan(&m.TenantID, &m.TenantName, &m.TenantSlug,
|
||
&m.RoleKey, &m.RoleName); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, m)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// recordLogin пише спробу входу. Помилки навмисно глушаться: збій
|
||
// журналювання не має заважати людині увійти.
|
||
func (s *Store) recordLogin(ctx context.Context, email string, ip net.IP, ua string, ok bool, reason, userID string) {
|
||
var ipArg any
|
||
if ip != nil {
|
||
ipArg = ip.String()
|
||
}
|
||
_, _ = s.pool.Exec(ctx, `
|
||
INSERT INTO core.login_attempts (email, ip, user_agent, success, reason, user_id)
|
||
VALUES ($1, $2::inet, $3, $4, NULLIF($5,''), $6)
|
||
`, email, ipArg, nullString(ua), ok, reason, nullUUID(userID))
|
||
}
|
||
|
||
// FailedLoginsSince рахує невдалі спроби — основа для гальмування
|
||
// перебору на рівні застосунку.
|
||
func (s *Store) FailedLoginsSince(ctx context.Context, email string, since time.Duration) (int, error) {
|
||
var n int
|
||
err := s.pool.QueryRow(ctx, `
|
||
SELECT count(*) FROM core.login_attempts
|
||
WHERE email = $1 AND NOT success AND ts > now() - $2::interval
|
||
`, email, since.String()).Scan(&n)
|
||
return n, err
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Сесії
|
||
// ---------------------------------------------------------------------
|
||
|
||
type Session struct {
|
||
ID string
|
||
UserID string
|
||
TenantID string
|
||
RefreshToken string
|
||
ExpiresAt time.Time
|
||
}
|
||
|
||
// CreateSession видає refresh-токен. У БД лягає лише його sha256.
|
||
func (s *Store) CreateSession(ctx context.Context, userID, tenantID string, ip net.IP, ua string) (*Session, error) {
|
||
token, err := auth.NewToken("npr_")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
sum := sha256.Sum256([]byte(token))
|
||
expires := time.Now().Add(RefreshTTL)
|
||
|
||
var ipArg any
|
||
if ip != nil {
|
||
ipArg = ip.String()
|
||
}
|
||
|
||
sess := &Session{UserID: userID, TenantID: tenantID, RefreshToken: token, ExpiresAt: expires}
|
||
err = s.pool.QueryRow(ctx, `
|
||
INSERT INTO core.sessions (user_id, tenant_id, token_hash, user_agent, ip, expires_at)
|
||
VALUES ($1, $2, $3, $4, $5::inet, $6)
|
||
RETURNING id::text
|
||
`, userID, tenantID, sum[:], nullString(ua), ipArg, expires).Scan(&sess.ID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return sess, nil
|
||
}
|
||
|
||
// RotateSession обмінює refresh-токен на новий.
|
||
//
|
||
// Ротація при кожному обміні, а не подовження строку: викрадений
|
||
// refresh-токен спрацює щонайбільше один раз, і законний власник
|
||
// одразу отримає відмову — це видимий сигнал компрометації.
|
||
func (s *Store) RotateSession(ctx context.Context, refreshToken string, ip net.IP, ua string) (*Session, *User, error) {
|
||
sum := sha256.Sum256([]byte(refreshToken))
|
||
|
||
var (
|
||
oldID, userID, tenantID string
|
||
expires time.Time
|
||
revoked *time.Time
|
||
)
|
||
err := s.pool.QueryRow(ctx, `
|
||
SELECT id::text, user_id::text, COALESCE(tenant_id::text,''), expires_at, revoked_at
|
||
FROM core.sessions WHERE token_hash = $1
|
||
`, sum[:]).Scan(&oldID, &userID, &tenantID, &expires, &revoked)
|
||
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, nil, ErrSessionInvalid
|
||
}
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
if revoked != nil || time.Now().After(expires) {
|
||
return nil, nil, ErrSessionInvalid
|
||
}
|
||
|
||
var u User
|
||
if err := s.pool.QueryRow(ctx, `
|
||
SELECT id::text, email::text, COALESCE(full_name,''), locale, timezone, mfa_enabled
|
||
FROM core.users WHERE id = $1 AND deleted_at IS NULL
|
||
`, userID).Scan(&u.ID, &u.Email, &u.FullName, &u.Locale, &u.Timezone, &u.MFA); err != nil {
|
||
return nil, nil, ErrSessionInvalid
|
||
}
|
||
|
||
// Членство могли відкликати вже після входу — перевіряємо щоразу,
|
||
// інакше звільнений співробітник продовжував би подовжувати сесію.
|
||
var stillMember bool
|
||
if err := s.pool.QueryRow(ctx, `
|
||
SELECT EXISTS (SELECT 1 FROM core.memberships WHERE user_id = $1 AND tenant_id = $2)
|
||
`, userID, tenantID).Scan(&stillMember); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
if !stillMember {
|
||
return nil, nil, ErrSessionInvalid
|
||
}
|
||
|
||
if _, err := s.pool.Exec(ctx,
|
||
`UPDATE core.sessions SET revoked_at = now() WHERE id = $1`, oldID); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
|
||
sess, err := s.CreateSession(ctx, userID, tenantID, ip, ua)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
return sess, &u, nil
|
||
}
|
||
|
||
func (s *Store) RevokeSession(ctx context.Context, refreshToken string) error {
|
||
sum := sha256.Sum256([]byte(refreshToken))
|
||
_, err := s.pool.Exec(ctx, `
|
||
UPDATE core.sessions SET revoked_at = now()
|
||
WHERE token_hash = $1 AND revoked_at IS NULL
|
||
`, sum[:])
|
||
return err
|
||
}
|
||
|
||
// RevokeAllSessions — вихід з усіх пристроїв; те саме робиться при зміні
|
||
// пароля й при видаленні членства.
|
||
func (s *Store) RevokeAllSessions(ctx context.Context, userID string) error {
|
||
_, err := s.pool.Exec(ctx, `
|
||
UPDATE core.sessions SET revoked_at = now()
|
||
WHERE user_id = $1 AND revoked_at IS NULL
|
||
`, userID)
|
||
return err
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Права
|
||
// ---------------------------------------------------------------------
|
||
|
||
// UserPermissions збирає ефективні права людини в тенанті.
|
||
//
|
||
// Джерело — membership → role → role_permissions. Кешувати тут нічого не
|
||
// треба: це один індексний join, а кеш прав — класичне джерело «звільнив,
|
||
// а він ще годину має доступ».
|
||
func (s *Store) UserPermissions(ctx context.Context, userID, tenantID string) ([]string, error) {
|
||
rows, err := s.pool.Query(ctx, `
|
||
SELECT rp.permission_key
|
||
FROM core.memberships m
|
||
JOIN core.role_permissions rp ON rp.role_id = m.role_id
|
||
WHERE m.user_id = $1 AND m.tenant_id = $2
|
||
`, userID, tenantID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var out []string
|
||
for rows.Next() {
|
||
var p string
|
||
if err := rows.Scan(&p); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, p)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// ScopeGroups — обмеження видимості пристроїв для користувача.
|
||
// nil означає «усі пристрої тенанта».
|
||
func (s *Store) ScopeGroups(ctx context.Context, userID, tenantID string) ([]string, error) {
|
||
var groups []string
|
||
err := s.pool.QueryRow(ctx, `
|
||
SELECT scope_group_ids::text[]
|
||
FROM core.memberships WHERE user_id = $1 AND tenant_id = $2
|
||
`, userID, tenantID).Scan(&groups)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNoMembership
|
||
}
|
||
return groups, err
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Команда
|
||
// ---------------------------------------------------------------------
|
||
|
||
func (s *Store) ListTeam(ctx context.Context, tenantID string) ([]TeamMember, error) {
|
||
var out []TeamMember
|
||
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT u.id::text, u.email::text, COALESCE(u.full_name,''),
|
||
r.id::text, r.key::text, r.name,
|
||
u.last_login_at, m.accepted_at, m.scope_group_ids::text[]
|
||
FROM core.memberships m
|
||
JOIN core.users u ON u.id = m.user_id
|
||
JOIN core.roles r ON r.id = m.role_id
|
||
WHERE m.tenant_id = $1 AND u.deleted_at IS NULL
|
||
ORDER BY u.email
|
||
`, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
|
||
for rows.Next() {
|
||
var t TeamMember
|
||
if err := rows.Scan(&t.UserID, &t.Email, &t.FullName,
|
||
&t.RoleID, &t.RoleKey, &t.RoleName,
|
||
&t.LastLogin, &t.AcceptedAt, &t.ScopeGroups); err != nil {
|
||
return err
|
||
}
|
||
out = append(out, t)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
|
||
return out, err
|
||
}
|
||
|
||
func (s *Store) ListRoles(ctx context.Context, tenantID string) ([]Role, error) {
|
||
var out []Role
|
||
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT r.id::text, r.key::text, r.name, COALESCE(r.description,''), r.is_system,
|
||
COALESCE(array_agg(rp.permission_key ORDER BY rp.permission_key)
|
||
FILTER (WHERE rp.permission_key IS NOT NULL), '{}')
|
||
FROM core.roles r
|
||
LEFT JOIN core.role_permissions rp ON rp.role_id = r.id
|
||
WHERE r.tenant_id IS NULL OR r.tenant_id = $1
|
||
GROUP BY r.id
|
||
ORDER BY r.is_system DESC, r.name
|
||
`, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
|
||
for rows.Next() {
|
||
var r Role
|
||
if err := rows.Scan(&r.ID, &r.Key, &r.Name, &r.Description,
|
||
&r.IsSystem, &r.Permissions); err != nil {
|
||
return err
|
||
}
|
||
out = append(out, r)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
|
||
return out, err
|
||
}
|
||
|
||
// CreateUser заводить користувача й одразу дає йому членство.
|
||
//
|
||
// Використовується і CLI-початківцем (перший власник), і запрошеннями.
|
||
func (s *Store) CreateUser(ctx context.Context, tenantID, email, password, fullName, roleKey string) (*User, error) {
|
||
hash, err := auth.HashPassword(password)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var u User
|
||
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
// Користувач глобальний: одна людина може працювати в кількох
|
||
// тенантах (типово для MSP-провайдера).
|
||
err := tx.QueryRow(ctx, `
|
||
INSERT INTO core.users (email, password_hash, full_name, email_verified_at)
|
||
VALUES ($1, $2, NULLIF($3,''), now())
|
||
ON CONFLICT (email) DO UPDATE
|
||
SET full_name = COALESCE(NULLIF(EXCLUDED.full_name,''), core.users.full_name)
|
||
RETURNING id::text, email::text, COALESCE(full_name,''), locale, timezone, mfa_enabled
|
||
`, email, hash, fullName).Scan(&u.ID, &u.Email, &u.FullName, &u.Locale, &u.Timezone, &u.MFA)
|
||
if err != nil {
|
||
return fmt.Errorf("створення користувача: %w", err)
|
||
}
|
||
|
||
var roleID string
|
||
if err := tx.QueryRow(ctx, `
|
||
SELECT id::text FROM core.roles
|
||
WHERE key = $1 AND (tenant_id IS NULL OR tenant_id = $2)
|
||
ORDER BY tenant_id NULLS LAST
|
||
LIMIT 1
|
||
`, roleKey, tenantID).Scan(&roleID); err != nil {
|
||
return fmt.Errorf("роль %q: %w", roleKey, err)
|
||
}
|
||
|
||
if _, err := tx.Exec(ctx, `
|
||
INSERT INTO core.memberships (tenant_id, user_id, role_id, accepted_at)
|
||
VALUES ($1, $2, $3, now())
|
||
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id
|
||
`, tenantID, u.ID, roleID); err != nil {
|
||
return fmt.Errorf("членство: %w", err)
|
||
}
|
||
return nil
|
||
})
|
||
|
||
if err != nil {
|
||
return nil, mapPgError(err)
|
||
}
|
||
return &u, nil
|
||
}
|
||
|
||
// SetRole змінює роль учасника команди.
|
||
func (s *Store) SetRole(ctx context.Context, tenantID, userID, roleID string) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
tag, err := tx.Exec(ctx, `
|
||
UPDATE core.memberships SET role_id = $3
|
||
WHERE tenant_id = $1 AND user_id = $2
|
||
`, tenantID, userID, roleID)
|
||
if err != nil {
|
||
return mapPgError(err)
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// RemoveMember прибирає людину з тенанта.
|
||
//
|
||
// Сам користувач лишається: він може працювати в іншому тенанті, а його
|
||
// сліди в аудиті мають лишатися читабельними. Зате всі сесії саме в
|
||
// цьому тенанті відкликаються негайно.
|
||
func (s *Store) RemoveMember(ctx context.Context, tenantID, userID string) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
tag, err := tx.Exec(ctx, `
|
||
DELETE FROM core.memberships WHERE tenant_id = $1 AND user_id = $2
|
||
`, tenantID, userID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return ErrNotFound
|
||
}
|
||
|
||
_, err = tx.Exec(ctx, `
|
||
UPDATE core.sessions SET revoked_at = now()
|
||
WHERE user_id = $1 AND tenant_id = $2 AND revoked_at IS NULL
|
||
`, userID, tenantID)
|
||
return err
|
||
})
|
||
}
|
||
|
||
// ChangePassword міняє пароль і відкликає всі інші сесії.
|
||
func (s *Store) ChangePassword(ctx context.Context, userID, oldPassword, newPassword string) error {
|
||
var hash *string
|
||
if err := s.pool.QueryRow(ctx,
|
||
`SELECT password_hash FROM core.users WHERE id = $1 AND deleted_at IS NULL`,
|
||
userID).Scan(&hash); err != nil {
|
||
return ErrBadCredentials
|
||
}
|
||
if hash == nil || auth.VerifyPassword(*hash, oldPassword) != nil {
|
||
return ErrBadCredentials
|
||
}
|
||
|
||
newHash, err := auth.HashPassword(newPassword)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if _, err := s.pool.Exec(ctx,
|
||
`UPDATE core.users SET password_hash = $2, updated_at = now() WHERE id = $1`,
|
||
userID, newHash); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Зміна пароля — типова реакція на «здається, мене зламали».
|
||
// Якщо після неї чужа сесія лишається живою, дія безглузда.
|
||
return s.RevokeAllSessions(ctx, userID)
|
||
}
|