diff --git a/deploy/config/api.docker.yaml b/deploy/config/api.docker.yaml index 8466b8a..b27555f 100644 --- a/deploy/config/api.docker.yaml +++ b/deploy/config/api.docker.yaml @@ -42,6 +42,9 @@ Auth: RefreshTTLSeconds: 2592000 CodeTTLSeconds: 600 DevReturnCode: true + DeviceLimits: + Desktop: 2 + Mobile: 1 Email: Driver: none # none|smtp Host: "" diff --git a/frontend/src/domain/auth.ts b/frontend/src/domain/auth.ts index 2a40586..8aefbe7 100644 --- a/frontend/src/domain/auth.ts +++ b/frontend/src/domain/auth.ts @@ -63,3 +63,8 @@ export type AccountDevice = { client: string; lastSeenAt: string; }; + +export type AccountDeviceLimits = { + desktop: number; + mobile: number; +}; diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json index 4e010a9..261e09d 100644 --- a/frontend/src/i18n/locales/en-US.json +++ b/frontend/src/i18n/locales/en-US.json @@ -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", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index a840fcf..24c62b8 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -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": "退出登录", diff --git a/frontend/src/infrastructure/authGateway.ts b/frontend/src/infrastructure/authGateway.ts index c9f8924..ec55766 100644 --- a/frontend/src/infrastructure/authGateway.ts +++ b/frontend/src/infrastructure/authGateway.ts @@ -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() { diff --git a/frontend/src/ui/components/AccountManagementDialog/index.css b/frontend/src/ui/components/AccountManagementDialog/index.css index c74feb9..f27ceca 100644 --- a/frontend/src/ui/components/AccountManagementDialog/index.css +++ b/frontend/src/ui/components/AccountManagementDialog/index.css @@ -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; + } +} diff --git a/frontend/src/ui/components/AccountManagementDialog/index.tsx b/frontend/src/ui/components/AccountManagementDialog/index.tsx index e895e89..d0ce07b 100644 --- a/frontend/src/ui/components/AccountManagementDialog/index.tsx +++ b/frontend/src/ui/components/AccountManagementDialog/index.tsx @@ -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) => Promise; }; +type DeviceLoadState = "loading" | "ready" | "error"; + export function AccountManagementDialog({ open, user, onClose, onLogout, onUserUpdate }: AccountManagementDialogProps) { - const { t } = useI18n(); + const { t, locale } = useI18n(); const avatarInputRef = useRef(null); + const deviceRequestRef = useRef(0); const scrollRevealTimerRef = useRef | null>(null); const [confirmDeviceRemoval, setConfirmDeviceRemoval] = useState(false); const [devices, setDevices] = useState([]); + const [deviceLimits, setDeviceLimits] = useState(null); + const [deviceLoadState, setDeviceLoadState] = useState("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(); }} >
- @@ -269,49 +310,78 @@ export function AccountManagementDialog({ open, user, onClose, onLogout, onUserU
{t("removeAllDevices")} -

{t("removeAllDevicesDescription")}

+

+ {deviceLimits + ? t("removeAllDevicesDescription", { desktop: deviceLimits.desktop, mobile: deviceLimits.mobile }) + : t(deviceLoadState === "loading" ? "deviceLimitsLoading" : "deviceLimitsUnavailable")} +

-
- )} - /> - { - const info = deviceInfoText(device, t); - return ( - - {info} - - ); - })} - /> - - device.current ? ( - - ) : ( - - ) + )} - /> + {deviceLoadState === "ready" && devices.length === 0 && ( + } message={t("noActiveDevices")}> + + + )} + {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 ( +
+
+
+
+
+
+ {device.current && {t("currentDevice")}} +
+
+ ); + })} +
@@ -334,16 +404,23 @@ export function AccountManagementDialog({ open, user, onClose, onLogout, onUserU {confirmDeviceRemoval && ( -
+
{ + if (!deviceRemoving && event.target === event.currentTarget) setConfirmDeviceRemoval(false); + }} + >

{t("removeAllDevicesConfirmTitle")}

-

{t("removeAllDevicesConfirmText")}

+

{t("removeAllDevicesConfirmText", { count: removableDeviceCount })}

- -
@@ -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) { + 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, + 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 ( -
-
- {title} -
- {cells.map((cell, index) => ( -
- {cell} + <> + {[0, 1].map((index) => ( + ))} + + ); +} + +function DeviceTableState({ icon, message, children }: { icon: ReactNode; message: string; children: ReactNode }) { + return ( +
+
+ {icon} + {message} + {children} +
); } diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index e303279..ffbf35d 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/app/layout.tsx","./src/app/page.tsx","./src/app/root.tsx","./src/app/[...path]/page.tsx","./src/app/api/[...path]/route.ts","./src/app/project/[id]/page.tsx","./src/app/projects/page.tsx","./src/app/projects/[id]/page.tsx","./src/application/route.ts","./src/components/ui/button.tsx","./src/components/ui/context-menu.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/popover.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/domain/auth.ts","./src/domain/brandkit.ts","./src/domain/design.ts","./src/i18n/i18n.tsx","./src/infrastructure/authgateway.ts","./src/infrastructure/brandkitrepository.ts","./src/infrastructure/designgateway.ts","./src/infrastructure/imageuploadencoding.ts","./src/infrastructure/usermessages.ts","./src/infrastructure/designgateway/agents.ts","./src/infrastructure/designgateway/assets.ts","./src/infrastructure/designgateway/canvas.ts","./src/infrastructure/designgateway/canvassnapshot.ts","./src/infrastructure/designgateway/clientids.ts","./src/infrastructure/designgateway/events.ts","./src/infrastructure/designgateway/generation.ts","./src/infrastructure/designgateway/projects.ts","./src/infrastructure/designgateway/request.ts","./src/infrastructure/designgateway/responses.ts","./src/infrastructure/designgateway/sharecontext.ts","./src/infrastructure/designgateway/sharing.ts","./src/infrastructure/designgateway/types.ts","./src/ui/app.tsx","./src/ui/auth/authprovider/index.tsx","./src/ui/auth/logindialog/brandlogo.tsx","./src/ui/auth/logindialog/otpinput.tsx","./src/ui/auth/logindialog/turnstilewidget.tsx","./src/ui/auth/logindialog/index.tsx","./src/ui/components/brandmark.tsx","./src/ui/components/accountmanagementdialog/index.tsx","./src/ui/components/artboardpreview/index.tsx","./src/ui/components/brandkitselector/index.tsx","./src/ui/components/canvasagentcomposer/index.tsx","./src/ui/components/canvasagenttimeline/index.tsx","./src/ui/components/canvasannotationpreview/index.tsx","./src/ui/components/canvasannotations/index.tsx","./src/ui/components/canvasminimap/index.tsx","./src/ui/components/canvasnodecontextmenu/index.tsx","./src/ui/components/canvaspanels/index.tsx","./src/ui/components/canvastoast/index.tsx","./src/ui/components/canvasworkoverlay/index.tsx","./src/ui/components/canvaszoommenu/index.tsx","./src/ui/components/deleteprojectdialog/index.tsx","./src/ui/components/imagegeneratorfloatingcomposer/index.tsx","./src/ui/components/imagenodeactions/index.tsx","./src/ui/components/imagetexteditpanel/index.tsx","./src/ui/components/layermergeskeleton/index.tsx","./src/ui/components/markdowncontent/index.tsx","./src/ui/components/multianglepanel/index.tsx","./src/ui/components/multiselectionoverlay/index.tsx","./src/ui/components/projectcard/index.tsx","./src/ui/components/projectthumb/index.tsx","./src/ui/components/promptcomposer/index.tsx","./src/ui/components/sharepopover/index.tsx","./src/ui/components/textstyletoolbar/fontcatalog.ts","./src/ui/components/textstyletoolbar/index.tsx","./src/ui/components/toolbarcustomizedialog/index.tsx","./src/ui/components/workspacetitle/index.tsx","./src/ui/hooks/usefloatingtoolbarplacement.ts","./src/ui/hooks/usetypewritertext.ts","./src/ui/hooks/useusermessage.ts","./src/ui/lib/friendlyagenterrors.ts","./src/ui/lib/imageadjustments.ts","./src/ui/lib/imagedelivery.ts","./src/ui/lib/promptimagereferences.ts","./src/ui/pages/brandkitpage/index.tsx","./src/ui/pages/brandkitroutepage/index.tsx","./src/ui/pages/canvasworkspace/index.tsx","./src/ui/pages/canvasworkspace/canvas/canvascolorpopover.tsx","./src/ui/pages/canvasworkspace/canvas/framestyletoolbar.tsx","./src/ui/pages/canvasworkspace/canvas/frametooloverlay.tsx","./src/ui/pages/canvasworkspace/canvas/penstroketoolbar.tsx","./src/ui/pages/canvasworkspace/canvas/pentool.tsx","./src/ui/pages/canvasworkspace/canvas/workspacetoolbar.tsx","./src/ui/pages/canvasworkspace/canvas/agentmessagefilters.ts","./src/ui/pages/canvasworkspace/canvas/canvasgeometry.ts","./src/ui/pages/canvasworkspace/canvas/canvasnodeops.ts","./src/ui/pages/canvasworkspace/canvas/framestyle.ts","./src/ui/pages/canvasworkspace/canvas/generatortaskpolling.ts","./src/ui/pages/canvasworkspace/canvas/imagegeneratorsizing.ts","./src/ui/pages/canvasworkspace/canvas/nodeexport.ts","./src/ui/pages/canvasworkspace/canvas/nodereferenceimages.ts","./src/ui/pages/canvasworkspace/canvas/shapenode.ts","./src/ui/pages/canvasworkspace/canvas/textnodedefaults.ts","./src/ui/pages/canvasworkspace/canvas/types.ts","./src/ui/pages/canvasworkspace/canvas/canvasselectionframe/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapenodepreview/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapestyletoolbar/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapetoolmenu/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapetooloverlay/index.tsx","./src/ui/pages/canvasworkspace/canvas/hooks/usecanvasdocument.ts","./src/ui/pages/canvasworkspace/canvas/hooks/usecanvaspersistence.ts","./src/ui/pages/canvasworkspace/canvas/hooks/usecanvasselection.ts","./src/ui/pages/canvasworkspace/canvas/hooks/usegenerationstream.ts","./src/ui/pages/homepage/index.tsx","./src/ui/pages/homeshell/index.tsx","./src/ui/pages/notfoundpage/index.tsx","./src/ui/pages/projectspage/index.tsx","./src/ui/pages/projectsroutepage/index.tsx"],"version":"6.0.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/app/layout.tsx","./src/app/page.tsx","./src/app/root.tsx","./src/app/[...path]/page.tsx","./src/app/api/[...path]/route.ts","./src/app/project/[id]/page.tsx","./src/app/projects/page.tsx","./src/app/projects/[id]/page.tsx","./src/application/route.ts","./src/components/ui/button.tsx","./src/components/ui/context-menu.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/popover.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/domain/auth.ts","./src/domain/brandkit.ts","./src/domain/design.ts","./src/i18n/i18n.tsx","./src/infrastructure/authgateway.ts","./src/infrastructure/brandkitrepository.ts","./src/infrastructure/designgateway.ts","./src/infrastructure/imageuploadencoding.ts","./src/infrastructure/usermessages.ts","./src/infrastructure/designgateway/agents.ts","./src/infrastructure/designgateway/assets.ts","./src/infrastructure/designgateway/canvas.ts","./src/infrastructure/designgateway/canvassnapshot.ts","./src/infrastructure/designgateway/clientids.ts","./src/infrastructure/designgateway/events.ts","./src/infrastructure/designgateway/generation.ts","./src/infrastructure/designgateway/projects.ts","./src/infrastructure/designgateway/request.ts","./src/infrastructure/designgateway/responses.ts","./src/infrastructure/designgateway/sharecontext.ts","./src/infrastructure/designgateway/sharing.ts","./src/infrastructure/designgateway/types.ts","./src/infrastructure/designgateway/uicontent.ts","./src/ui/app.tsx","./src/ui/auth/authprovider/index.tsx","./src/ui/auth/logindialog/brandlogo.tsx","./src/ui/auth/logindialog/otpinput.tsx","./src/ui/auth/logindialog/turnstilewidget.tsx","./src/ui/auth/logindialog/index.tsx","./src/ui/components/brandmark.tsx","./src/ui/components/accountmanagementdialog/index.tsx","./src/ui/components/artboardpreview/index.tsx","./src/ui/components/brandkitselector/index.tsx","./src/ui/components/canvasagentcomposer/index.tsx","./src/ui/components/canvasagentemptystate/defaults.ts","./src/ui/components/canvasagentemptystate/index.tsx","./src/ui/components/canvasagenttimeline/index.tsx","./src/ui/components/canvasannotationpreview/index.tsx","./src/ui/components/canvasannotations/index.tsx","./src/ui/components/canvasminimap/index.tsx","./src/ui/components/canvasnodecontextmenu/index.tsx","./src/ui/components/canvaspanels/index.tsx","./src/ui/components/canvastoast/index.tsx","./src/ui/components/canvasworkoverlay/index.tsx","./src/ui/components/canvaszoommenu/index.tsx","./src/ui/components/deleteprojectdialog/index.tsx","./src/ui/components/imagedropoverlay/index.tsx","./src/ui/components/imagegeneratorfloatingcomposer/index.tsx","./src/ui/components/imagenodeactions/index.tsx","./src/ui/components/imagetexteditpanel/index.tsx","./src/ui/components/layermergeskeleton/index.tsx","./src/ui/components/markdowncontent/index.tsx","./src/ui/components/multianglepanel/index.tsx","./src/ui/components/multiselectionoverlay/index.tsx","./src/ui/components/projectcard/index.tsx","./src/ui/components/projectthumb/index.tsx","./src/ui/components/promptcomposer/index.tsx","./src/ui/components/sharepopover/index.tsx","./src/ui/components/textstyletoolbar/fontcatalog.ts","./src/ui/components/textstyletoolbar/index.tsx","./src/ui/components/toolbarcustomizedialog/index.tsx","./src/ui/components/workspacetitle/index.tsx","./src/ui/hooks/usefloatingtoolbarplacement.ts","./src/ui/hooks/usetypewritertext.ts","./src/ui/hooks/useusermessage.ts","./src/ui/lib/friendlyagenterrors.ts","./src/ui/lib/imageadjustments.ts","./src/ui/lib/imagedelivery.ts","./src/ui/lib/promptimagereferences.ts","./src/ui/pages/brandkitpage/index.tsx","./src/ui/pages/brandkitroutepage/index.tsx","./src/ui/pages/canvasworkspace/index.tsx","./src/ui/pages/canvasworkspace/canvas/canvascolorpopover.tsx","./src/ui/pages/canvasworkspace/canvas/framestyletoolbar.tsx","./src/ui/pages/canvasworkspace/canvas/frametooloverlay.tsx","./src/ui/pages/canvasworkspace/canvas/penstroketoolbar.tsx","./src/ui/pages/canvasworkspace/canvas/pentool.tsx","./src/ui/pages/canvasworkspace/canvas/workspacetoolbar.tsx","./src/ui/pages/canvasworkspace/canvas/agentmessagefilters.ts","./src/ui/pages/canvasworkspace/canvas/canvasgeometry.ts","./src/ui/pages/canvasworkspace/canvas/canvasnodeops.ts","./src/ui/pages/canvasworkspace/canvas/framestyle.ts","./src/ui/pages/canvasworkspace/canvas/generatortaskpolling.ts","./src/ui/pages/canvasworkspace/canvas/imagegeneratorsizing.ts","./src/ui/pages/canvasworkspace/canvas/nodeexport.ts","./src/ui/pages/canvasworkspace/canvas/nodereferenceimages.ts","./src/ui/pages/canvasworkspace/canvas/shapenode.ts","./src/ui/pages/canvasworkspace/canvas/textnodedefaults.ts","./src/ui/pages/canvasworkspace/canvas/types.ts","./src/ui/pages/canvasworkspace/canvas/canvasselectionframe/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapenodepreview/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapestyletoolbar/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapetoolmenu/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapetooloverlay/index.tsx","./src/ui/pages/canvasworkspace/canvas/hooks/usecanvasdocument.ts","./src/ui/pages/canvasworkspace/canvas/hooks/usecanvaspersistence.ts","./src/ui/pages/canvasworkspace/canvas/hooks/usecanvasselection.ts","./src/ui/pages/canvasworkspace/canvas/hooks/usegenerationstream.ts","./src/ui/pages/homepage/index.tsx","./src/ui/pages/homeshell/index.tsx","./src/ui/pages/notfoundpage/index.tsx","./src/ui/pages/projectspage/index.tsx","./src/ui/pages/projectsroutepage/index.tsx"],"version":"6.0.3"} \ No newline at end of file diff --git a/server/API.md b/server/API.md index 0ebab29..69cf8f1 100644 --- a/server/API.md +++ b/server/API.md @@ -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 diff --git a/server/README.md b/server/README.md index cebd76c..a4d98ac 100644 --- a/server/README.md +++ b/server/README.md @@ -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: diff --git a/server/etc/config.yaml b/server/etc/config.yaml index 819c5f0..2779406 100644 --- a/server/etc/config.yaml +++ b/server/etc/config.yaml @@ -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" diff --git a/server/go.mod b/server/go.mod index 2b3d01f..d708122 100644 --- a/server/go.mod +++ b/server/go.mod @@ -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 diff --git a/server/go.sum b/server/go.sum index 9931018..566e8b6 100644 --- a/server/go.sum +++ b/server/go.sum @@ -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= diff --git a/server/img_infinite_canvas.api b/server/img_infinite_canvas.api index bb84bc8..4e7da59 100644 --- a/server/img_infinite_canvas.api +++ b/server/img_infinite_canvas.api @@ -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 { diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 8fe49a0..988a0cb 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -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"` diff --git a/server/internal/handler/user_context_middleware.go b/server/internal/handler/user_context_middleware.go index c768432..42129b4 100644 --- a/server/internal/handler/user_context_middleware.go +++ b/server/internal/handler/user_context_middleware.go @@ -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")) diff --git a/server/internal/handler/user_context_middleware_test.go b/server/internal/handler/user_context_middleware_test.go index ff50ea1..25dc5ae 100644 --- a/server/internal/handler/user_context_middleware_test.go +++ b/server/internal/handler/user_context_middleware_test.go @@ -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") diff --git a/server/internal/infrastructure/postgres/schema.sql b/server/internal/infrastructure/postgres/schema.sql index 422fb45..72eabdf 100644 --- a/server/internal/infrastructure/postgres/schema.sql +++ b/server/internal/infrastructure/postgres/schema.sql @@ -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, diff --git a/server/internal/logic/auth_mapper.go b/server/internal/logic/auth_mapper.go index c1b8548..b0a8d06 100644 --- a/server/internal/logic/auth_mapper.go +++ b/server/internal/logic/auth_mapper.go @@ -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 diff --git a/server/internal/logic/list_account_devices_logic.go b/server/internal/logic/list_account_devices_logic.go index f92fb7a..8be66fb 100644 --- a/server/internal/logic/list_account_devices_logic.go +++ b/server/internal/logic/list_account_devices_logic.go @@ -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 } diff --git a/server/internal/modules/auth/device.go b/server/internal/modules/auth/device.go new file mode 100644 index 0000000..9b09741 --- /dev/null +++ b/server/internal/modules/auth/device.go @@ -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]) +} diff --git a/server/internal/modules/auth/device_test.go b/server/internal/modules/auth/device_test.go new file mode 100644 index 0000000..017fe5b --- /dev/null +++ b/server/internal/modules/auth/device_test.go @@ -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) + } + }) + } +} diff --git a/server/internal/modules/auth/memory_store.go b/server/internal/modules/auth/memory_store.go index edd5ed7..2c5a1a2 100644 --- a/server/internal/modules/auth/memory_store.go +++ b/server/internal/modules/auth/memory_store.go @@ -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 diff --git a/server/internal/modules/auth/postgres_store.go b/server/internal/modules/auth/postgres_store.go index e07f637..672812b 100644 --- a/server/internal/modules/auth/postgres_store.go +++ b/server/internal/modules/auth/postgres_store.go @@ -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 { diff --git a/server/internal/modules/auth/postgres_store_integration_test.go b/server/internal/modules/auth/postgres_store_integration_test.go new file mode 100644 index 0000000..34d2932 --- /dev/null +++ b/server/internal/modules/auth/postgres_store_integration_test.go @@ -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)) + } +} diff --git a/server/internal/modules/auth/service.go b/server/internal/modules/auth/service.go index 87d7e36..ad3b4b8 100644 --- a/server/internal/modules/auth/service.go +++ b/server/internal/modules/auth/service.go @@ -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) diff --git a/server/internal/modules/auth/service_test.go b/server/internal/modules/auth/service_test.go index 6fa3d11..10e24cc 100644 --- a/server/internal/modules/auth/service_test.go +++ b/server/internal/modules/auth/service_test.go @@ -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") } } diff --git a/server/internal/modules/auth/session_context.go b/server/internal/modules/auth/session_context.go new file mode 100644 index 0000000..be49edd --- /dev/null +++ b/server/internal/modules/auth/session_context.go @@ -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 +} diff --git a/server/internal/modules/auth/token.go b/server/internal/modules/auth/token.go index e144fe2..7d88d41 100644 --- a/server/internal/modules/auth/token.go +++ b/server/internal/modules/auth/token.go @@ -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) { diff --git a/server/internal/modules/auth/types.go b/server/internal/modules/auth/types.go index 948fad5..bac1df5 100644 --- a/server/internal/modules/auth/types.go +++ b/server/internal/modules/auth/types.go @@ -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 } diff --git a/server/internal/svc/service_context.go b/server/internal/svc/service_context.go index 211f359..3f2fed7 100644 --- a/server/internal/svc/service_context.go +++ b/server/internal/svc/service_context.go @@ -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, diff --git a/server/internal/types/types.go b/server/internal/types/types.go index 0a9860d..3708542 100644 --- a/server/internal/types/types.go +++ b/server/internal/types/types.go @@ -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 {