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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
ID int64
|
||||
CommandID string
|
||||
|
|
@ -787,6 +814,88 @@ LIMIT $3`, beforeActiveUS, beforeID, limit+1)
|
|||
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) {
|
||||
var out AccountDetail
|
||||
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/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsAPI)))
|
||||
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}/avatar", s.requireAuthAPI(http.HandlerFunc(s.handleAccountAvatarAPI)))
|
||||
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) {
|
||||
if s.read == nil {
|
||||
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 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">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
AccountRatingListResponse,
|
||||
AccountStatsResponse,
|
||||
AccountStorageListResponse,
|
||||
SharedDeviceGroupListResponse,
|
||||
StorageStatsResponse,
|
||||
AdminLoginResult,
|
||||
AdminSession,
|
||||
|
|
@ -154,6 +155,7 @@ export const api = {
|
|||
logout: () => request<{ ok: boolean }>("/api/logout", { method: "POST", body: "{}" }),
|
||||
accounts: (params: URLSearchParams) => request<AccountListResponse>(`/api/accounts?${params.toString()}`),
|
||||
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}`),
|
||||
channels: (params: URLSearchParams) => request<ChannelListResponse>(`/api/channels?${params.toString()}`),
|
||||
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 { api, errorMessage } from "../api";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
|
|
@ -99,6 +99,10 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
|
|||
title={"Accounts"}
|
||||
eyebrow={data?.listing === false ? "Search results" : "Recently active accounts"}
|
||||
actions={
|
||||
<>
|
||||
<button className="btn icon-text" type="button" onClick={() => navigate("/accounts/shared-devices")}>
|
||||
<Smartphone size={15} /> {"Shared devices"}
|
||||
</button>
|
||||
<button
|
||||
className="btn"
|
||||
type="button"
|
||||
|
|
@ -110,6 +114,7 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
|
|||
>
|
||||
<RefreshCw size={15} /> {"Refresh"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { AccountDetailPage } from "./AccountDetailPage";
|
|||
import { AccountRatingDetailPage } from "./AccountRatingDetailPage";
|
||||
import { AccountRatingsPage } from "./AccountRatingsPage";
|
||||
import { AccountsPage } from "./AccountsPage";
|
||||
import { SharedDevicesPage } from "./SharedDevicesPage";
|
||||
import { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage";
|
||||
import { CollectibleUsernamesPage } from "./CollectibleUsernamesPage";
|
||||
import { ChannelDetailPage } from "./ChannelDetailPage";
|
||||
|
|
@ -100,6 +101,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
|||
if (moderationCaseID) {
|
||||
return <ModerationCaseDetailPage id={Number(moderationCaseID)} navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/accounts/shared-devices") {
|
||||
return <SharedDevicesPage navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/accounts") {
|
||||
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("/account-ratings")) return "Account Rating";
|
||||
if (pathname.startsWith("/storage")) return "Storage";
|
||||
if (pathname.startsWith("/accounts/shared-devices")) return "Shared Devices";
|
||||
if (pathname.startsWith("/accounts")) return "Accounts";
|
||||
if (pathname.startsWith("/channels")) return "Supergroups and Channels";
|
||||
if (pathname.startsWith("/bots")) return "Bots";
|
||||
|
|
|
|||
|
|
@ -789,6 +789,39 @@ export type AccountStatsResponse = {
|
|||
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 = {
|
||||
PhysicalBytes: 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