feat(auth): add device session tracking with configurable limits

Track authenticated sessions per device (parsing user-agent for desktop
vs mobile via mileusna/useragent), enforce configurable concurrent device
limits (Auth.DeviceLimits), and surface device status and "remove other
devices" management in the account dialog. Adds device/session context to
the auth module stores and exposes limits through the API.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 01:01:54 +08:00
parent 875fff825c
commit 58f11302fe
32 changed files with 1783 additions and 253 deletions
+3
View File
@@ -42,6 +42,9 @@ Auth:
RefreshTTLSeconds: 2592000
CodeTTLSeconds: 600
DevReturnCode: true
DeviceLimits:
Desktop: 2
Mobile: 1
Email:
Driver: none # none|smtp
Host: ""
+5
View File
@@ -63,3 +63,8 @@ export type AccountDevice = {
client: string;
lastSeenAt: string;
};
export type AccountDeviceLimits = {
desktop: number;
mobile: number;
};
+18 -5
View File
@@ -19,16 +19,27 @@
"username": "Username",
"email": "Email",
"deviceManagement": "Device Management",
"removeAllDevices": "Remove All Devices",
"removeAllDevicesDescription": "A Moteva account is for personal use only. To prevent abuse and keep the platform stable, tasks can run in up to 2 desktop web sessions and 1 mobile web session at the same time. For team or multi-person use, please purchase Team Plan.",
"removeAllDevices": "Remove Other Devices",
"removeAllDevicesDescription": "A Moteva account is for personal use only. To prevent abuse and keep the platform stable, tasks can run in up to {desktop} desktop web sessions and {mobile} mobile web sessions at the same time. For team or multi-person use, please purchase Team Plan.",
"deviceLimitsLoading": "Loading the current device policy.",
"deviceLimitsUnavailable": "The current device policy is temporarily unavailable.",
"onlineStatus": "Online",
"deviceInfo": "Device Info",
"deviceStatus": "Device status",
"currentDevice": "Current Device",
"system": "System",
"currentLoginAccount": "Current account: {name}",
"accountDeviceDesktop": "Desktop",
"accountDeviceMobile": "Mobile",
"accountDeviceUnknownSystem": "Unknown system",
"accountDeviceUnknownBrowser": "Unknown browser",
"deviceOnline": "Online",
"deviceOffline": "Offline",
"deviceLastSeen": "Last active {time}",
"deviceListLoading": "Loading devices",
"deviceListLoadFailed": "Could not load devices",
"noActiveDevices": "No active devices",
"retry": "Retry",
"avatarImageRequired": "Please choose an image file.",
"avatarTooLarge": "Avatar image must be under 2 MB.",
"avatarUpdateFailed": "Failed to update avatar. Please choose the image again.",
@@ -37,9 +48,11 @@
"usernameUpdated": "Username updated.",
"usernameUpdateFailed": "Failed to update username. Please try again later.",
"currentDeviceNotice": "This is the device you are using now.",
"devicesRemoved": "{count} device(s) removed.",
"removeAllDevicesConfirmTitle": "Remove all devices?",
"removeAllDevicesConfirmText": "After confirmation, your current session will be removed and you will need to log in again.",
"devicesRemoved": "{count} other device(s) removed.",
"devicesRemoving": "Removing",
"devicesRemoveFailed": "Could not remove the other devices. Please try again.",
"removeAllDevicesConfirmTitle": "Remove other devices?",
"removeAllDevicesConfirmText": "This will sign out {count} other device(s). Your current device will stay signed in.",
"guides": "Guides",
"contactUs": "Contact us",
"logout": "Log out",
+18 -5
View File
@@ -19,16 +19,27 @@
"username": "用户名",
"email": "电子邮箱",
"deviceManagement": "设备管理",
"removeAllDevices": "移除全部设备",
"removeAllDevicesDescription": "Moteva 账户仅限个人使用。为防止滥用并确保平台稳定,任务最多可同时在 2 个桌面 Web 会话和 1 个移动 Web 会话中运行。如需团队或多人使用,请购买 Team Plan。",
"removeAllDevices": "移除其他设备",
"removeAllDevicesDescription": "Moteva 账户仅限个人使用。为防止滥用并确保平台稳定,任务最多可同时在 {desktop} 个桌面 Web 会话和 {mobile} 个移动 Web 会话中运行。如需团队或多人使用,请购买 Team Plan。",
"deviceLimitsLoading": "正在加载当前设备策略。",
"deviceLimitsUnavailable": "当前设备策略暂时无法加载。",
"onlineStatus": "在线状态",
"deviceInfo": "设备信息",
"deviceStatus": "设备状态",
"currentDevice": "当前设备",
"system": "系统",
"currentLoginAccount": "当前登录账号:{name}",
"accountDeviceDesktop": "桌面端",
"accountDeviceMobile": "移动端",
"accountDeviceUnknownSystem": "未知系统",
"accountDeviceUnknownBrowser": "未知浏览器",
"deviceOnline": "在线",
"deviceOffline": "离线",
"deviceLastSeen": "最近活跃 {time}",
"deviceListLoading": "正在加载设备",
"deviceListLoadFailed": "设备列表加载失败",
"noActiveDevices": "暂无活跃设备",
"retry": "重试",
"avatarImageRequired": "请选择图片文件。",
"avatarTooLarge": "头像图片不能超过 2MB。",
"avatarUpdateFailed": "头像更新失败,请重新选择图片。",
@@ -37,9 +48,11 @@
"usernameUpdated": "用户名已更新。",
"usernameUpdateFailed": "用户名更新失败,请稍后重试。",
"currentDeviceNotice": "这是当前正在使用的设备。",
"devicesRemoved": "已移除 {count} 设备。",
"removeAllDevicesConfirmTitle": "移除全部设备?",
"removeAllDevicesConfirmText": "确认后将移除当前登录状态,并需要重新登录。",
"devicesRemoved": "已移除 {count} 个其他设备。",
"devicesRemoving": "正在移除",
"devicesRemoveFailed": "设备移除失败,请稍后重试。",
"removeAllDevicesConfirmTitle": "移除其他设备?",
"removeAllDevicesConfirmText": "将退出 {count} 个其他设备上的登录状态,当前设备会保持登录。",
"guides": "使用指南",
"contactUs": "联系我们",
"logout": "退出登录",
+2 -2
View File
@@ -1,4 +1,4 @@
import type { AccountDevice, AuthCodeResponse, AuthOptions, AuthSession, AuthUser, StoredAuthSession } from "@/domain/auth";
import type { AccountDevice, AccountDeviceLimits, AuthCodeResponse, AuthOptions, AuthSession, AuthUser, StoredAuthSession } from "@/domain/auth";
import { createApiFailure, createNetworkFailure } from "@/infrastructure/userMessages";
const sessionStorageKey = "img-infinite-canvas.auth.session";
@@ -100,7 +100,7 @@ export const authGateway = {
},
devices() {
return apiRequest<{ devices: AccountDevice[] }>("/api/account/devices");
return apiRequest<{ devices: AccountDevice[]; limits: AccountDeviceLimits }>("/api/account/devices");
},
removeDevices() {
@@ -348,6 +348,7 @@
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 0 12px;
border: 1px solid #e0e3e8;
border-radius: 8px;
@@ -371,27 +372,21 @@
}
.account-management-button.danger {
border-color: transparent;
color: #8d939d;
background: rgba(17, 24, 39, 0.06);
border-color: #efc8c2;
color: #b53b2c;
background: #fff;
}
.account-management-button.danger:hover:not(:disabled) {
color: #d64d34;
background: rgba(214, 77, 52, 0.1);
border-color: #e4ada4;
color: #a63225;
background: #fdf3f1;
}
.account-management-button.small {
min-width: auto;
height: 28px;
padding: 0 10px;
font-size: 13px;
line-height: 18px;
}
.account-management-button.current {
.account-management-button.danger:disabled {
border-color: transparent;
color: #aeb4bd;
background: rgba(17, 24, 39, 0.03);
background: rgba(17, 24, 39, 0.05);
}
.account-management-device-action {
@@ -424,62 +419,221 @@
}
.account-management-device-table {
display: grid;
grid-template-columns: 100px minmax(0, 1fr) 120px;
overflow: hidden;
border-top: 1px solid #e5e7eb;
}
.account-management-device-col {
min-width: 0;
display: flex;
flex-direction: column;
.account-management-device-header,
.account-management-device-row {
display: grid;
grid-template-columns: 96px minmax(0, 1fr) 112px;
}
.account-management-device-head,
.account-management-device-cell {
display: flex;
align-items: center;
.account-management-device-header {
min-height: 38px;
border-bottom: 1px solid #e5e7eb;
}
.account-management-device-head {
height: 36px;
color: #737a86;
font-size: 12px;
line-height: 16px;
font-weight: 500;
}
.account-management-device-col.info .account-management-device-head,
.account-management-device-col.info .account-management-device-cell {
padding: 0 12px;
}
.account-management-device-col.action .account-management-device-cell {
justify-content: flex-end;
}
.account-management-device-cell {
height: 48px;
color: #20242c;
font-size: 12px;
line-height: 16px;
}
.account-management-device-cell span {
.account-management-device-header > span {
display: flex;
align-items: center;
min-width: 0;
}
.account-management-device-header > span:first-child {
padding-left: 12px;
}
.account-management-device-row {
min-height: 62px;
border-bottom: 1px solid #e5e7eb;
color: #20242c;
}
.account-management-device-status,
.account-management-device-info,
.account-management-device-row-action {
min-width: 0;
display: flex;
align-items: center;
}
.account-management-device-status {
padding-left: 16px;
}
.account-management-device-dot {
width: 8px;
height: 8px;
flex: 0 0 auto;
border-radius: 999px;
background: #b7bdc6;
box-shadow: 0 0 0 3px rgba(183, 189, 198, 0.16);
}
.account-management-device-dot.online {
background: #2fc36c;
box-shadow: 0 0 0 3px rgba(47, 195, 108, 0.14);
}
.account-management-device-info {
gap: 10px;
padding: 9px 12px 9px 0;
}
.account-management-device-info > svg {
flex: 0 0 auto;
color: #7a818c;
}
.account-management-device-info > span {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.account-management-device-info strong,
.account-management-device-info small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-management-online-dot {
width: 7px;
height: 7px;
margin-left: 9px;
.account-management-device-info strong {
color: #262a31;
font-size: 12px;
line-height: 17px;
font-weight: 550;
}
.account-management-device-info small {
color: #818895;
font-size: 11px;
line-height: 15px;
}
.account-management-device-row-action {
justify-content: flex-end;
}
.account-management-device-current {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border: 1px solid #e1e4e9;
border-radius: 8px;
color: #8d949f;
background: #fafafa;
font-size: 12px;
line-height: 18px;
font-weight: 500;
white-space: nowrap;
}
.account-management-device-state {
min-height: 88px;
border-bottom: 1px solid #e5e7eb;
}
.account-management-device-state > div {
min-height: 88px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 18px;
color: #777f8b;
font-size: 13px;
line-height: 18px;
}
.account-management-device-state svg {
flex: 0 0 auto;
}
.account-management-device-retry {
min-height: 30px;
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: 4px;
padding: 0 9px;
border: 1px solid #dfe3e8;
border-radius: 7px;
color: #343943;
background: #fff;
font-size: 12px;
font-weight: 600;
}
.account-management-device-retry:hover {
background: #f6f7f8;
}
.account-management-device-skeleton {
display: block;
border-radius: 5px;
background: #eceef1;
animation: account-management-device-pulse 1.4s ease-in-out infinite;
}
.account-management-device-skeleton.dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #31cf74;
}
.account-management-device-skeleton.icon {
width: 17px;
height: 17px;
flex: 0 0 auto;
}
.account-management-device-skeleton.line {
height: 10px;
}
.account-management-device-skeleton.line.primary {
width: min(230px, 42vw);
}
.account-management-device-skeleton.line.secondary {
width: min(170px, 32vw);
height: 8px;
}
.account-management-device-skeleton.badge {
width: 70px;
height: 28px;
}
.account-management-sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
padding: 0;
margin: -1px;
border: 0;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
@keyframes account-management-device-pulse {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
.account-management-logout-copy {
@@ -540,6 +694,10 @@
padding: 0 14px;
border: 1px solid #e0e3e8;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
color: #20242c;
background: #fff;
font-size: 14px;
@@ -552,6 +710,11 @@
background: #d64d34;
}
.account-management-confirm button:disabled {
cursor: wait;
opacity: 0.58;
}
@media (max-width: 820px) {
.account-management-backdrop {
padding: 16px;
@@ -590,9 +753,40 @@
gap: 14px;
}
.account-management-device-table {
grid-template-columns: 76px minmax(220px, 1fr) 106px;
overflow-x: auto;
.account-management-device-action .account-management-button {
width: 100%;
}
.account-management-device-header {
display: none;
}
.account-management-device-row {
grid-template-columns: 26px minmax(0, 1fr);
padding: 12px 0;
}
.account-management-device-status {
align-items: flex-start;
padding: 7px 0 0 4px;
}
.account-management-device-info {
padding: 0;
}
.account-management-device-row-action {
grid-column: 2;
justify-content: flex-start;
padding-top: 8px;
}
.account-management-device-row.loading .account-management-device-row-action {
display: none;
}
.account-management-device-state > div {
flex-wrap: wrap;
}
.account-management-name-form {
@@ -605,3 +799,9 @@
width: min(320px, 100%);
}
}
@media (prefers-reduced-motion: reduce) {
.account-management-device-skeleton {
animation: none;
}
}
@@ -1,9 +1,9 @@
"use client";
import { CircleUserRound, PencilLine, ReceiptText, X, Zap } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent, type ReactNode } from "react";
import { AlertCircle, CircleUserRound, Loader2, Monitor, MonitorX, PencilLine, ReceiptText, RefreshCw, Smartphone, X, Zap } from "lucide-react";
import { useCallback, useEffect, useRef, useState, type ChangeEvent, type FormEvent, type ReactNode } from "react";
import { createPortal } from "react-dom";
import type { AccountDevice, AuthUser } from "@/domain/auth";
import type { AccountDevice, AccountDeviceLimits, AuthUser } from "@/domain/auth";
import { useI18n } from "@/i18n/i18n";
import { authGateway } from "@/infrastructure/authGateway";
@@ -15,12 +15,18 @@ type AccountManagementDialogProps = {
onUserUpdate: (userPatch: Partial<AuthUser>) => Promise<unknown>;
};
type DeviceLoadState = "loading" | "ready" | "error";
export function AccountManagementDialog({ open, user, onClose, onLogout, onUserUpdate }: AccountManagementDialogProps) {
const { t } = useI18n();
const { t, locale } = useI18n();
const avatarInputRef = useRef<HTMLInputElement | null>(null);
const deviceRequestRef = useRef(0);
const scrollRevealTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [confirmDeviceRemoval, setConfirmDeviceRemoval] = useState(false);
const [devices, setDevices] = useState<AccountDevice[]>([]);
const [deviceLimits, setDeviceLimits] = useState<AccountDeviceLimits | null>(null);
const [deviceLoadState, setDeviceLoadState] = useState<DeviceLoadState>("loading");
const [deviceRemoving, setDeviceRemoving] = useState(false);
const [editingName, setEditingName] = useState(false);
const [nameDraft, setNameDraft] = useState("");
const [feedback, setFeedback] = useState<{ tone: "success" | "error"; message: string } | null>(null);
@@ -30,15 +36,36 @@ export function AccountManagementDialog({ open, user, onClose, onLogout, onUserU
const displayEmail = user?.email?.trim() || "";
const displayAvatar = cleanAvatarUrl(user?.avatarUrl);
const avatarLabel = avatarInitials(displayName || displayEmail);
const loginTimestamp = useMemo(() => formatUtcTimestamp(new Date()), []);
const accountDevices = devices.length > 0 ? devices : [defaultCurrentDevice(loginTimestamp)];
const hasRemovableDevices = accountDevices.some((device) => !device.current);
const removableDeviceCount = devices.filter((device) => !device.current).length;
const hasRemovableDevices = deviceLoadState === "ready" && removableDeviceCount > 0;
const loadDevices = useCallback(async () => {
const requestId = ++deviceRequestRef.current;
setDeviceLoadState("loading");
try {
const { devices: nextDevices, limits } = await authGateway.devices();
if (deviceRequestRef.current !== requestId) return;
setDevices(nextDevices);
setDeviceLimits(limits);
setDeviceLoadState("ready");
} catch {
if (deviceRequestRef.current !== requestId) return;
setDevices([]);
setDeviceLimits(null);
setDeviceLoadState("error");
}
}, []);
useEffect(() => {
if (!open) return;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
if (event.key !== "Escape" || deviceRemoving) return;
if (confirmDeviceRemoval) {
setConfirmDeviceRemoval(false);
return;
}
onClose();
};
const previousOverflow = document.body.style.overflow;
const previousDocumentOverflow = document.documentElement.style.overflow;
@@ -51,28 +78,23 @@ export function AccountManagementDialog({ open, user, onClose, onLogout, onUserU
document.documentElement.style.overflow = previousDocumentOverflow;
document.removeEventListener("keydown", closeOnEscape);
};
}, [onClose, open]);
}, [confirmDeviceRemoval, deviceRemoving, onClose, open]);
useEffect(() => {
if (!open) return;
let active = true;
authGateway
.devices()
.then(({ devices: nextDevices }) => {
if (active) setDevices(nextDevices);
})
.catch(() => {
if (active) setDevices([defaultCurrentDevice(loginTimestamp)]);
});
void loadDevices();
return () => {
active = false;
deviceRequestRef.current++;
};
}, [loginTimestamp, open]);
}, [loadDevices, open]);
useEffect(() => {
if (open) return;
setConfirmDeviceRemoval(false);
setDevices([]);
setDeviceLimits(null);
setDeviceLoadState("loading");
setDeviceRemoving(false);
setEditingName(false);
setFeedback(null);
setProfileSaving(false);
@@ -149,11 +171,30 @@ export function AccountManagementDialog({ open, user, onClose, onLogout, onUserU
};
const removeAllDevices = async () => {
setConfirmDeviceRemoval(false);
const response = await authGateway.removeDevices();
const { devices: nextDevices } = await authGateway.devices();
setDevices(nextDevices);
showFeedback("success", t("devicesRemoved", { count: response.removed }));
if (deviceRemoving) return;
setDeviceRemoving(true);
try {
const response = await authGateway.removeDevices();
setDevices((currentDevices) => currentDevices.filter((device) => device.current));
setDeviceLoadState("ready");
setConfirmDeviceRemoval(false);
showFeedback("success", t("devicesRemoved", { count: response.removed }));
const requestId = ++deviceRequestRef.current;
try {
const { devices: nextDevices, limits } = await authGateway.devices();
if (deviceRequestRef.current === requestId) {
setDevices(nextDevices);
setDeviceLimits(limits);
}
} catch {
// The destructive action succeeded; keep the optimistic current-device result.
}
} catch {
showFeedback("error", t("devicesRemoveFailed"));
} finally {
setDeviceRemoving(false);
}
};
const revealScrollbarWhileScrolling = () => {
@@ -171,11 +212,11 @@ export function AccountManagementDialog({ open, user, onClose, onLogout, onUserU
role="presentation"
onWheelCapture={(event) => event.stopPropagation()}
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
if (!deviceRemoving && event.target === event.currentTarget) onClose();
}}
>
<section className="account-management-dialog" role="dialog" aria-modal="true" aria-label={t("accountManagement")}>
<button className="account-management-close" type="button" onClick={onClose} aria-label={t("close")}>
<button className="account-management-close" type="button" onClick={onClose} aria-label={t("close")} disabled={deviceRemoving}>
<X size={21} strokeWidth={1.8} />
</button>
@@ -269,49 +310,78 @@ export function AccountManagementDialog({ open, user, onClose, onLogout, onUserU
<div className="account-management-device-action">
<div>
<strong>{t("removeAllDevices")}</strong>
<p>{t("removeAllDevicesDescription")}</p>
<p>
{deviceLimits
? t("removeAllDevicesDescription", { desktop: deviceLimits.desktop, mobile: deviceLimits.mobile })
: t(deviceLoadState === "loading" ? "deviceLimitsLoading" : "deviceLimitsUnavailable")}
</p>
</div>
<button
className="account-management-button danger"
type="button"
onClick={() => setConfirmDeviceRemoval(true)}
disabled={!hasRemovableDevices}
disabled={!hasRemovableDevices || deviceRemoving}
>
<MonitorX size={16} strokeWidth={1.8} />
{t("removeAllDevices")}
</button>
</div>
<div className="account-management-device-table" role="table" aria-label={t("deviceManagement")}>
<DeviceColumn
title={t("onlineStatus")}
className="status"
cells={accountDevices.map((device) => <span key={device.id} className={device.online ? "account-management-online-dot" : ""} />)}
/>
<DeviceColumn
title={t("deviceInfo")}
className="info"
cells={accountDevices.map((device) => {
const info = deviceInfoText(device, t);
return (
<span key={device.id} title={info}>
{info}
</span>
);
})}
/>
<DeviceColumn
title=""
className="action"
cells={accountDevices.map((device) =>
device.current ? (
<button key={device.id} className="account-management-button small current" type="button" disabled>
{t("currentDevice")}
<div
className="account-management-device-table"
role="table"
aria-label={t("deviceManagement")}
aria-busy={deviceLoadState === "loading" || deviceRemoving}
>
<div className="account-management-device-header" role="row">
<span role="columnheader">{t("onlineStatus")}</span>
<span role="columnheader">{t("deviceInfo")}</span>
<span role="columnheader" aria-label={t("deviceStatus")} />
</div>
<div className="account-management-device-body" role="rowgroup">
{deviceLoadState === "loading" && <DeviceLoadingRows label={t("deviceListLoading")} />}
{deviceLoadState === "error" && (
<DeviceTableState icon={<AlertCircle size={18} />} message={t("deviceListLoadFailed")}>
<button className="account-management-device-retry" type="button" onClick={() => void loadDevices()}>
<RefreshCw size={14} strokeWidth={1.9} />
{t("retry")}
</button>
) : (
<span key={device.id} />
)
</DeviceTableState>
)}
/>
{deviceLoadState === "ready" && devices.length === 0 && (
<DeviceTableState icon={<Monitor size={18} />} message={t("noActiveDevices")}>
<button className="account-management-device-retry" type="button" onClick={() => void loadDevices()}>
<RefreshCw size={14} strokeWidth={1.9} />
{t("retry")}
</button>
</DeviceTableState>
)}
{deviceLoadState === "ready" &&
devices.map((device) => {
const primaryInfo = devicePrimaryInfo(device, t);
const secondaryInfo = deviceSecondaryInfo(device, t, locale);
const onlineLabel = device.online ? t("deviceOnline") : t("deviceOffline");
const DeviceIcon = device.deviceType === "mobile" ? Smartphone : Monitor;
return (
<div className="account-management-device-row" role="row" key={device.id}>
<div className="account-management-device-status" role="cell" title={onlineLabel}>
<span className={`account-management-device-dot ${device.online ? "online" : "offline"}`} aria-hidden="true" />
<span className="account-management-sr-only">{onlineLabel}</span>
</div>
<div className="account-management-device-info" role="cell">
<DeviceIcon size={17} strokeWidth={1.7} aria-hidden="true" />
<span>
<strong title={primaryInfo}>{primaryInfo}</strong>
<small title={secondaryInfo}>{secondaryInfo}</small>
</span>
</div>
<div className="account-management-device-row-action" role="cell">
{device.current && <span className="account-management-device-current">{t("currentDevice")}</span>}
</div>
</div>
);
})}
</div>
</div>
</section>
@@ -334,16 +404,23 @@ export function AccountManagementDialog({ open, user, onClose, onLogout, onUserU
</main>
</div>
{confirmDeviceRemoval && (
<div className="account-management-confirm-backdrop" role="presentation">
<div
className="account-management-confirm-backdrop"
role="presentation"
onMouseDown={(event) => {
if (!deviceRemoving && event.target === event.currentTarget) setConfirmDeviceRemoval(false);
}}
>
<div className="account-management-confirm" role="alertdialog" aria-modal="true" aria-label={t("removeAllDevices")}>
<h4>{t("removeAllDevicesConfirmTitle")}</h4>
<p>{t("removeAllDevicesConfirmText")}</p>
<p>{t("removeAllDevicesConfirmText", { count: removableDeviceCount })}</p>
<div>
<button type="button" onClick={() => setConfirmDeviceRemoval(false)}>
<button type="button" onClick={() => setConfirmDeviceRemoval(false)} disabled={deviceRemoving}>
{t("cancel")}
</button>
<button className="danger" type="button" onClick={() => void removeAllDevices()}>
{t("removeAllDevices")}
<button className="danger" type="button" onClick={() => void removeAllDevices()} disabled={deviceRemoving}>
{deviceRemoving && <Loader2 className="spin" size={15} aria-hidden="true" />}
{deviceRemoving ? t("devicesRemoving") : t("removeAllDevices")}
</button>
</div>
</div>
@@ -355,35 +432,38 @@ export function AccountManagementDialog({ open, user, onClose, onLogout, onUserU
);
}
function formatUtcTimestamp(date: Date) {
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");
const hour = String(date.getUTCHours()).padStart(2, "0");
const minute = String(date.getUTCMinutes()).padStart(2, "0");
return `${year}-${month}-${day} ${hour}:${minute}(UTC)`;
}
function defaultCurrentDevice(lastSeenAt: string): AccountDevice {
return {
id: "current",
online: true,
current: true,
deviceType: "desktop",
system: "unknown",
browser: "unknown",
client: "PC-WEB",
lastSeenAt
};
}
function deviceInfoText(device: AccountDevice, t: (key: string) => string) {
const deviceType = device.deviceType === "desktop" ? t("accountDeviceDesktop") : device.deviceType || t("accountDeviceDesktop");
function devicePrimaryInfo(device: AccountDevice, t: (key: string, params?: Record<string, string | number>) => string) {
const deviceType = device.deviceType === "mobile" ? t("accountDeviceMobile") : t("accountDeviceDesktop");
const system = device.system === "unknown" ? t("accountDeviceUnknownSystem") : device.system || t("accountDeviceUnknownSystem");
const browser = device.browser === "unknown" ? t("accountDeviceUnknownBrowser") : device.browser || t("accountDeviceUnknownBrowser");
return `${deviceType} · ${system} · ${browser}`;
}
return `${deviceType} | ${system} | ${browser} | ${device.client || "PC-WEB"} | | ${device.lastSeenAt}`;
function deviceSecondaryInfo(
device: AccountDevice,
t: (key: string, params?: Record<string, string | number>) => string,
locale: string
) {
const fallbackClient = device.deviceType === "mobile" ? "MOBILE-WEB" : "PC-WEB";
return `${device.client || fallbackClient} · ${t("deviceLastSeen", { time: formatDeviceLastSeen(device.lastSeenAt, locale) })}`;
}
function formatDeviceLastSeen(value: string, locale: string) {
const trimmed = value.trim();
if (!trimmed) return "-";
const legacyUTC = trimmed.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})(?::(\d{2}))?\(UTC\)$/);
const instant = new Date(legacyUTC ? `${legacyUTC[1]}T${legacyUTC[2]}:${legacyUTC[3] || "00"}Z` : trimmed);
if (Number.isNaN(instant.getTime())) return trimmed;
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
timeZoneName: "short"
}).format(instant);
}
function avatarInitials(value: string) {
@@ -401,17 +481,39 @@ function cleanAvatarUrl(value: string | undefined) {
return avatarUrl;
}
function DeviceColumn({ title, className, cells }: { title: string; className: string; cells: ReactNode[] }) {
function DeviceLoadingRows({ label }: { label: string }) {
return (
<div className={`account-management-device-col ${className}`}>
<div className="account-management-device-head" role="columnheader">
{title}
</div>
{cells.map((cell, index) => (
<div className="account-management-device-cell" role="cell" key={index}>
{cell}
<>
{[0, 1].map((index) => (
<div className="account-management-device-row loading" role="row" key={index} aria-hidden="true">
<div className="account-management-device-status" role="cell">
<span className="account-management-device-skeleton dot" />
{index === 0 && <span className="account-management-sr-only">{label}</span>}
</div>
<div className="account-management-device-info" role="cell">
<span className="account-management-device-skeleton icon" />
<span>
<i className="account-management-device-skeleton line primary" />
<i className="account-management-device-skeleton line secondary" />
</span>
</div>
<div className="account-management-device-row-action" role="cell">
<span className="account-management-device-skeleton badge" />
</div>
</div>
))}
</>
);
}
function DeviceTableState({ icon, message, children }: { icon: ReactNode; message: string; children: ReactNode }) {
return (
<div className="account-management-device-state" role="row">
<div role="cell" aria-colspan={3}>
{icon}
<span>{message}</span>
{children}
</div>
</div>
);
}
File diff suppressed because one or more lines are too long
+38 -3
View File
@@ -190,15 +190,50 @@ Updates account display name and avatar URL. The frontend uploads avatar files t
`GET /api/account/devices`
Returns the current local web session. This version does not maintain a multi-device session registry yet, so the current device is read-only and cannot be removed.
Returns every non-revoked, non-expired web session for the authenticated account. The current session is listed first, followed by most recently active sessions.
```json
{
"devices": [
{
"id": "62dc9af7-e44a-4f47-a0bc-b3408d17c33f",
"online": true,
"current": true,
"deviceType": "desktop",
"system": "macOS",
"browser": "Chrome 126",
"client": "PC-WEB",
"lastSeenAt": "2026-07-11T09:00:00Z"
}
],
"limits": {
"desktop": 2,
"mobile": 1
}
}
```
`limits` is sourced from `Auth.DeviceLimits.Desktop` and `Auth.DeviceLimits.Mobile` in service configuration. It is returned with the device list so clients display the effective policy without hard-coded counts. Defaults are 2 desktop web sessions and 1 mobile web session.
Each signed access and refresh token carries an opaque random session ID. Bearer authentication validates that server-side session before accepting the token, so revoked sessions stop working immediately. Device metadata is derived from request headers for display only and is never an authorization signal. A session is reported online when it is current or was active during the previous five minutes.
`lastSeenAt` is an RFC3339 UTC instant. Clients must render it in the browser's local timezone rather than displaying the UTC wire representation directly.
Valid legacy access tokens without a session ID are migrated to a revocable server-side session on first use after their user subject is verified. This preserves existing logins during rollout.
`DELETE /api/account/devices`
Removes non-current devices when a future server-side session registry exists. In the current local-only implementation, it returns `{"removed": 0}`.
Atomically revokes every active session for the account except the current session. Returns the number of sessions revoked:
```json
{
"removed": 2
}
```
`POST /api/account/logout`
Records a logout request server-side and returns `{"removed": 0}`. The frontend then clears its locally stored session.
Revokes the current server-side session and returns `{"removed": 1}` when it was active. The frontend then clears its locally stored token. Other device sessions remain signed in.
## Projects
+6
View File
@@ -47,6 +47,10 @@ JobQueue:
MaxRetry: 3
TimeoutSeconds: 900
ShutdownTimeoutSeconds: 30
Auth:
DeviceLimits:
Desktop: 2
Mobile: 1
Sharing:
EncryptionSecretEnv: SHARING_ENCRYPTION_SECRET
Agent:
@@ -77,6 +81,8 @@ ObjectStorage:
ExpiresIn: 900
```
`Auth.DeviceLimits` is the source of truth for the desktop and mobile web-session counts shown in account device management. The effective values are returned by `GET /api/account/devices`.
`Agent.Image.InputImageTransport` controls how input/reference images are sent to the image model for edit/image-to-image calls: `file` uploads image bytes with multipart form data, while `url` sends `images: [{"image_url": "..."}]` for gateways that can fetch public URLs directly.
Start local Docker dependencies for debugging:
+3
View File
@@ -42,6 +42,9 @@ Auth:
RefreshTTLSeconds: 2592000
CodeTTLSeconds: 600
DevReturnCode: true
DeviceLimits:
Desktop: 1
Mobile: 1
Email:
Driver: smtp # none|smtp
Host: "smtp.126.com"
+1
View File
@@ -9,6 +9,7 @@ require (
github.com/gorilla/websocket v1.5.3
github.com/hibiken/asynq v0.26.0
github.com/jackc/pgx/v5 v5.8.0
github.com/mileusna/useragent v1.3.5
github.com/minio/minio-go/v7 v7.2.1
github.com/redis/go-redis/v9 v9.19.0
github.com/sky-valley/pi v0.2.8
+2
View File
@@ -71,6 +71,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
+7 -1
View File
@@ -148,8 +148,14 @@ type AccountDevice {
LastSeenAt string `json:"lastSeenAt"`
}
type AccountDeviceLimits {
Desktop int64 `json:"desktop"`
Mobile int64 `json:"mobile"`
}
type AccountDeviceListResponse {
Devices []AccountDevice `json:"devices"`
Devices []AccountDevice `json:"devices"`
Limits AccountDeviceLimits `json:"limits"`
}
type AccountDeviceMutationResponse {
+6
View File
@@ -91,12 +91,18 @@ type AuthConfig struct {
RefreshTTLSeconds int64 `json:",default=2592000"`
CodeTTLSeconds int64 `json:",default=600"`
DevReturnCode bool `json:",default=true"`
DeviceLimits AuthDeviceLimitsConfig
Email EmailAuthConfig
Turnstile TurnstileAuthConfig
Google GoogleAuthConfig
Wechat WechatAuthConfig
}
type AuthDeviceLimitsConfig struct {
Desktop int64 `json:",default=2"`
Mobile int64 `json:",default=1"`
}
type EmailAuthConfig struct {
Driver string `json:",default=none"`
Host string `json:",optional"`
@@ -15,6 +15,10 @@ type bearerAuthenticator interface {
UserIDFromBearer(ctx context.Context, authorization string) (string, bool)
}
type bearerIdentityAuthenticator interface {
IdentityFromBearer(ctx context.Context, authorization string) (authmodule.Identity, bool)
}
type profileAuthenticator interface {
GetProfile(ctx context.Context, userID string) (authmodule.User, error)
}
@@ -30,10 +34,22 @@ func UserContextMiddleware(authenticator bearerAuthenticator, shareAuthorizers .
}
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := authmodule.ContextWithRequestMetadata(r.Context(), authmodule.RequestMetadata{
UserAgent: r.Header.Get("User-Agent"),
Platform: r.Header.Get("Sec-CH-UA-Platform"),
Mobile: r.Header.Get("Sec-CH-UA-Mobile"),
})
userID := ""
sessionID := ""
authenticated := false
if authenticator != nil {
if tokenUserID, ok := userIDFromRequestAuth(r.Context(), r, authenticator); ok {
if identityAuthenticator, ok := authenticator.(bearerIdentityAuthenticator); ok {
if identity, valid := identityAuthenticator.IdentityFromBearer(ctx, r.Header.Get("Authorization")); valid {
userID = identity.UserID
sessionID = identity.SessionID
authenticated = true
}
} else if tokenUserID, ok := userIDFromRequestAuth(ctx, r, authenticator); ok {
userID = tokenUserID
authenticated = true
}
@@ -41,7 +57,7 @@ func UserContextMiddleware(authenticator bearerAuthenticator, shareAuthorizers .
actor := sharingmodule.Actor{Authenticated: authenticated, UserID: userID}
if authenticated && requestUsesShareAccess(r) {
if profiles, ok := authenticator.(profileAuthenticator); ok {
profile, err := profiles.GetProfile(r.Context(), userID)
profile, err := profiles.GetProfile(ctx, userID)
if err != nil {
writeAuthRequired(w)
return
@@ -54,7 +70,8 @@ func UserContextMiddleware(authenticator bearerAuthenticator, shareAuthorizers .
}
}
ctx := sharingmodule.ContextWithActor(r.Context(), actor)
ctx = sharingmodule.ContextWithActor(ctx, actor)
ctx = authmodule.ContextWithSessionID(ctx, sessionID)
ctx = design.ContextWithUserID(ctx, userID)
shareAuthorized := false
shareID := strings.TrimSpace(r.Header.Get("X-Share-Id"))
@@ -7,6 +7,7 @@ import (
"testing"
"img_infinite_canvas/internal/domain/design"
authmodule "img_infinite_canvas/internal/modules/auth"
sharingmodule "img_infinite_canvas/internal/modules/sharing"
)
@@ -19,6 +20,25 @@ func (fakeBearerAuthenticator) UserIDFromBearer(_ context.Context, authorization
return "", false
}
type fakeIdentityAuthenticator struct {
metadata authmodule.RequestMetadata
}
func (f *fakeIdentityAuthenticator) UserIDFromBearer(context.Context, string) (string, bool) {
return "", false
}
func (f *fakeIdentityAuthenticator) IdentityFromBearer(ctx context.Context, authorization string) (authmodule.Identity, bool) {
if authorization != "Bearer session-token" {
return authmodule.Identity{}, false
}
f.metadata = authmodule.RequestMetadataFromContext(ctx)
return authmodule.Identity{
UserID: "91cd197b-8255-4172-9981-77391fd38f33",
SessionID: "session-1",
}, true
}
type fakeShareAuthorizer struct {
access sharingmodule.Access
err error
@@ -50,6 +70,33 @@ func TestUserContextMiddlewareUsesAuthorizationHeader(t *testing.T) {
}
}
func TestUserContextMiddlewarePropagatesSessionAndDeviceMetadata(t *testing.T) {
authenticator := &fakeIdentityAuthenticator{}
req := httptest.NewRequest(http.MethodGet, "/api/account/devices", nil)
req.Header.Set("Authorization", "Bearer session-token")
req.Header.Set("User-Agent", "test-browser")
req.Header.Set("Sec-CH-UA-Platform", `"macOS"`)
req.Header.Set("Sec-CH-UA-Mobile", "?0")
rec := httptest.NewRecorder()
UserContextMiddleware(authenticator)(func(w http.ResponseWriter, r *http.Request) {
if got := design.UserIDFromContext(r.Context()); got != "91cd197b-8255-4172-9981-77391fd38f33" {
t.Fatalf("expected authenticated user in context, got %s", got)
}
if got := authmodule.SessionIDFromContext(r.Context()); got != "session-1" {
t.Fatalf("expected session id in context, got %s", got)
}
w.WriteHeader(http.StatusNoContent)
})(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected status %d, got %d", http.StatusNoContent, rec.Code)
}
if authenticator.metadata.UserAgent != "test-browser" || authenticator.metadata.Platform != `"macOS"` || authenticator.metadata.Mobile != "?0" {
t.Fatalf("unexpected request metadata %#v", authenticator.metadata)
}
}
func TestUserContextMiddlewareIgnoresLegacyTokenHeaderAndCookie(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/projects", nil)
req.Header.Set("token", "valid")
@@ -43,6 +43,27 @@ CREATE TABLE IF NOT EXISTS auth_verification_codes (
CREATE INDEX IF NOT EXISTS auth_codes_target_idx ON auth_verification_codes(region, channel, target, purpose, created_at DESC);
CREATE TABLE IF NOT EXISTS auth_device_sessions (
id TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device_type TEXT NOT NULL DEFAULT 'desktop',
system TEXT NOT NULL DEFAULT 'unknown',
browser TEXT NOT NULL DEFAULT 'unknown',
client TEXT NOT NULL DEFAULT 'PC-WEB',
user_agent TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS auth_device_sessions_user_active_idx
ON auth_device_sessions(user_id, last_seen_at DESC)
WHERE revoked_at IS NULL;
CREATE INDEX IF NOT EXISTS auth_device_sessions_expiry_idx
ON auth_device_sessions(expires_at)
WHERE revoked_at IS NULL;
CREATE TABLE IF NOT EXISTS brand_kits (
id TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+3 -1
View File
@@ -1,6 +1,8 @@
package logic
import (
"time"
authmodule "img_infinite_canvas/internal/modules/auth"
"img_infinite_canvas/internal/types"
)
@@ -93,7 +95,7 @@ func toAPIAccountDevices(devices []authmodule.Device) []types.AccountDevice {
System: device.System,
Browser: device.Browser,
Client: device.Client,
LastSeenAt: device.LastSeenAt.UTC().Format("2006-01-02 15:04(UTC)"),
LastSeenAt: device.LastSeenAt.UTC().Format(time.RFC3339),
})
}
return items
@@ -32,5 +32,12 @@ func (l *ListAccountDevicesLogic) ListAccountDevices() (resp *types.AccountDevic
if err != nil {
return nil, err
}
return &types.AccountDeviceListResponse{Devices: toAPIAccountDevices(devices)}, nil
limits := l.svcCtx.AuthService.DeviceLimits()
return &types.AccountDeviceListResponse{
Devices: toAPIAccountDevices(devices),
Limits: types.AccountDeviceLimits{
Desktop: limits.Desktop,
Mobile: limits.Mobile,
},
}, nil
}
+79
View File
@@ -0,0 +1,79 @@
package auth
import (
"strings"
"github.com/mileusna/useragent"
)
const (
unknownDeviceSystem = "unknown"
unknownDeviceBrowser = "unknown"
)
func deviceSessionFromMetadata(metadata RequestMetadata) DeviceSession {
parsed := useragent.Parse(strings.TrimSpace(metadata.UserAgent))
deviceType := "desktop"
client := "PC-WEB"
if clientHintMobile(metadata.Mobile) || parsed.Mobile || parsed.Tablet {
deviceType = "mobile"
client = "MOBILE-WEB"
}
system := cleanDeviceLabel(parsed.OS, unknownDeviceSystem)
if platform := cleanClientHint(metadata.Platform); platform != "" {
system = platform
}
browser := cleanDeviceLabel(parsed.Name, unknownDeviceBrowser)
if parsed.Version != "" && browser != unknownDeviceBrowser {
browser += " " + majorVersion(parsed.Version)
}
return DeviceSession{
DeviceType: deviceType,
System: system,
Browser: browser,
Client: client,
UserAgent: limitMetadata(strings.TrimSpace(metadata.UserAgent), 1024),
}
}
func clientHintMobile(value string) bool {
value = strings.Trim(strings.TrimSpace(value), `"`)
return value == "1" || strings.EqualFold(value, "true") || strings.EqualFold(value, "?1")
}
func cleanClientHint(value string) string {
value = strings.Trim(strings.TrimSpace(value), `"`)
if value == "" || strings.EqualFold(value, "unknown") {
return ""
}
return limitMetadata(value, 80)
}
func cleanDeviceLabel(value string, fallback string) string {
value = strings.TrimSpace(value)
if value == "" || strings.EqualFold(value, "unknown") {
return fallback
}
return limitMetadata(value, 80)
}
func majorVersion(version string) string {
version = strings.TrimSpace(version)
if index := strings.IndexByte(version, '.'); index >= 0 {
version = version[:index]
}
return limitMetadata(version, 16)
}
func limitMetadata(value string, maxRunes int) string {
if maxRunes <= 0 {
return ""
}
runes := []rune(value)
if len(runes) <= maxRunes {
return value
}
return string(runes[:maxRunes])
}
@@ -0,0 +1,62 @@
package auth
import (
"strings"
"testing"
)
func TestDeviceSessionFromMetadata(t *testing.T) {
tests := []struct {
name string
metadata RequestMetadata
deviceType string
system string
browser string
client string
}{
{
name: "unknown desktop fallback",
metadata: RequestMetadata{},
deviceType: "desktop",
system: "unknown",
browser: "unknown",
client: "PC-WEB",
},
{
name: "desktop chrome",
metadata: RequestMetadata{
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0.0.0 Safari/537.36",
Platform: `"Windows"`,
Mobile: "?0",
},
deviceType: "desktop",
system: "Windows",
browser: "Chrome 126",
client: "PC-WEB",
},
{
name: "mobile safari from client hints",
metadata: RequestMetadata{
UserAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1",
Platform: `"iOS"`,
Mobile: "?1",
},
deviceType: "mobile",
system: "iOS",
browser: "Safari 18",
client: "MOBILE-WEB",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
session := deviceSessionFromMetadata(test.metadata)
if session.DeviceType != test.deviceType || session.System != test.system || session.Client != test.client {
t.Fatalf("unexpected device metadata %#v", session)
}
if !strings.HasPrefix(session.Browser, test.browser) {
t.Fatalf("expected browser %q, got %q", test.browser, session.Browser)
}
})
}
}
+173 -5
View File
@@ -2,6 +2,7 @@ package auth
import (
"context"
"sort"
"strings"
"sync"
"time"
@@ -10,15 +11,17 @@ import (
)
type MemoryStore struct {
mu sync.RWMutex
users map[string]User
codes map[string]VerificationCode
mu sync.RWMutex
users map[string]User
codes map[string]VerificationCode
sessions map[string]DeviceSession
}
func NewMemoryStore() *MemoryStore {
return &MemoryStore{
users: make(map[string]User),
codes: make(map[string]VerificationCode),
users: make(map[string]User),
codes: make(map[string]VerificationCode),
sessions: make(map[string]DeviceSession),
}
}
@@ -104,6 +107,171 @@ func (s *MemoryStore) UpdateUserProfile(ctx context.Context, userID string, patc
return user, nil
}
func (s *MemoryStore) CreateDeviceSession(ctx context.Context, session DeviceSession, activeLimit int64) error {
if err := ctx.Err(); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.sessions[session.ID]; !exists {
s.sessions[session.ID] = session
}
s.enforceDeviceSessionLimitLocked(session.UserID, session.DeviceType, session.ID, activeLimit, session.CreatedAt)
return nil
}
func (s *MemoryStore) FindDeviceSession(ctx context.Context, id string) (DeviceSession, error) {
if err := ctx.Err(); err != nil {
return DeviceSession{}, err
}
s.mu.RLock()
defer s.mu.RUnlock()
session, ok := s.sessions[strings.TrimSpace(id)]
if !ok {
return DeviceSession{}, design.ErrNotFound
}
return session, nil
}
func (s *MemoryStore) ListDeviceSessions(ctx context.Context, userID string, now time.Time) ([]DeviceSession, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
userID = normalizeUUID(userID)
s.mu.RLock()
defer s.mu.RUnlock()
sessions := make([]DeviceSession, 0)
for _, session := range s.sessions {
if session.UserID != userID || session.RevokedAt != nil || !session.ExpiresAt.After(now) {
continue
}
sessions = append(sessions, session)
}
return sessions, nil
}
func (s *MemoryStore) TouchDeviceSession(ctx context.Context, session DeviceSession) error {
if err := ctx.Err(); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
stored, ok := s.sessions[session.ID]
if !ok || stored.UserID != session.UserID {
return design.ErrNotFound
}
if stored.RevokedAt != nil || !stored.ExpiresAt.After(session.LastSeenAt) {
return design.ErrNotFound
}
if stored.LastSeenAt.After(session.LastSeenAt) {
return nil
}
stored.DeviceType = session.DeviceType
stored.System = session.System
stored.Browser = session.Browser
stored.Client = session.Client
stored.UserAgent = session.UserAgent
stored.LastSeenAt = session.LastSeenAt
s.sessions[session.ID] = stored
return nil
}
func (s *MemoryStore) EnforceDeviceSessionLimit(
ctx context.Context,
userID string,
deviceType string,
keepSessionID string,
activeLimit int64,
revokedAt time.Time,
) (int64, error) {
if err := ctx.Err(); err != nil {
return 0, err
}
s.mu.Lock()
defer s.mu.Unlock()
return s.enforceDeviceSessionLimitLocked(normalizeUUID(userID), deviceType, keepSessionID, activeLimit, revokedAt), nil
}
func (s *MemoryStore) enforceDeviceSessionLimitLocked(
userID string,
deviceType string,
keepSessionID string,
activeLimit int64,
revokedAt time.Time,
) int64 {
if activeLimit < 1 {
activeLimit = 1
}
active := make([]DeviceSession, 0)
for _, session := range s.sessions {
if session.UserID != userID || session.DeviceType != deviceType || session.RevokedAt != nil || !session.ExpiresAt.After(revokedAt) {
continue
}
active = append(active, session)
}
sort.SliceStable(active, func(i, j int) bool {
iKeep := active[i].ID == keepSessionID
jKeep := active[j].ID == keepSessionID
if iKeep != jKeep {
return iKeep
}
if !active[i].CreatedAt.Equal(active[j].CreatedAt) {
return active[i].CreatedAt.After(active[j].CreatedAt)
}
if !active[i].LastSeenAt.Equal(active[j].LastSeenAt) {
return active[i].LastSeenAt.After(active[j].LastSeenAt)
}
return active[i].ID > active[j].ID
})
var removed int64
for index := int(activeLimit); index < len(active); index++ {
session := active[index]
revoked := revokedAt
session.RevokedAt = &revoked
s.sessions[session.ID] = session
removed++
}
return removed
}
func (s *MemoryStore) RevokeOtherDeviceSessions(ctx context.Context, userID string, currentSessionID string, revokedAt time.Time) (int64, error) {
if err := ctx.Err(); err != nil {
return 0, err
}
userID = normalizeUUID(userID)
s.mu.Lock()
defer s.mu.Unlock()
var removed int64
for id, session := range s.sessions {
if session.UserID != userID || session.ID == currentSessionID || session.RevokedAt != nil || !session.ExpiresAt.After(revokedAt) {
continue
}
revoked := revokedAt
session.RevokedAt = &revoked
s.sessions[id] = session
removed++
}
return removed, nil
}
func (s *MemoryStore) RevokeDeviceSession(ctx context.Context, userID string, sessionID string, revokedAt time.Time) (int64, error) {
if err := ctx.Err(); err != nil {
return 0, err
}
userID = normalizeUUID(userID)
s.mu.Lock()
defer s.mu.Unlock()
session, ok := s.sessions[sessionID]
if !ok || session.UserID != userID || session.RevokedAt != nil || !session.ExpiresAt.After(revokedAt) {
return 0, nil
}
revoked := revokedAt
session.RevokedAt = &revoked
s.sessions[sessionID] = session
return 1, nil
}
func (s *MemoryStore) CreateVerificationCode(ctx context.Context, code VerificationCode) error {
if err := ctx.Err(); err != nil {
return err
@@ -112,6 +112,174 @@ RETURNING id, region, phone_country_code, phone, email, name, avatar_url, passwo
return scanUser(row)
}
func (s *PostgresStore) CreateDeviceSession(ctx context.Context, session DeviceSession, activeLimit int64) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
if err := lockDeviceSessions(ctx, tx, session.UserID, session.DeviceType); err != nil {
return err
}
if _, err := tx.Exec(ctx, `
INSERT INTO auth_device_sessions (
id, user_id, device_type, system, browser, client, user_agent,
created_at, last_seen_at, expires_at, revoked_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (id) DO NOTHING
`, session.ID, toPGUUID(session.UserID), session.DeviceType, session.System, session.Browser, session.Client, session.UserAgent,
session.CreatedAt, session.LastSeenAt, session.ExpiresAt, session.RevokedAt); err != nil {
return err
}
if _, err := enforceDeviceSessionLimitPostgres(ctx, tx, session.UserID, session.DeviceType, session.ID, activeLimit, session.CreatedAt); err != nil {
return err
}
return tx.Commit(ctx)
}
func (s *PostgresStore) FindDeviceSession(ctx context.Context, id string) (DeviceSession, error) {
return scanDeviceSession(s.pool.QueryRow(ctx, `
SELECT id, user_id, device_type, system, browser, client, user_agent, created_at, last_seen_at, expires_at, revoked_at
FROM auth_device_sessions
WHERE id = $1
`, strings.TrimSpace(id)))
}
func (s *PostgresStore) ListDeviceSessions(ctx context.Context, userID string, now time.Time) ([]DeviceSession, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, user_id, device_type, system, browser, client, user_agent, created_at, last_seen_at, expires_at, revoked_at
FROM auth_device_sessions
WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > $2
ORDER BY last_seen_at DESC, created_at DESC, id ASC
`, toPGUUID(userID), now)
if err != nil {
return nil, err
}
defer rows.Close()
sessions := make([]DeviceSession, 0)
for rows.Next() {
session, scanErr := scanDeviceSession(rows)
if scanErr != nil {
return nil, scanErr
}
sessions = append(sessions, session)
}
if err := rows.Err(); err != nil {
return nil, err
}
return sessions, nil
}
func (s *PostgresStore) TouchDeviceSession(ctx context.Context, session DeviceSession) error {
result, err := s.pool.Exec(ctx, `
UPDATE auth_device_sessions
SET device_type = $3,
system = $4,
browser = $5,
client = $6,
user_agent = $7,
last_seen_at = GREATEST(last_seen_at, $8)
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > $8
`, session.ID, toPGUUID(session.UserID), session.DeviceType, session.System, session.Browser, session.Client, session.UserAgent, session.LastSeenAt)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return design.ErrNotFound
}
return nil
}
func (s *PostgresStore) EnforceDeviceSessionLimit(
ctx context.Context,
userID string,
deviceType string,
keepSessionID string,
activeLimit int64,
revokedAt time.Time,
) (int64, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return 0, err
}
defer func() { _ = tx.Rollback(ctx) }()
if err := lockDeviceSessions(ctx, tx, userID, deviceType); err != nil {
return 0, err
}
removed, err := enforceDeviceSessionLimitPostgres(ctx, tx, userID, deviceType, keepSessionID, activeLimit, revokedAt)
if err != nil {
return 0, err
}
if err := tx.Commit(ctx); err != nil {
return 0, err
}
return removed, nil
}
func lockDeviceSessions(ctx context.Context, tx pgx.Tx, userID string, deviceType string) error {
_, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, normalizeUUID(userID)+":"+deviceType)
return err
}
func enforceDeviceSessionLimitPostgres(
ctx context.Context,
tx pgx.Tx,
userID string,
deviceType string,
keepSessionID string,
activeLimit int64,
revokedAt time.Time,
) (int64, error) {
if activeLimit < 1 {
activeLimit = 1
}
result, err := tx.Exec(ctx, `
WITH overflow AS (
SELECT id
FROM auth_device_sessions
WHERE user_id = $1 AND device_type = $2 AND revoked_at IS NULL AND expires_at > $4
ORDER BY CASE WHEN id = $3 THEN 0 ELSE 1 END,
created_at DESC,
last_seen_at DESC,
id DESC
OFFSET $5
)
UPDATE auth_device_sessions
SET revoked_at = $4
WHERE id IN (SELECT id FROM overflow)
`, toPGUUID(userID), deviceType, strings.TrimSpace(keepSessionID), revokedAt, activeLimit)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
func (s *PostgresStore) RevokeOtherDeviceSessions(ctx context.Context, userID string, currentSessionID string, revokedAt time.Time) (int64, error) {
result, err := s.pool.Exec(ctx, `
UPDATE auth_device_sessions
SET revoked_at = $3
WHERE user_id = $1 AND id <> $2 AND revoked_at IS NULL AND expires_at > $3
`, toPGUUID(userID), strings.TrimSpace(currentSessionID), revokedAt)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
func (s *PostgresStore) RevokeDeviceSession(ctx context.Context, userID string, sessionID string, revokedAt time.Time) (int64, error) {
result, err := s.pool.Exec(ctx, `
UPDATE auth_device_sessions
SET revoked_at = $3
WHERE user_id = $1 AND id = $2 AND revoked_at IS NULL AND expires_at > $3
`, toPGUUID(userID), strings.TrimSpace(sessionID), revokedAt)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
func (s *PostgresStore) CreateVerificationCode(ctx context.Context, code VerificationCode) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO auth_verification_codes (id, region, channel, target, purpose, code_hash, attempts, expires_at, created_at)
@@ -179,6 +347,31 @@ func scanUser(row rowScanner) (User, error) {
return user, nil
}
func scanDeviceSession(row rowScanner) (DeviceSession, error) {
var userID pgtype.UUID
var session DeviceSession
if err := row.Scan(
&session.ID,
&userID,
&session.DeviceType,
&session.System,
&session.Browser,
&session.Client,
&session.UserAgent,
&session.CreatedAt,
&session.LastSeenAt,
&session.ExpiresAt,
&session.RevokedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return DeviceSession{}, design.ErrNotFound
}
return DeviceSession{}, err
}
session.UserID = fromPGUUID(userID)
return session, nil
}
func toPGUUID(id string) pgtype.UUID {
parsed, err := uuid.Parse(normalizeUUID(id))
if err != nil {
@@ -0,0 +1,74 @@
package auth
import (
"context"
"os"
"sync"
"testing"
"time"
)
func TestPostgresStoreEnforcesConcurrentDeviceLimit(t *testing.T) {
dataSource := os.Getenv("POSTGRES_TEST_DSN")
if dataSource == "" {
t.Skip("POSTGRES_TEST_DSN is not configured")
}
ctx := context.Background()
store, err := NewPostgresStore(ctx, dataSource)
if err != nil {
t.Fatal(err)
}
defer func() { _ = store.Close() }()
user, err := store.UpsertUser(ctx, User{
ID: newID(),
Region: RegionGlobal,
Email: newID() + "@device-limit.test",
Status: "active",
})
if err != nil {
t.Fatal(err)
}
defer func() {
_, _ = store.pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, toPGUUID(user.ID))
}()
createdAt := time.Date(2026, 7, 11, 13, 0, 0, 0, time.UTC)
const loginCount = 8
const activeLimit = 2
errCh := make(chan error, loginCount)
var wg sync.WaitGroup
for index := 0; index < loginCount; index++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
sessionTime := createdAt.Add(time.Duration(index) * time.Millisecond)
errCh <- store.CreateDeviceSession(ctx, DeviceSession{
ID: newID(),
UserID: user.ID,
DeviceType: "desktop",
System: "macOS",
Browser: "Chrome 149",
Client: "PC-WEB",
CreatedAt: sessionTime,
LastSeenAt: sessionTime,
ExpiresAt: sessionTime.Add(time.Hour),
}, activeLimit)
}(index)
}
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
t.Fatal(err)
}
}
sessions, err := store.ListDeviceSessions(ctx, user.ID, createdAt)
if err != nil {
t.Fatal(err)
}
if len(sessions) != activeLimit {
t.Fatalf("expected %d active desktop sessions after concurrent login, got %d", activeLimit, len(sessions))
}
}
+181 -21
View File
@@ -3,11 +3,14 @@ package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"math/big"
"net/url"
"regexp"
"sort"
"strings"
"time"
@@ -17,6 +20,11 @@ import (
"golang.org/x/crypto/bcrypt"
)
const (
deviceOnlineWindow = 5 * time.Minute
sessionTouchWindow = time.Minute
)
type Config struct {
Region string
RegionMode string
@@ -24,6 +32,7 @@ type Config struct {
TokenTTL time.Duration
RefreshTokenTTL time.Duration
CodeTTL time.Duration
DeviceLimits DeviceLimits
DevReturnCode bool
EmailFromName string
EmailFromEmail string
@@ -78,6 +87,12 @@ func NewServiceWithCodeStoreEmailAndHumanVerifier(store Store, codeStore Verific
if cfg.CodeTTL <= 0 {
cfg.CodeTTL = 10 * time.Minute
}
if cfg.DeviceLimits.Desktop <= 0 {
cfg.DeviceLimits.Desktop = 2
}
if cfg.DeviceLimits.Mobile <= 0 {
cfg.DeviceLimits.Mobile = 1
}
if strings.TrimSpace(cfg.EmailFromName) == "" {
cfg.EmailFromName = loginEmailFromName
}
@@ -123,6 +138,10 @@ func (s *Service) Options() Options {
}, Turnstile: turnstile}
}
func (s *Service) DeviceLimits() DeviceLimits {
return s.cfg.DeviceLimits
}
func (s *Service) RequestChinaSMSCode(ctx context.Context, phone string, countryCode string, purpose string) (CodeResult, error) {
if err := s.requireRegion(RegionChina); err != nil {
return CodeResult{}, err
@@ -371,11 +390,39 @@ func (s *Service) LoginGlobalGoogle(ctx context.Context, idToken string) (Sessio
}
func (s *Service) UserIDFromBearer(ctx context.Context, authorization string) (string, bool) {
identity, ok := s.IdentityFromBearer(ctx, authorization)
return identity.UserID, ok
}
func (s *Service) IdentityFromBearer(ctx context.Context, authorization string) (Identity, bool) {
token, ok := bearerToken(authorization)
if !ok {
return "", false
return Identity{}, false
}
return parseToken(ctx, token, s.cfg.TokenSecret, s.now())
now := s.now().UTC()
payload, ok := parseAccessTokenPayload(ctx, token, s.cfg.TokenSecret, now)
if !ok {
return Identity{}, false
}
userID := normalizeUUID(payload.Subject)
sessionID := strings.TrimSpace(payload.SessionID)
if sessionID == "" {
sessionID = legacyDeviceSessionID(token)
if err := s.ensureLegacyDeviceSession(ctx, sessionID, userID, payload, now); err != nil {
return Identity{}, false
}
}
session, err := s.store.FindDeviceSession(ctx, sessionID)
if err != nil || session.UserID != userID || session.RevokedAt != nil || !session.ExpiresAt.After(now) {
return Identity{}, false
}
if !session.LastSeenAt.After(now.Add(-sessionTouchWindow)) {
if err := s.touchDeviceSession(ctx, session, now); err != nil {
return Identity{}, false
}
}
return Identity{UserID: userID, SessionID: sessionID}, true
}
func (s *Service) GetProfile(ctx context.Context, userID string) (User, error) {
@@ -401,35 +448,79 @@ func (s *Service) UpdateProfile(ctx context.Context, userID string, patch Profil
}
func (s *Service) ListDevices(ctx context.Context, userID string) ([]Device, error) {
if _, err := s.store.FindUserByID(ctx, normalizeUUID(userID)); err != nil {
userID = normalizeUUID(userID)
if _, err := s.store.FindUserByID(ctx, userID); err != nil {
return nil, err
}
return []Device{
{
ID: "current",
Online: true,
Current: true,
DeviceType: "desktop",
System: "unknown",
Browser: "unknown",
Client: "PC-WEB",
LastSeenAt: s.now().UTC(),
},
}, nil
now := s.now().UTC()
currentSessionID := SessionIDFromContext(ctx)
for _, policy := range []struct {
deviceType string
limit int64
}{
{deviceType: "desktop", limit: s.cfg.DeviceLimits.Desktop},
{deviceType: "mobile", limit: s.cfg.DeviceLimits.Mobile},
} {
if _, err := s.store.EnforceDeviceSessionLimit(ctx, userID, policy.deviceType, currentSessionID, policy.limit, now); err != nil {
return nil, err
}
}
sessions, err := s.store.ListDeviceSessions(ctx, userID, now)
if err != nil {
return nil, err
}
devices := make([]Device, 0, len(sessions))
for _, session := range sessions {
current := session.ID == currentSessionID
devices = append(devices, Device{
ID: session.ID,
Online: current || session.LastSeenAt.After(now.Add(-deviceOnlineWindow)),
Current: current,
DeviceType: session.DeviceType,
System: session.System,
Browser: session.Browser,
Client: session.Client,
LastSeenAt: session.LastSeenAt,
})
}
if currentSessionID == "" && len(devices) == 1 {
devices[0].Current = true
devices[0].Online = true
}
sort.SliceStable(devices, func(i, j int) bool {
if devices[i].Current != devices[j].Current {
return devices[i].Current
}
if !devices[i].LastSeenAt.Equal(devices[j].LastSeenAt) {
return devices[i].LastSeenAt.After(devices[j].LastSeenAt)
}
return devices[i].ID < devices[j].ID
})
return devices, nil
}
func (s *Service) RemoveOtherDevices(ctx context.Context, userID string) (int64, error) {
if _, err := s.store.FindUserByID(ctx, normalizeUUID(userID)); err != nil {
userID = normalizeUUID(userID)
if _, err := s.store.FindUserByID(ctx, userID); err != nil {
return 0, err
}
return 0, nil
currentSessionID := SessionIDFromContext(ctx)
if err := s.requireActiveDeviceSession(ctx, userID, currentSessionID); err != nil {
return 0, err
}
return s.store.RevokeOtherDeviceSessions(ctx, userID, currentSessionID, s.now().UTC())
}
func (s *Service) Logout(ctx context.Context, userID string) (int64, error) {
if _, err := s.store.FindUserByID(ctx, normalizeUUID(userID)); err != nil {
userID = normalizeUUID(userID)
if _, err := s.store.FindUserByID(ctx, userID); err != nil {
return 0, err
}
return 0, nil
currentSessionID := SessionIDFromContext(ctx)
if err := s.requireActiveDeviceSession(ctx, userID, currentSessionID); err != nil {
return 0, err
}
return s.store.RevokeDeviceSession(ctx, userID, currentSessionID, s.now().UTC())
}
func (s *Service) Close() error {
@@ -527,14 +618,24 @@ func (s *Service) upsertAndSign(ctx context.Context, user User) (Session, error)
return Session{}, err
}
now := s.now()
token, expiresIn, err := signToken(saved.ID, s.cfg.TokenSecret, s.cfg.TokenTTL, now)
sessionID := newID()
token, expiresIn, err := signSessionToken(saved.ID, sessionID, s.cfg.TokenSecret, s.cfg.TokenTTL, now)
if err != nil {
return Session{}, err
}
refreshToken, refreshExpiresIn, err := signRefreshToken(saved.ID, s.cfg.TokenSecret, s.cfg.RefreshTokenTTL, now)
refreshToken, refreshExpiresIn, err := signSessionRefreshToken(saved.ID, sessionID, s.cfg.TokenSecret, s.cfg.RefreshTokenTTL, now)
if err != nil {
return Session{}, err
}
deviceSession := deviceSessionFromMetadata(RequestMetadataFromContext(ctx))
deviceSession.ID = sessionID
deviceSession.UserID = saved.ID
deviceSession.CreatedAt = now.UTC()
deviceSession.LastSeenAt = now.UTC()
deviceSession.ExpiresAt = now.Add(time.Duration(expiresIn) * time.Second).UTC()
if err := s.store.CreateDeviceSession(ctx, deviceSession, s.deviceSessionLimit(deviceSession.DeviceType)); err != nil {
return Session{}, err
}
return Session{
Status: SessionStatusAuthenticated,
User: saved,
@@ -545,6 +646,65 @@ func (s *Service) upsertAndSign(ctx context.Context, user User) (Session, error)
}, nil
}
func (s *Service) ensureLegacyDeviceSession(ctx context.Context, sessionID string, userID string, payload tokenPayload, now time.Time) error {
if _, err := s.store.FindDeviceSession(ctx, sessionID); err == nil {
return nil
} else if !errors.Is(err, design.ErrNotFound) {
return err
}
if _, err := s.store.FindUserByID(ctx, userID); err != nil {
return err
}
createdAt := time.Unix(payload.Issued, 0).UTC()
if payload.Issued <= 0 || createdAt.After(now) {
createdAt = now
}
session := deviceSessionFromMetadata(RequestMetadataFromContext(ctx))
session.ID = sessionID
session.UserID = userID
session.CreatedAt = createdAt
session.LastSeenAt = now
session.ExpiresAt = time.Unix(payload.Expires, 0).UTC()
return s.store.CreateDeviceSession(ctx, session, s.deviceSessionLimit(session.DeviceType))
}
func (s *Service) touchDeviceSession(ctx context.Context, session DeviceSession, now time.Time) error {
metadata := RequestMetadataFromContext(ctx)
if metadata.UserAgent != "" || metadata.Platform != "" || metadata.Mobile != "" {
parsed := deviceSessionFromMetadata(metadata)
session.DeviceType = parsed.DeviceType
session.System = parsed.System
session.Browser = parsed.Browser
session.Client = parsed.Client
session.UserAgent = parsed.UserAgent
}
session.LastSeenAt = now
return s.store.TouchDeviceSession(ctx, session)
}
func (s *Service) requireActiveDeviceSession(ctx context.Context, userID string, sessionID string) error {
if strings.TrimSpace(sessionID) == "" {
return fmt.Errorf("%w: current session is required", ErrInvalidCredentials)
}
session, err := s.store.FindDeviceSession(ctx, sessionID)
if err != nil || session.UserID != userID || session.RevokedAt != nil || !session.ExpiresAt.After(s.now().UTC()) {
return fmt.Errorf("%w: current session is invalid", ErrInvalidCredentials)
}
return nil
}
func (s *Service) deviceSessionLimit(deviceType string) int64 {
if deviceType == "mobile" {
return s.cfg.DeviceLimits.Mobile
}
return s.cfg.DeviceLimits.Desktop
}
func legacyDeviceSessionID(token string) string {
digest := sha256.Sum256([]byte(strings.TrimSpace(token)))
return "legacy:" + base64.RawURLEncoding.EncodeToString(digest[:])
}
func (s *Service) requireRegion(region string) error {
if normalizeRegion(s.cfg.Region) != region {
return fmt.Errorf("%w: current region is %s", ErrInvalidRegion, s.cfg.Region)
+242 -10
View File
@@ -3,6 +3,7 @@ package auth
import (
"context"
"errors"
"strings"
"testing"
"time"
@@ -95,6 +96,127 @@ func TestGlobalEmailCodeLogin(t *testing.T) {
}
}
func TestDeviceLimitsUseConfiguredValuesAndSafeDefaults(t *testing.T) {
configured := NewService(NewMemoryStore(), Config{
Region: RegionGlobal,
TokenSecret: "test-secret",
DeviceLimits: DeviceLimits{Desktop: 5, Mobile: 3},
})
if limits := configured.DeviceLimits(); limits.Desktop != 5 || limits.Mobile != 3 {
t.Fatalf("unexpected configured limits %#v", limits)
}
defaults := NewService(NewMemoryStore(), Config{Region: RegionGlobal, TokenSecret: "test-secret"})
if limits := defaults.DeviceLimits(); limits.Desktop != 2 || limits.Mobile != 1 {
t.Fatalf("unexpected default limits %#v", limits)
}
}
func TestDesktopDeviceLimitRevokesOldestSession(t *testing.T) {
service := NewService(NewMemoryStore(), Config{
Region: RegionGlobal,
TokenSecret: "test-secret",
TokenTTL: time.Hour,
CodeTTL: time.Minute,
DevReturnCode: true,
DeviceLimits: DeviceLimits{Desktop: 1, Mobile: 1},
})
now := time.Date(2026, 7, 11, 11, 0, 0, 0, time.UTC)
service.now = func() time.Time { return now }
desktopCtx := ContextWithRequestMetadata(context.Background(), RequestMetadata{
UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/149.0.0.0 Safari/537.36",
Platform: `"macOS"`,
Mobile: "?0",
})
login := func() Session {
t.Helper()
code, err := service.RequestGlobalEmailCode(desktopCtx, "limit@example.com", "")
if err != nil {
t.Fatal(err)
}
session, err := service.LoginGlobalEmailCode(desktopCtx, "limit@example.com", code.DevCode)
if err != nil {
t.Fatal(err)
}
return session
}
first := login()
firstIdentity, ok := service.IdentityFromBearer(desktopCtx, "Bearer "+first.AccessToken)
if !ok {
t.Fatal("expected first desktop session to authenticate")
}
now = now.Add(time.Second)
second := login()
secondIdentity, ok := service.IdentityFromBearer(desktopCtx, "Bearer "+second.AccessToken)
if !ok {
t.Fatal("expected newest desktop session to authenticate")
}
if _, ok := service.IdentityFromBearer(desktopCtx, "Bearer "+first.AccessToken); ok {
t.Fatal("expected oldest desktop session to be revoked at the configured limit")
}
secondCtx := ContextWithSessionID(desktopCtx, secondIdentity.SessionID)
devices, err := service.ListDevices(secondCtx, second.User.ID)
if err != nil {
t.Fatal(err)
}
if len(devices) != 1 || devices[0].ID != secondIdentity.SessionID || !devices[0].Current {
t.Fatalf("expected only the newest desktop session, got %#v (first=%s)", devices, firstIdentity.SessionID)
}
}
func TestListDevicesReconcilesSessionsCreatedBeforeLimitEnforcement(t *testing.T) {
service := NewService(NewMemoryStore(), Config{
Region: RegionGlobal,
TokenSecret: "test-secret",
TokenTTL: time.Hour,
CodeTTL: time.Minute,
DevReturnCode: true,
DeviceLimits: DeviceLimits{Desktop: 2, Mobile: 1},
})
now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)
service.now = func() time.Time { return now }
desktopCtx := ContextWithRequestMetadata(context.Background(), RequestMetadata{
UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/149.0.0.0 Safari/537.36",
Platform: `"macOS"`,
Mobile: "?0",
})
login := func() Session {
t.Helper()
code, err := service.RequestGlobalEmailCode(desktopCtx, "reconcile@example.com", "")
if err != nil {
t.Fatal(err)
}
session, err := service.LoginGlobalEmailCode(desktopCtx, "reconcile@example.com", code.DevCode)
if err != nil {
t.Fatal(err)
}
return session
}
first := login()
now = now.Add(time.Second)
second := login()
secondIdentity, ok := service.IdentityFromBearer(desktopCtx, "Bearer "+second.AccessToken)
if !ok {
t.Fatal("expected newest session to authenticate")
}
service.cfg.DeviceLimits.Desktop = 1
secondCtx := ContextWithSessionID(desktopCtx, secondIdentity.SessionID)
devices, err := service.ListDevices(secondCtx, second.User.ID)
if err != nil {
t.Fatal(err)
}
if len(devices) != 1 || devices[0].ID != secondIdentity.SessionID {
t.Fatalf("expected list reconciliation to preserve only the current session, got %#v", devices)
}
if _, ok := service.IdentityFromBearer(desktopCtx, "Bearer "+first.AccessToken); ok {
t.Fatal("expected pre-enforcement excess session to be revoked")
}
}
func TestAccountProfileUpdatesPersistInStore(t *testing.T) {
ctx := context.Background()
service := NewService(NewMemoryStore(), Config{
@@ -130,36 +252,146 @@ func TestAccountProfileUpdatesPersistInStore(t *testing.T) {
}
}
func TestAccountDevicesExposeCurrentLocalDeviceOnly(t *testing.T) {
ctx := context.Background()
service := NewService(NewMemoryStore(), Config{
func TestAccountDevicesTrackAndRevokeOtherSessions(t *testing.T) {
store := NewMemoryStore()
service := NewService(store, Config{
Region: RegionGlobal,
TokenSecret: "test-secret",
TokenTTL: time.Hour,
CodeTTL: time.Minute,
DevReturnCode: true,
})
now := time.Date(2026, 7, 11, 9, 0, 0, 0, time.UTC)
service.now = func() time.Time { return now }
code, err := service.RequestGlobalEmailCode(ctx, "user@example.com", "")
desktopMetadata := RequestMetadata{
UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
Platform: `"macOS"`,
Mobile: "?0",
}
desktopLoginCtx := ContextWithRequestMetadata(context.Background(), desktopMetadata)
code, err := service.RequestGlobalEmailCode(desktopLoginCtx, "user@example.com", "")
if err != nil {
t.Fatal(err)
}
session, err := service.LoginGlobalEmailCode(ctx, "user@example.com", code.DevCode)
desktopSession, err := service.LoginGlobalEmailCode(desktopLoginCtx, "user@example.com", code.DevCode)
if err != nil {
t.Fatal(err)
}
devices, err := service.ListDevices(ctx, session.User.ID)
desktopIdentity, ok := service.IdentityFromBearer(desktopLoginCtx, "Bearer "+desktopSession.AccessToken)
if !ok {
t.Fatal("expected desktop session to authenticate")
}
now = now.Add(2 * time.Minute)
mobileMetadata := RequestMetadata{
UserAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1",
Platform: `"iOS"`,
Mobile: "?1",
}
mobileLoginCtx := ContextWithRequestMetadata(context.Background(), mobileMetadata)
code, err = service.RequestGlobalEmailCode(mobileLoginCtx, "user@example.com", "")
if err != nil {
t.Fatal(err)
}
mobileSession, err := service.LoginGlobalEmailCode(mobileLoginCtx, "user@example.com", code.DevCode)
if err != nil {
t.Fatal(err)
}
mobileIdentity, ok := service.IdentityFromBearer(mobileLoginCtx, "Bearer "+mobileSession.AccessToken)
if !ok {
t.Fatal("expected mobile session to authenticate")
}
mobileCtx := ContextWithSessionID(mobileLoginCtx, mobileIdentity.SessionID)
devices, err := service.ListDevices(mobileCtx, mobileSession.User.ID)
if err != nil {
t.Fatal(err)
}
if len(devices) != 2 {
t.Fatalf("expected two devices, got %#v", devices)
}
if !devices[0].Current || devices[0].ID != mobileIdentity.SessionID || devices[0].DeviceType != "mobile" || devices[0].System != "iOS" {
t.Fatalf("expected current mobile device first, got %#v", devices[0])
}
if devices[1].Current || devices[1].ID != desktopIdentity.SessionID || devices[1].DeviceType != "desktop" || devices[1].System != "macOS" {
t.Fatalf("expected secondary desktop device, got %#v", devices[1])
}
removed, err := service.RemoveOtherDevices(mobileCtx, mobileSession.User.ID)
if err != nil {
t.Fatal(err)
}
if removed != 1 {
t.Fatalf("expected one removable device, got %d", removed)
}
if _, ok := service.IdentityFromBearer(desktopLoginCtx, "Bearer "+desktopSession.AccessToken); ok {
t.Fatal("expected removed desktop session to be rejected")
}
if _, ok := service.IdentityFromBearer(mobileLoginCtx, "Bearer "+mobileSession.AccessToken); !ok {
t.Fatal("expected current mobile session to remain valid")
}
devices, err = service.ListDevices(mobileCtx, mobileSession.User.ID)
if err != nil {
t.Fatal(err)
}
if len(devices) != 1 || !devices[0].Current {
t.Fatalf("expected one current device, got %#v", devices)
t.Fatalf("expected only the current device after removal, got %#v", devices)
}
removed, err := service.RemoveOtherDevices(ctx, session.User.ID)
removed, err = service.Logout(mobileCtx, mobileSession.User.ID)
if err != nil {
t.Fatal(err)
}
if removed != 0 {
t.Fatalf("expected no removable devices, got %d", removed)
if removed != 1 {
t.Fatalf("expected logout to revoke one current session, got %d", removed)
}
if _, ok := service.IdentityFromBearer(mobileLoginCtx, "Bearer "+mobileSession.AccessToken); ok {
t.Fatal("expected logged-out session to be rejected")
}
}
func TestLegacyBearerMigratesToRevocableDeviceSession(t *testing.T) {
store := NewMemoryStore()
service := NewService(store, Config{Region: RegionGlobal, TokenSecret: "test-secret", TokenTTL: time.Hour})
now := time.Date(2026, 7, 11, 10, 0, 0, 0, time.UTC)
service.now = func() time.Time { return now }
user, err := store.UpsertUser(context.Background(), User{ID: newID(), Region: RegionGlobal, Email: "legacy@example.com", Status: "active"})
if err != nil {
t.Fatal(err)
}
legacyToken, _, err := signToken(user.ID, "test-secret", time.Hour, now)
if err != nil {
t.Fatal(err)
}
ctx := ContextWithRequestMetadata(context.Background(), RequestMetadata{
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0.0.0 Safari/537.36",
})
identity, ok := service.IdentityFromBearer(ctx, "Bearer "+legacyToken)
if !ok || identity.UserID != user.ID || !strings.HasPrefix(identity.SessionID, "legacy:") {
t.Fatalf("expected migrated legacy identity, got %#v / %v", identity, ok)
}
authenticatedCtx := ContextWithSessionID(ctx, identity.SessionID)
devices, err := service.ListDevices(authenticatedCtx, user.ID)
if err != nil {
t.Fatal(err)
}
if len(devices) != 1 || !devices[0].Current || devices[0].System != "Windows" {
t.Fatalf("unexpected migrated device %#v", devices)
}
if _, err := service.Logout(authenticatedCtx, user.ID); err != nil {
t.Fatal(err)
}
if _, ok := service.IdentityFromBearer(ctx, "Bearer "+legacyToken); ok {
t.Fatal("expected revoked legacy token to stay revoked")
}
nonUserToken, _, err := signToken(newID(), "test-secret", time.Hour, now)
if err != nil {
t.Fatal(err)
}
if _, ok := service.IdentityFromBearer(ctx, "Bearer "+nonUserToken); ok {
t.Fatal("expected signed token for a missing user to be rejected")
}
}
@@ -0,0 +1,24 @@
package auth
import "context"
type requestMetadataContextKey struct{}
type sessionIDContextKey struct{}
func ContextWithRequestMetadata(ctx context.Context, metadata RequestMetadata) context.Context {
return context.WithValue(ctx, requestMetadataContextKey{}, metadata)
}
func RequestMetadataFromContext(ctx context.Context) RequestMetadata {
metadata, _ := ctx.Value(requestMetadataContextKey{}).(RequestMetadata)
return metadata
}
func ContextWithSessionID(ctx context.Context, sessionID string) context.Context {
return context.WithValue(ctx, sessionIDContextKey{}, sessionID)
}
func SessionIDFromContext(ctx context.Context) string {
sessionID, _ := ctx.Value(sessionIDContextKey{}).(string)
return sessionID
}
+39 -37
View File
@@ -13,67 +13,69 @@ import (
)
type tokenPayload struct {
Subject string `json:"sub"`
Type string `json:"typ,omitempty"`
OpenID string `json:"openid,omitempty"`
UnionID string `json:"unionid,omitempty"`
Issued int64 `json:"iat"`
Expires int64 `json:"exp"`
Subject string `json:"sub"`
Type string `json:"typ,omitempty"`
SessionID string `json:"sid,omitempty"`
OpenID string `json:"openid,omitempty"`
UnionID string `json:"unionid,omitempty"`
Issued int64 `json:"iat"`
Expires int64 `json:"exp"`
}
func signToken(userID string, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
if ttl <= 0 {
ttl = 7 * 24 * time.Hour
}
return signTypedToken(userID, "access", secret, ttl, now)
return signTypedToken(userID, "access", "", secret, ttl, now)
}
func signRefreshToken(userID string, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
if ttl <= 0 {
ttl = 30 * 24 * time.Hour
}
return signTypedToken(userID, "refresh", secret, ttl, now)
return signTypedToken(userID, "refresh", "", secret, ttl, now)
}
func signTypedToken(userID string, tokenType string, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
func signSessionToken(userID string, sessionID string, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
if ttl <= 0 {
ttl = 7 * 24 * time.Hour
}
return signTypedToken(userID, "access", sessionID, secret, ttl, now)
}
func signSessionRefreshToken(userID string, sessionID string, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
if ttl <= 0 {
ttl = 30 * 24 * time.Hour
}
return signTypedToken(userID, "refresh", sessionID, secret, ttl, now)
}
func signTypedToken(userID string, tokenType string, sessionID string, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
payload := tokenPayload{
Subject: userID,
Type: tokenType,
Issued: now.Unix(),
Expires: now.Add(ttl).Unix(),
Subject: userID,
Type: tokenType,
SessionID: sessionID,
Issued: now.Unix(),
Expires: now.Add(ttl).Unix(),
}
token, err := signPayload(payload, secret)
return token, int64(ttl.Seconds()), err
}
func parseToken(ctx context.Context, token string, secret string, now time.Time) (string, bool) {
if err := ctx.Err(); err != nil {
return "", false
}
parts := strings.Split(strings.TrimSpace(token), ".")
if len(parts) != 2 {
return "", false
}
expected := signBytes([]byte(parts[0]), secret)
actual, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil || !hmac.Equal(expected, actual) {
return "", false
}
data, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return "", false
}
var payload tokenPayload
if err := json.Unmarshal(data, &payload); err != nil {
return "", false
}
if payload.Subject == "" || payload.Expires <= now.Unix() {
return "", false
payload, ok := parseAccessTokenPayload(ctx, token, secret, now)
return payload.Subject, ok
}
func parseAccessTokenPayload(ctx context.Context, token string, secret string, now time.Time) (tokenPayload, bool) {
payload, ok := parsePayload(ctx, token, secret)
if !ok || payload.Subject == "" || payload.Expires <= now.Unix() {
return tokenPayload{}, false
}
if payload.Type != "" && payload.Type != "access" {
return "", false
return tokenPayload{}, false
}
return payload.Subject, true
return payload, true
}
func signWechatBindingToken(identity WechatIdentity, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
+37
View File
@@ -79,6 +79,36 @@ type Device struct {
LastSeenAt time.Time
}
type DeviceSession struct {
ID string
UserID string
DeviceType string
System string
Browser string
Client string
UserAgent string
CreatedAt time.Time
LastSeenAt time.Time
ExpiresAt time.Time
RevokedAt *time.Time
}
type RequestMetadata struct {
UserAgent string
Platform string
Mobile string
}
type Identity struct {
UserID string
SessionID string
}
type DeviceLimits struct {
Desktop int64
Mobile int64
}
type Session struct {
Status string
User User
@@ -146,6 +176,13 @@ type Store interface {
FindUserByWechat(ctx context.Context, openID string, unionID string) (User, error)
UpsertUser(ctx context.Context, user User) (User, error)
UpdateUserProfile(ctx context.Context, userID string, patch ProfilePatch) (User, error)
CreateDeviceSession(ctx context.Context, session DeviceSession, activeLimit int64) error
FindDeviceSession(ctx context.Context, id string) (DeviceSession, error)
ListDeviceSessions(ctx context.Context, userID string, now time.Time) ([]DeviceSession, error)
TouchDeviceSession(ctx context.Context, session DeviceSession) error
EnforceDeviceSessionLimit(ctx context.Context, userID string, deviceType string, keepSessionID string, activeLimit int64, revokedAt time.Time) (int64, error)
RevokeOtherDeviceSessions(ctx context.Context, userID string, currentSessionID string, revokedAt time.Time) (int64, error)
RevokeDeviceSession(ctx context.Context, userID string, sessionID string, revokedAt time.Time) (int64, error)
VerificationCodeStore
Close() error
}
+10 -6
View File
@@ -234,12 +234,16 @@ func newAuthService(c config.Config, cacheStore cacheinfra.Store) *authmodule.Se
codeStore := authmodule.NewCacheCodeStore(cacheStore, codeTTL)
humanVerifier := newAuthHumanVerifier(c)
return authmodule.NewServiceWithCodeStoreEmailAndHumanVerifier(store, codeStore, newAuthEmailSender(c), humanVerifier, authmodule.Config{
Region: opts.Region,
RegionMode: opts.RegionMode,
TokenSecret: opts.TokenSecret,
TokenTTL: time.Duration(opts.TokenTTLSeconds) * time.Second,
RefreshTokenTTL: time.Duration(opts.RefreshTTLSeconds) * time.Second,
CodeTTL: time.Duration(opts.CodeTTLSeconds) * time.Second,
Region: opts.Region,
RegionMode: opts.RegionMode,
TokenSecret: opts.TokenSecret,
TokenTTL: time.Duration(opts.TokenTTLSeconds) * time.Second,
RefreshTokenTTL: time.Duration(opts.RefreshTTLSeconds) * time.Second,
CodeTTL: time.Duration(opts.CodeTTLSeconds) * time.Second,
DeviceLimits: authmodule.DeviceLimits{
Desktop: opts.DeviceLimits.Desktop,
Mobile: opts.DeviceLimits.Mobile,
},
DevReturnCode: opts.DevReturnCode,
EmailFromName: opts.Email.FromName,
EmailFromEmail: opts.Email.FromEmail,
+7 -1
View File
@@ -14,8 +14,14 @@ type AccountDevice struct {
LastSeenAt string `json:"lastSeenAt"`
}
type AccountDeviceLimits struct {
Desktop int64 `json:"desktop"`
Mobile int64 `json:"mobile"`
}
type AccountDeviceListResponse struct {
Devices []AccountDevice `json:"devices"`
Devices []AccountDevice `json:"devices"`
Limits AccountDeviceLimits `json:"limits"`
}
type AccountDeviceMutationResponse struct {