added ability to check shared devices
This commit is contained in:
parent
7d0daef923
commit
72db66d4aa
14 changed files with 456 additions and 23 deletions
|
|
@ -157,6 +157,33 @@ type AuthorizationRow struct {
|
||||||
ActiveAt time.Time
|
ActiveAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SharedDeviceAccount is one account whose authorizations matched a
|
||||||
|
// SharedDeviceGroup's device fingerprint.
|
||||||
|
type SharedDeviceAccount struct {
|
||||||
|
UserID int64
|
||||||
|
Phone string
|
||||||
|
Username string
|
||||||
|
FirstName string
|
||||||
|
LastName string
|
||||||
|
ActiveAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// SharedDeviceGroup is a device fingerprint (device_model + system_version +
|
||||||
|
// platform + ip) shared by more than one distinct account's authorizations --
|
||||||
|
// a heuristic multi-accounting signal, not proof: device_model/system_version
|
||||||
|
// are client-reported and spoofable, and ip alone collides naturally behind
|
||||||
|
// NAT, shared wifi, or carrier CGNAT. Treat this as a lead to investigate, not
|
||||||
|
// a verdict.
|
||||||
|
type SharedDeviceGroup struct {
|
||||||
|
DeviceModel string
|
||||||
|
SystemVersion string
|
||||||
|
Platform string
|
||||||
|
IP string
|
||||||
|
AccountCount int
|
||||||
|
LastActiveAt time.Time
|
||||||
|
Accounts []SharedDeviceAccount
|
||||||
|
}
|
||||||
|
|
||||||
type AuditLogRow struct {
|
type AuditLogRow struct {
|
||||||
ID int64
|
ID int64
|
||||||
CommandID string
|
CommandID string
|
||||||
|
|
@ -787,6 +814,88 @@ LIMIT $3`, beforeActiveUS, beforeID, limit+1)
|
||||||
return out, hasMore, nil
|
return out, hasMore, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListSharedDeviceGroups pages through device fingerprints (device_model +
|
||||||
|
// system_version + platform + ip) that more than one distinct account has
|
||||||
|
// authorized from -- see SharedDeviceGroup's doc comment on why this is a
|
||||||
|
// heuristic, not a verdict. Pagination is plain offset/limit over the
|
||||||
|
// aggregated group list (not the raw authorizations table): the number of
|
||||||
|
// *groups* is expected to stay small relative to total session count, so an
|
||||||
|
// offset scan over the pre-aggregated CTE is cheap even though offset
|
||||||
|
// pagination over raw rows elsewhere in this file deliberately uses a keyset
|
||||||
|
// cursor instead.
|
||||||
|
func (s *readStore) ListSharedDeviceGroups(ctx context.Context, offset, limit int) ([]SharedDeviceGroup, bool, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = accountListDefaultLimit
|
||||||
|
}
|
||||||
|
if limit > accountListMaxLimit {
|
||||||
|
limit = accountListMaxLimit
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
WITH device_groups AS (
|
||||||
|
SELECT device_model, system_version, platform, ip,
|
||||||
|
count(DISTINCT user_id)::int AS account_count,
|
||||||
|
max(active_at) AS last_active_at
|
||||||
|
FROM authorizations
|
||||||
|
WHERE device_model <> ''
|
||||||
|
GROUP BY device_model, system_version, platform, ip
|
||||||
|
HAVING count(DISTINCT user_id) > 1
|
||||||
|
ORDER BY max(active_at) DESC, device_model, system_version, platform, ip
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
),
|
||||||
|
members AS (
|
||||||
|
SELECT DISTINCT ON (a.device_model, a.system_version, a.platform, a.ip, a.user_id)
|
||||||
|
a.device_model, a.system_version, a.platform, a.ip, a.user_id, a.active_at
|
||||||
|
FROM authorizations a
|
||||||
|
JOIN device_groups g ON a.device_model = g.device_model AND a.system_version = g.system_version
|
||||||
|
AND a.platform = g.platform AND a.ip = g.ip
|
||||||
|
ORDER BY a.device_model, a.system_version, a.platform, a.ip, a.user_id, a.active_at DESC
|
||||||
|
)
|
||||||
|
SELECT g.device_model, g.system_version, g.platform, g.ip, g.account_count, g.last_active_at,
|
||||||
|
m.user_id, m.active_at, u.phone, u.username, u.first_name, u.last_name
|
||||||
|
FROM device_groups g
|
||||||
|
JOIN members m ON m.device_model = g.device_model AND m.system_version = g.system_version
|
||||||
|
AND m.platform = g.platform AND m.ip = g.ip
|
||||||
|
JOIN users u ON u.id = m.user_id
|
||||||
|
ORDER BY g.last_active_at DESC, g.device_model, g.system_version, g.platform, g.ip, m.active_at DESC`,
|
||||||
|
limit+1, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("list shared device groups: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
groups := make([]SharedDeviceGroup, 0, limit+1)
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
deviceModel, systemVersion, platform, ip string
|
||||||
|
accountCount int
|
||||||
|
lastActiveAt time.Time
|
||||||
|
acc SharedDeviceAccount
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&deviceModel, &systemVersion, &platform, &ip, &accountCount, &lastActiveAt,
|
||||||
|
&acc.UserID, &acc.ActiveAt, &acc.Phone, &acc.Username, &acc.FirstName, &acc.LastName); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if len(groups) == 0 {
|
||||||
|
groups = append(groups, SharedDeviceGroup{DeviceModel: deviceModel, SystemVersion: systemVersion, Platform: platform, IP: ip, AccountCount: accountCount, LastActiveAt: lastActiveAt})
|
||||||
|
} else if last := &groups[len(groups)-1]; last.DeviceModel != deviceModel || last.SystemVersion != systemVersion || last.Platform != platform || last.IP != ip {
|
||||||
|
groups = append(groups, SharedDeviceGroup{DeviceModel: deviceModel, SystemVersion: systemVersion, Platform: platform, IP: ip, AccountCount: accountCount, LastActiveAt: lastActiveAt})
|
||||||
|
}
|
||||||
|
last := &groups[len(groups)-1]
|
||||||
|
last.Accounts = append(last.Accounts, acc)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
hasMore := len(groups) > limit
|
||||||
|
if hasMore {
|
||||||
|
groups = groups[:limit]
|
||||||
|
}
|
||||||
|
return groups, hasMore, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *readStore) AccountDetail(ctx context.Context, userID int64) (AccountDetail, error) {
|
func (s *readStore) AccountDetail(ctx context.Context, userID int64) (AccountDetail, error) {
|
||||||
var out AccountDetail
|
var out AccountDetail
|
||||||
err := s.pool.QueryRow(ctx, `
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
|
|
||||||
109
cmd/telesrv-admin/readstore_shared_devices_integration_test.go
Normal file
109
cmd/telesrv-admin/readstore_shared_devices_integration_test.go
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListSharedDeviceGroups is hand-written SQL grouping authorizations by a
|
||||||
|
// device fingerprint tuple, so the grouping logic (which accounts land in
|
||||||
|
// which group, and that a lone different device never leaks in) can only be
|
||||||
|
// proven against the real schema. Gated on TELESRV_TEST_POSTGRES_DSN like the
|
||||||
|
// rest of this package's integration tests.
|
||||||
|
func TestReadStoreSharedDeviceGroups(t *testing.T) {
|
||||||
|
store, pool := verificationReadStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
suffix := fmt.Sprintf("%d", time.Now().UnixNano()%1_000_000)
|
||||||
|
|
||||||
|
sharedDeviceModel := "Integration-Test-Device-" + suffix
|
||||||
|
sharedSystemVersion := "Test OS 1.0"
|
||||||
|
sharedPlatform := "test-platform"
|
||||||
|
sharedIP := "203.0.113." + suffix[len(suffix)-2:]
|
||||||
|
|
||||||
|
userA := 3_700_000_000 + time.Now().UnixNano()%1_000_000
|
||||||
|
userB := userA + 1
|
||||||
|
userC := userA + 2 // different device -- must never appear in the shared group.
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
for _, id := range []int64{userA, userB, userC} {
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM authorizations WHERE user_id=$1`, id)
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM auth_keys WHERE auth_key_id=$1`, id)
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id=$1`, id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for i, id := range []int64{userA, userB, userC} {
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
INSERT INTO users (id, access_hash, phone, first_name, last_name, username, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, '', $5, now(), now())`,
|
||||||
|
id, id, fmt.Sprintf("+1890%s%d", suffix, i), fmt.Sprintf("Shared%d", i), fmt.Sprintf("shared%d%s", i, suffix)); err != nil {
|
||||||
|
t.Fatalf("seed user %d: %v", id, err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
INSERT INTO auth_keys (auth_key_id, body, server_salt) VALUES ($1, '\x00', 0)`, id); err != nil {
|
||||||
|
t.Fatalf("seed auth key %d: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// userA and userB authorize from the same device fingerprint; userC from a
|
||||||
|
// distinct one, so it must not be pulled into the shared group.
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
INSERT INTO authorizations (user_id, auth_key_id, device_model, system_version, platform, ip, created_at, active_at)
|
||||||
|
VALUES ($1, $1, $2, $3, $4, $5, now(), now())`, userA, sharedDeviceModel, sharedSystemVersion, sharedPlatform, sharedIP); err != nil {
|
||||||
|
t.Fatalf("seed authorization A: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
INSERT INTO authorizations (user_id, auth_key_id, device_model, system_version, platform, ip, created_at, active_at)
|
||||||
|
VALUES ($1, $1, $2, $3, $4, $5, now(), now())`, userB, sharedDeviceModel, sharedSystemVersion, sharedPlatform, sharedIP); err != nil {
|
||||||
|
t.Fatalf("seed authorization B: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
INSERT INTO authorizations (user_id, auth_key_id, device_model, system_version, platform, ip, created_at, active_at)
|
||||||
|
VALUES ($1, $1, $2, $3, $4, $5, now(), now())`, userC, "Other-Device-"+suffix, sharedSystemVersion, sharedPlatform, sharedIP+".other"); err != nil {
|
||||||
|
t.Fatalf("seed authorization C: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
groups, _, err := store.ListSharedDeviceGroups(ctx, 0, 200)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListSharedDeviceGroups: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var found *SharedDeviceGroup
|
||||||
|
for i := range groups {
|
||||||
|
if groups[i].DeviceModel == sharedDeviceModel {
|
||||||
|
found = &groups[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
t.Fatalf("seeded device fingerprint %q not found among %d groups", sharedDeviceModel, len(groups))
|
||||||
|
}
|
||||||
|
if found.AccountCount != 2 {
|
||||||
|
t.Fatalf("AccountCount = %d, want 2", found.AccountCount)
|
||||||
|
}
|
||||||
|
if found.SystemVersion != sharedSystemVersion || found.Platform != sharedPlatform || found.IP != sharedIP {
|
||||||
|
t.Fatalf("group fingerprint = %+v, want system_version=%q platform=%q ip=%q", found, sharedSystemVersion, sharedPlatform, sharedIP)
|
||||||
|
}
|
||||||
|
if len(found.Accounts) != 2 {
|
||||||
|
t.Fatalf("Accounts = %#v, want exactly 2 members", found.Accounts)
|
||||||
|
}
|
||||||
|
seen := map[int64]bool{}
|
||||||
|
for _, acc := range found.Accounts {
|
||||||
|
seen[acc.UserID] = true
|
||||||
|
if acc.UserID == userC {
|
||||||
|
t.Fatalf("userC (different device) leaked into the shared group: %#v", found.Accounts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !seen[userA] || !seen[userB] {
|
||||||
|
t.Fatalf("Accounts = %#v, want both userA=%d and userB=%d present", found.Accounts, userA, userB)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A lone device (userC's) must never itself form a "shared" group.
|
||||||
|
for i := range groups {
|
||||||
|
if groups[i].DeviceModel == "Other-Device-"+suffix {
|
||||||
|
t.Fatalf("a device with only one distinct account formed a group: %+v", groups[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -54,6 +54,7 @@ func (s *server) routes() http.Handler {
|
||||||
mux.Handle("GET /api/session", s.requireAuthAPI(http.HandlerFunc(s.handleSession)))
|
mux.Handle("GET /api/session", s.requireAuthAPI(http.HandlerFunc(s.handleSession)))
|
||||||
mux.Handle("GET /api/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsAPI)))
|
mux.Handle("GET /api/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsAPI)))
|
||||||
mux.Handle("GET /api/accounts/stats", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsStatsAPI)))
|
mux.Handle("GET /api/accounts/stats", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsStatsAPI)))
|
||||||
|
mux.Handle("GET /api/accounts/shared-devices", s.requireAuthAPI(http.HandlerFunc(s.handleSharedDeviceGroupsAPI)))
|
||||||
mux.Handle("GET /api/accounts/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleAccountDetailAPI)))
|
mux.Handle("GET /api/accounts/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleAccountDetailAPI)))
|
||||||
mux.Handle("GET /api/accounts/{id}/avatar", s.requireAuthAPI(http.HandlerFunc(s.handleAccountAvatarAPI)))
|
mux.Handle("GET /api/accounts/{id}/avatar", s.requireAuthAPI(http.HandlerFunc(s.handleAccountAvatarAPI)))
|
||||||
mux.Handle("GET /api/channels", s.requireAuthAPI(http.HandlerFunc(s.handleChannelsAPI)))
|
mux.Handle("GET /api/channels", s.requireAuthAPI(http.HandlerFunc(s.handleChannelsAPI)))
|
||||||
|
|
@ -632,6 +633,33 @@ func (s *server) handleAccountsAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *server) handleSharedDeviceGroupsAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.read == nil {
|
||||||
|
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
offset, _ := parseInt(r.URL.Query().Get("offset"))
|
||||||
|
limit, _ := parseInt(r.URL.Query().Get("limit"))
|
||||||
|
groups, hasMore, err := s.read.ListSharedDeviceGroups(r.Context(), offset, limit)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = accountListDefaultLimit
|
||||||
|
}
|
||||||
|
if limit > accountListMaxLimit {
|
||||||
|
limit = accountListMaxLimit
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
"rows": groups,
|
||||||
|
"has_more": hasMore,
|
||||||
|
"next_offset": offset + limit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *server) handleAccountsStatsAPI(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleAccountsStatsAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.read == nil {
|
if s.read == nil {
|
||||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||||
|
|
|
||||||
10
cmd/telesrv-admin/web/dist/assets/index-CvaJc5Uc.js
vendored
Normal file
10
cmd/telesrv-admin/web/dist/assets/index-CvaJc5Uc.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
cmd/telesrv-admin/web/dist/index.html
vendored
2
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -23,7 +23,7 @@
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/index-Defd2Shn.js"></script>
|
<script type="module" crossorigin src="/assets/index-CvaJc5Uc.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-C7vmH2zd.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-C7vmH2zd.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type {
|
||||||
AccountRatingListResponse,
|
AccountRatingListResponse,
|
||||||
AccountStatsResponse,
|
AccountStatsResponse,
|
||||||
AccountStorageListResponse,
|
AccountStorageListResponse,
|
||||||
|
SharedDeviceGroupListResponse,
|
||||||
StorageStatsResponse,
|
StorageStatsResponse,
|
||||||
AdminLoginResult,
|
AdminLoginResult,
|
||||||
AdminSession,
|
AdminSession,
|
||||||
|
|
@ -154,6 +155,7 @@ export const api = {
|
||||||
logout: () => request<{ ok: boolean }>("/api/logout", { method: "POST", body: "{}" }),
|
logout: () => request<{ ok: boolean }>("/api/logout", { method: "POST", body: "{}" }),
|
||||||
accounts: (params: URLSearchParams) => request<AccountListResponse>(`/api/accounts?${params.toString()}`),
|
accounts: (params: URLSearchParams) => request<AccountListResponse>(`/api/accounts?${params.toString()}`),
|
||||||
accountStats: () => request<AccountStatsResponse>("/api/accounts/stats"),
|
accountStats: () => request<AccountStatsResponse>("/api/accounts/stats"),
|
||||||
|
sharedDeviceGroups: (params: URLSearchParams) => request<SharedDeviceGroupListResponse>(`/api/accounts/shared-devices?${params.toString()}`),
|
||||||
account: (id: number) => request<AccountDetail>(`/api/accounts/${id}`),
|
account: (id: number) => request<AccountDetail>(`/api/accounts/${id}`),
|
||||||
channels: (params: URLSearchParams) => request<ChannelListResponse>(`/api/channels?${params.toString()}`),
|
channels: (params: URLSearchParams) => request<ChannelListResponse>(`/api/channels?${params.toString()}`),
|
||||||
channel: (id: number) => request<ChannelDetail>(`/api/channels/${id}`),
|
channel: (id: number) => request<ChannelDetail>(`/api/channels/${id}`),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ChevronLeft, ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Loader2, RefreshCw, Search, Smartphone } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api, errorMessage } from "../api";
|
import { api, errorMessage } from "../api";
|
||||||
import { Avatar } from "../components/Avatar";
|
import { Avatar } from "../components/Avatar";
|
||||||
|
|
@ -99,17 +99,22 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
|
||||||
title={"Accounts"}
|
title={"Accounts"}
|
||||||
eyebrow={data?.listing === false ? "Search results" : "Recently active accounts"}
|
eyebrow={data?.listing === false ? "Search results" : "Recently active accounts"}
|
||||||
actions={
|
actions={
|
||||||
<button
|
<>
|
||||||
className="btn"
|
<button className="btn icon-text" type="button" onClick={() => navigate("/accounts/shared-devices")}>
|
||||||
type="button"
|
<Smartphone size={15} /> {"Shared devices"}
|
||||||
onClick={() => {
|
</button>
|
||||||
void loadFresh();
|
<button
|
||||||
void loadStats();
|
className="btn"
|
||||||
}}
|
type="button"
|
||||||
disabled={busy}
|
onClick={() => {
|
||||||
>
|
void loadFresh();
|
||||||
<RefreshCw size={15} /> {"Refresh"}
|
void loadStats();
|
||||||
</button>
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<RefreshCw size={15} /> {"Refresh"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{error && <Alert>{error}</Alert>}
|
{error && <Alert>{error}</Alert>}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { AccountDetailPage } from "./AccountDetailPage";
|
||||||
import { AccountRatingDetailPage } from "./AccountRatingDetailPage";
|
import { AccountRatingDetailPage } from "./AccountRatingDetailPage";
|
||||||
import { AccountRatingsPage } from "./AccountRatingsPage";
|
import { AccountRatingsPage } from "./AccountRatingsPage";
|
||||||
import { AccountsPage } from "./AccountsPage";
|
import { AccountsPage } from "./AccountsPage";
|
||||||
|
import { SharedDevicesPage } from "./SharedDevicesPage";
|
||||||
import { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage";
|
import { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage";
|
||||||
import { CollectibleUsernamesPage } from "./CollectibleUsernamesPage";
|
import { CollectibleUsernamesPage } from "./CollectibleUsernamesPage";
|
||||||
import { ChannelDetailPage } from "./ChannelDetailPage";
|
import { ChannelDetailPage } from "./ChannelDetailPage";
|
||||||
|
|
@ -100,6 +101,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
||||||
if (moderationCaseID) {
|
if (moderationCaseID) {
|
||||||
return <ModerationCaseDetailPage id={Number(moderationCaseID)} navigate={navigate} />;
|
return <ModerationCaseDetailPage id={Number(moderationCaseID)} navigate={navigate} />;
|
||||||
}
|
}
|
||||||
|
if (route.path === "/accounts/shared-devices") {
|
||||||
|
return <SharedDevicesPage navigate={navigate} />;
|
||||||
|
}
|
||||||
if (route.path === "/accounts") {
|
if (route.path === "/accounts") {
|
||||||
return <AccountsPage navigate={navigate} />;
|
return <AccountsPage navigate={navigate} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
131
cmd/telesrv-admin/web/src/pages/SharedDevicesPage.tsx
Normal file
131
cmd/telesrv-admin/web/src/pages/SharedDevicesPage.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { ArrowLeft, ChevronDown, ChevronRight, Loader2, RefreshCw, Smartphone } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api, errorMessage } from "../api";
|
||||||
|
import { Avatar } from "../components/Avatar";
|
||||||
|
import { Alert, Badge, EmptyRow, Metric, PageFrame, SectionHead } from "../components/ui";
|
||||||
|
import { displayName, displayPhone, displayUsername, formatDate } from "../lib/format";
|
||||||
|
import type { Navigate } from "../routing";
|
||||||
|
import type { SharedDeviceGroup } from "../types";
|
||||||
|
|
||||||
|
// SharedDevicesPage surfaces authorizations that look like they came from the
|
||||||
|
// same physical device but belong to different accounts -- a lead worth
|
||||||
|
// investigating for multi-accounting, not a verdict: device_model and
|
||||||
|
// system_version are self-reported by the client and easy to spoof, and a
|
||||||
|
// matching IP alone is common and innocent behind NAT, shared wifi, or
|
||||||
|
// carrier CGNAT. Treat every group here as "worth a look", not "guilty".
|
||||||
|
export function SharedDevicesPage({ navigate }: { navigate: Navigate }) {
|
||||||
|
const [groups, setGroups] = useState<SharedDeviceGroup[]>([]);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function load(next = false) {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
const at = next ? offset : 0;
|
||||||
|
const params = new URLSearchParams({ limit: "20", offset: String(at) });
|
||||||
|
try {
|
||||||
|
const result = await api.sharedDeviceGroups(params);
|
||||||
|
const page = result.rows ?? [];
|
||||||
|
setGroups((current) => (next ? [...current, ...page] : page));
|
||||||
|
setOffset(result.next_offset);
|
||||||
|
setHasMore(Boolean(result.has_more));
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load(false);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const totalFlaggedAccounts = groups.reduce((sum, group) => sum + group.AccountCount, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageFrame
|
||||||
|
title={"Shared Devices"}
|
||||||
|
eyebrow={"Multi-account signal — device/IP overlap across different accounts"}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<button className="btn icon-text" type="button" onClick={() => navigate("/accounts")}>
|
||||||
|
<ArrowLeft size={15} /> {"Back to accounts"}
|
||||||
|
</button>
|
||||||
|
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
|
||||||
|
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
<div className="metric-row">
|
||||||
|
<Metric label={"Device groups on page"} value={String(groups.length)} />
|
||||||
|
<Metric label={"Accounts flagged on page"} value={String(totalFlaggedAccounts)} tone="warn" />
|
||||||
|
</div>
|
||||||
|
<p className="about-text">
|
||||||
|
{"Each card below is a device fingerprint (device model + OS + platform + IP) that more than one account has authorized from. "}
|
||||||
|
{"device_model/system_version are self-reported by the client, and IP alone can collide innocently -- use this as a lead, not a verdict."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="stacked-sections">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<section className="section-block" key={`${group.DeviceModel}|${group.SystemVersion}|${group.Platform}|${group.IP}`}>
|
||||||
|
<SectionHead
|
||||||
|
title={group.DeviceModel || "Unknown device"}
|
||||||
|
text={`${group.Platform || "unknown platform"} ${group.SystemVersion} · ${group.IP} · last active ${formatDate(group.LastActiveAt)}`}
|
||||||
|
action={<Badge tone="warn"><Smartphone size={12} /> {`${group.AccountCount} accounts`}</Badge>}
|
||||||
|
/>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="avatar-col"></th>
|
||||||
|
<th>{"User ID"}</th>
|
||||||
|
<th>{"Phone"}</th>
|
||||||
|
<th>{"Username"}</th>
|
||||||
|
<th>{"Name"}</th>
|
||||||
|
<th>{"Active from this device"}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{group.Accounts.map((account) => (
|
||||||
|
<tr key={account.UserID}>
|
||||||
|
<td className="avatar-col"><Avatar id={account.UserID} firstName={account.FirstName} lastName={account.LastName} username={account.Username} /></td>
|
||||||
|
<td className="mono">{account.UserID}</td>
|
||||||
|
<td>{displayPhone(account.Phone)}</td>
|
||||||
|
<td>{displayUsername(account.Username) || "-"}</td>
|
||||||
|
<td>{displayName(account) || "-"}</td>
|
||||||
|
<td>{formatDate(account.ActiveAt)}</td>
|
||||||
|
<td><button className="row-link" onClick={() => navigate(`/accounts/${account.UserID}`)}>{"Details"} <ChevronRight size={14} /></button></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
{groups.length === 0 && (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="data-table">
|
||||||
|
<tbody>
|
||||||
|
<EmptyRow colSpan={7} />
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<div className="toolbar">
|
||||||
|
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
|
||||||
|
{busy ? <Loader2 size={15} className="spin" /> : <ChevronDown size={15} />} {"Load more"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ export function routeTitle(pathname: string): string {
|
||||||
if (pathname.startsWith("/collectible-usernames")) return "Collectible Usernames";
|
if (pathname.startsWith("/collectible-usernames")) return "Collectible Usernames";
|
||||||
if (pathname.startsWith("/account-ratings")) return "Account Rating";
|
if (pathname.startsWith("/account-ratings")) return "Account Rating";
|
||||||
if (pathname.startsWith("/storage")) return "Storage";
|
if (pathname.startsWith("/storage")) return "Storage";
|
||||||
|
if (pathname.startsWith("/accounts/shared-devices")) return "Shared Devices";
|
||||||
if (pathname.startsWith("/accounts")) return "Accounts";
|
if (pathname.startsWith("/accounts")) return "Accounts";
|
||||||
if (pathname.startsWith("/channels")) return "Supergroups and Channels";
|
if (pathname.startsWith("/channels")) return "Supergroups and Channels";
|
||||||
if (pathname.startsWith("/bots")) return "Bots";
|
if (pathname.startsWith("/bots")) return "Bots";
|
||||||
|
|
|
||||||
|
|
@ -789,6 +789,39 @@ export type AccountStatsResponse = {
|
||||||
online: number;
|
online: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// SharedDeviceAccount is one account whose authorizations matched a
|
||||||
|
// SharedDeviceGroup's device fingerprint.
|
||||||
|
export type SharedDeviceAccount = {
|
||||||
|
UserID: number;
|
||||||
|
Phone: string;
|
||||||
|
Username: string;
|
||||||
|
FirstName: string;
|
||||||
|
LastName: string;
|
||||||
|
ActiveAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// SharedDeviceGroup is a device fingerprint (device model + OS + platform +
|
||||||
|
// IP) shared by more than one distinct account -- a heuristic multi-account
|
||||||
|
// signal, not proof (device_model/system_version are client-reported and
|
||||||
|
// spoofable, and IP alone collides behind NAT/shared wifi/carrier CGNAT).
|
||||||
|
export type SharedDeviceGroup = {
|
||||||
|
DeviceModel: string;
|
||||||
|
SystemVersion: string;
|
||||||
|
Platform: string;
|
||||||
|
IP: string;
|
||||||
|
AccountCount: number;
|
||||||
|
LastActiveAt: string;
|
||||||
|
Accounts: SharedDeviceAccount[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SharedDeviceGroupListResponse = {
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
rows: SharedDeviceGroup[];
|
||||||
|
has_more: boolean;
|
||||||
|
next_offset: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type StorageStatsResponse = {
|
export type StorageStatsResponse = {
|
||||||
PhysicalBytes: string;
|
PhysicalBytes: string;
|
||||||
LogicalBytes: string;
|
LogicalBytes: string;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
DROP INDEX IF EXISTS idx_authorizations_device_fingerprint;
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- Supports the admin console's "accounts sharing a device" report, which
|
||||||
|
-- groups authorizations by (device_model, system_version, platform, ip) and
|
||||||
|
-- looks for groups spanning more than one distinct user_id. Without this
|
||||||
|
-- index that GROUP BY is a full sequential scan of authorizations, a table
|
||||||
|
-- that grows roughly with active-session count and is never pruned except on
|
||||||
|
-- explicit revoke.
|
||||||
|
|
||||||
|
CREATE INDEX idx_authorizations_device_fingerprint
|
||||||
|
ON public.authorizations (device_model, system_version, platform, ip)
|
||||||
|
WHERE device_model <> '';
|
||||||
Loading…
Add table
Add a link
Reference in a new issue