feat(auth): add device session tracking with configurable limits
Track authenticated sessions per device (parsing user-agent for desktop vs mobile via mileusna/useragent), enforce configurable concurrent device limits (Auth.DeviceLimits), and surface device status and "remove other devices" management in the account dialog. Adds device/session context to the auth module stores and exposes limits through the API. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,9 @@ Auth:
|
||||
RefreshTTLSeconds: 2592000
|
||||
CodeTTLSeconds: 600
|
||||
DevReturnCode: true
|
||||
DeviceLimits:
|
||||
Desktop: 2
|
||||
Mobile: 1
|
||||
Email:
|
||||
Driver: none # none|smtp
|
||||
Host: ""
|
||||
|
||||
@@ -63,3 +63,8 @@ export type AccountDevice = {
|
||||
client: string;
|
||||
lastSeenAt: string;
|
||||
};
|
||||
|
||||
export type AccountDeviceLimits = {
|
||||
desktop: number;
|
||||
mobile: number;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "退出登录",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AccountDevice, AuthCodeResponse, AuthOptions, AuthSession, AuthUser, StoredAuthSession } from "@/domain/auth";
|
||||
import type { AccountDevice, AccountDeviceLimits, AuthCodeResponse, AuthOptions, AuthSession, AuthUser, StoredAuthSession } from "@/domain/auth";
|
||||
import { createApiFailure, createNetworkFailure } from "@/infrastructure/userMessages";
|
||||
|
||||
const sessionStorageKey = "img-infinite-canvas.auth.session";
|
||||
@@ -100,7 +100,7 @@ export const authGateway = {
|
||||
},
|
||||
|
||||
devices() {
|
||||
return apiRequest<{ devices: AccountDevice[] }>("/api/account/devices");
|
||||
return apiRequest<{ devices: AccountDevice[]; limits: AccountDeviceLimits }>("/api/account/devices");
|
||||
},
|
||||
|
||||
removeDevices() {
|
||||
|
||||
@@ -348,6 +348,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #e0e3e8;
|
||||
border-radius: 8px;
|
||||
@@ -371,27 +372,21 @@
|
||||
}
|
||||
|
||||
.account-management-button.danger {
|
||||
border-color: transparent;
|
||||
color: #8d939d;
|
||||
background: rgba(17, 24, 39, 0.06);
|
||||
border-color: #efc8c2;
|
||||
color: #b53b2c;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.account-management-button.danger:hover:not(:disabled) {
|
||||
color: #d64d34;
|
||||
background: rgba(214, 77, 52, 0.1);
|
||||
border-color: #e4ada4;
|
||||
color: #a63225;
|
||||
background: #fdf3f1;
|
||||
}
|
||||
|
||||
.account-management-button.small {
|
||||
min-width: auto;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.account-management-button.current {
|
||||
.account-management-button.danger:disabled {
|
||||
border-color: transparent;
|
||||
color: #aeb4bd;
|
||||
background: rgba(17, 24, 39, 0.03);
|
||||
background: rgba(17, 24, 39, 0.05);
|
||||
}
|
||||
|
||||
.account-management-device-action {
|
||||
@@ -424,62 +419,221 @@
|
||||
}
|
||||
|
||||
.account-management-device-table {
|
||||
display: grid;
|
||||
grid-template-columns: 100px minmax(0, 1fr) 120px;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.account-management-device-col {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.account-management-device-header,
|
||||
.account-management-device-row {
|
||||
display: grid;
|
||||
grid-template-columns: 96px minmax(0, 1fr) 112px;
|
||||
}
|
||||
|
||||
.account-management-device-head,
|
||||
.account-management-device-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.account-management-device-header {
|
||||
min-height: 38px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.account-management-device-head {
|
||||
height: 36px;
|
||||
color: #737a86;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.account-management-device-col.info .account-management-device-head,
|
||||
.account-management-device-col.info .account-management-device-cell {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.account-management-device-col.action .account-management-device-cell {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.account-management-device-cell {
|
||||
height: 48px;
|
||||
color: #20242c;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.account-management-device-cell span {
|
||||
.account-management-device-header > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.account-management-device-header > span:first-child {
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.account-management-device-row {
|
||||
min-height: 62px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
color: #20242c;
|
||||
}
|
||||
|
||||
.account-management-device-status,
|
||||
.account-management-device-info,
|
||||
.account-management-device-row-action {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.account-management-device-status {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.account-management-device-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
background: #b7bdc6;
|
||||
box-shadow: 0 0 0 3px rgba(183, 189, 198, 0.16);
|
||||
}
|
||||
|
||||
.account-management-device-dot.online {
|
||||
background: #2fc36c;
|
||||
box-shadow: 0 0 0 3px rgba(47, 195, 108, 0.14);
|
||||
}
|
||||
|
||||
.account-management-device-info {
|
||||
gap: 10px;
|
||||
padding: 9px 12px 9px 0;
|
||||
}
|
||||
|
||||
.account-management-device-info > svg {
|
||||
flex: 0 0 auto;
|
||||
color: #7a818c;
|
||||
}
|
||||
|
||||
.account-management-device-info > span {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.account-management-device-info strong,
|
||||
.account-management-device-info small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.account-management-online-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-left: 9px;
|
||||
.account-management-device-info strong {
|
||||
color: #262a31;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
.account-management-device-info small {
|
||||
color: #818895;
|
||||
font-size: 11px;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.account-management-device-row-action {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.account-management-device-current {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e1e4e9;
|
||||
border-radius: 8px;
|
||||
color: #8d949f;
|
||||
background: #fafafa;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.account-management-device-state {
|
||||
min-height: 88px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.account-management-device-state > div {
|
||||
min-height: 88px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 18px;
|
||||
color: #777f8b;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.account-management-device-state svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.account-management-device-retry {
|
||||
min-height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: 4px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid #dfe3e8;
|
||||
border-radius: 7px;
|
||||
color: #343943;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.account-management-device-retry:hover {
|
||||
background: #f6f7f8;
|
||||
}
|
||||
|
||||
.account-management-device-skeleton {
|
||||
display: block;
|
||||
border-radius: 5px;
|
||||
background: #eceef1;
|
||||
animation: account-management-device-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.account-management-device-skeleton.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #31cf74;
|
||||
}
|
||||
|
||||
.account-management-device-skeleton.icon {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.account-management-device-skeleton.line {
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.account-management-device-skeleton.line.primary {
|
||||
width: min(230px, 42vw);
|
||||
}
|
||||
|
||||
.account-management-device-skeleton.line.secondary {
|
||||
width: min(170px, 32vw);
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.account-management-device-skeleton.badge {
|
||||
width: 70px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.account-management-sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
border: 0;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@keyframes account-management-device-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.account-management-logout-copy {
|
||||
@@ -540,6 +694,10 @@
|
||||
padding: 0 14px;
|
||||
border: 1px solid #e0e3e8;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
color: #20242c;
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
@@ -552,6 +710,11 @@
|
||||
background: #d64d34;
|
||||
}
|
||||
|
||||
.account-management-confirm button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.account-management-backdrop {
|
||||
padding: 16px;
|
||||
@@ -590,9 +753,40 @@
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.account-management-device-table {
|
||||
grid-template-columns: 76px minmax(220px, 1fr) 106px;
|
||||
overflow-x: auto;
|
||||
.account-management-device-action .account-management-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.account-management-device-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.account-management-device-row {
|
||||
grid-template-columns: 26px minmax(0, 1fr);
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.account-management-device-status {
|
||||
align-items: flex-start;
|
||||
padding: 7px 0 0 4px;
|
||||
}
|
||||
|
||||
.account-management-device-info {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.account-management-device-row-action {
|
||||
grid-column: 2;
|
||||
justify-content: flex-start;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.account-management-device-row.loading .account-management-device-row-action {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.account-management-device-state > div {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.account-management-name-form {
|
||||
@@ -605,3 +799,9 @@
|
||||
width: min(320px, 100%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.account-management-device-skeleton {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { CircleUserRound, PencilLine, ReceiptText, X, Zap } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent, type ReactNode } from "react";
|
||||
import { AlertCircle, CircleUserRound, Loader2, Monitor, MonitorX, PencilLine, ReceiptText, RefreshCw, Smartphone, X, Zap } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState, type ChangeEvent, type FormEvent, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { AccountDevice, AuthUser } from "@/domain/auth";
|
||||
import type { AccountDevice, AccountDeviceLimits, AuthUser } from "@/domain/auth";
|
||||
import { useI18n } from "@/i18n/i18n";
|
||||
import { authGateway } from "@/infrastructure/authGateway";
|
||||
|
||||
@@ -15,12 +15,18 @@ type AccountManagementDialogProps = {
|
||||
onUserUpdate: (userPatch: Partial<AuthUser>) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type DeviceLoadState = "loading" | "ready" | "error";
|
||||
|
||||
export function AccountManagementDialog({ open, user, onClose, onLogout, onUserUpdate }: AccountManagementDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const deviceRequestRef = useRef(0);
|
||||
const scrollRevealTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [confirmDeviceRemoval, setConfirmDeviceRemoval] = useState(false);
|
||||
const [devices, setDevices] = useState<AccountDevice[]>([]);
|
||||
const [deviceLimits, setDeviceLimits] = useState<AccountDeviceLimits | null>(null);
|
||||
const [deviceLoadState, setDeviceLoadState] = useState<DeviceLoadState>("loading");
|
||||
const [deviceRemoving, setDeviceRemoving] = useState(false);
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [nameDraft, setNameDraft] = useState("");
|
||||
const [feedback, setFeedback] = useState<{ tone: "success" | "error"; message: string } | null>(null);
|
||||
@@ -30,15 +36,36 @@ export function AccountManagementDialog({ open, user, onClose, onLogout, onUserU
|
||||
const displayEmail = user?.email?.trim() || "";
|
||||
const displayAvatar = cleanAvatarUrl(user?.avatarUrl);
|
||||
const avatarLabel = avatarInitials(displayName || displayEmail);
|
||||
const loginTimestamp = useMemo(() => formatUtcTimestamp(new Date()), []);
|
||||
const accountDevices = devices.length > 0 ? devices : [defaultCurrentDevice(loginTimestamp)];
|
||||
const hasRemovableDevices = accountDevices.some((device) => !device.current);
|
||||
const removableDeviceCount = devices.filter((device) => !device.current).length;
|
||||
const hasRemovableDevices = deviceLoadState === "ready" && removableDeviceCount > 0;
|
||||
|
||||
const loadDevices = useCallback(async () => {
|
||||
const requestId = ++deviceRequestRef.current;
|
||||
setDeviceLoadState("loading");
|
||||
try {
|
||||
const { devices: nextDevices, limits } = await authGateway.devices();
|
||||
if (deviceRequestRef.current !== requestId) return;
|
||||
setDevices(nextDevices);
|
||||
setDeviceLimits(limits);
|
||||
setDeviceLoadState("ready");
|
||||
} catch {
|
||||
if (deviceRequestRef.current !== requestId) return;
|
||||
setDevices([]);
|
||||
setDeviceLimits(null);
|
||||
setDeviceLoadState("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
if (event.key !== "Escape" || deviceRemoving) return;
|
||||
if (confirmDeviceRemoval) {
|
||||
setConfirmDeviceRemoval(false);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
const previousDocumentOverflow = document.documentElement.style.overflow;
|
||||
@@ -51,28 +78,23 @@ export function AccountManagementDialog({ open, user, onClose, onLogout, onUserU
|
||||
document.documentElement.style.overflow = previousDocumentOverflow;
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [onClose, open]);
|
||||
}, [confirmDeviceRemoval, deviceRemoving, onClose, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let active = true;
|
||||
authGateway
|
||||
.devices()
|
||||
.then(({ devices: nextDevices }) => {
|
||||
if (active) setDevices(nextDevices);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setDevices([defaultCurrentDevice(loginTimestamp)]);
|
||||
});
|
||||
void loadDevices();
|
||||
return () => {
|
||||
active = false;
|
||||
deviceRequestRef.current++;
|
||||
};
|
||||
}, [loginTimestamp, open]);
|
||||
}, [loadDevices, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) return;
|
||||
setConfirmDeviceRemoval(false);
|
||||
setDevices([]);
|
||||
setDeviceLimits(null);
|
||||
setDeviceLoadState("loading");
|
||||
setDeviceRemoving(false);
|
||||
setEditingName(false);
|
||||
setFeedback(null);
|
||||
setProfileSaving(false);
|
||||
@@ -149,11 +171,30 @@ export function AccountManagementDialog({ open, user, onClose, onLogout, onUserU
|
||||
};
|
||||
|
||||
const removeAllDevices = async () => {
|
||||
setConfirmDeviceRemoval(false);
|
||||
const response = await authGateway.removeDevices();
|
||||
const { devices: nextDevices } = await authGateway.devices();
|
||||
setDevices(nextDevices);
|
||||
showFeedback("success", t("devicesRemoved", { count: response.removed }));
|
||||
if (deviceRemoving) return;
|
||||
setDeviceRemoving(true);
|
||||
try {
|
||||
const response = await authGateway.removeDevices();
|
||||
setDevices((currentDevices) => currentDevices.filter((device) => device.current));
|
||||
setDeviceLoadState("ready");
|
||||
setConfirmDeviceRemoval(false);
|
||||
showFeedback("success", t("devicesRemoved", { count: response.removed }));
|
||||
|
||||
const requestId = ++deviceRequestRef.current;
|
||||
try {
|
||||
const { devices: nextDevices, limits } = await authGateway.devices();
|
||||
if (deviceRequestRef.current === requestId) {
|
||||
setDevices(nextDevices);
|
||||
setDeviceLimits(limits);
|
||||
}
|
||||
} catch {
|
||||
// The destructive action succeeded; keep the optimistic current-device result.
|
||||
}
|
||||
} catch {
|
||||
showFeedback("error", t("devicesRemoveFailed"));
|
||||
} finally {
|
||||
setDeviceRemoving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revealScrollbarWhileScrolling = () => {
|
||||
@@ -171,11 +212,11 @@ export function AccountManagementDialog({ open, user, onClose, onLogout, onUserU
|
||||
role="presentation"
|
||||
onWheelCapture={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
if (!deviceRemoving && event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<section className="account-management-dialog" role="dialog" aria-modal="true" aria-label={t("accountManagement")}>
|
||||
<button className="account-management-close" type="button" onClick={onClose} aria-label={t("close")}>
|
||||
<button className="account-management-close" type="button" onClick={onClose} aria-label={t("close")} disabled={deviceRemoving}>
|
||||
<X size={21} strokeWidth={1.8} />
|
||||
</button>
|
||||
|
||||
@@ -269,49 +310,78 @@ export function AccountManagementDialog({ open, user, onClose, onLogout, onUserU
|
||||
<div className="account-management-device-action">
|
||||
<div>
|
||||
<strong>{t("removeAllDevices")}</strong>
|
||||
<p>{t("removeAllDevicesDescription")}</p>
|
||||
<p>
|
||||
{deviceLimits
|
||||
? t("removeAllDevicesDescription", { desktop: deviceLimits.desktop, mobile: deviceLimits.mobile })
|
||||
: t(deviceLoadState === "loading" ? "deviceLimitsLoading" : "deviceLimitsUnavailable")}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="account-management-button danger"
|
||||
type="button"
|
||||
onClick={() => setConfirmDeviceRemoval(true)}
|
||||
disabled={!hasRemovableDevices}
|
||||
disabled={!hasRemovableDevices || deviceRemoving}
|
||||
>
|
||||
<MonitorX size={16} strokeWidth={1.8} />
|
||||
{t("removeAllDevices")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="account-management-device-table" role="table" aria-label={t("deviceManagement")}>
|
||||
<DeviceColumn
|
||||
title={t("onlineStatus")}
|
||||
className="status"
|
||||
cells={accountDevices.map((device) => <span key={device.id} className={device.online ? "account-management-online-dot" : ""} />)}
|
||||
/>
|
||||
<DeviceColumn
|
||||
title={t("deviceInfo")}
|
||||
className="info"
|
||||
cells={accountDevices.map((device) => {
|
||||
const info = deviceInfoText(device, t);
|
||||
return (
|
||||
<span key={device.id} title={info}>
|
||||
{info}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
/>
|
||||
<DeviceColumn
|
||||
title=""
|
||||
className="action"
|
||||
cells={accountDevices.map((device) =>
|
||||
device.current ? (
|
||||
<button key={device.id} className="account-management-button small current" type="button" disabled>
|
||||
{t("currentDevice")}
|
||||
<div
|
||||
className="account-management-device-table"
|
||||
role="table"
|
||||
aria-label={t("deviceManagement")}
|
||||
aria-busy={deviceLoadState === "loading" || deviceRemoving}
|
||||
>
|
||||
<div className="account-management-device-header" role="row">
|
||||
<span role="columnheader">{t("onlineStatus")}</span>
|
||||
<span role="columnheader">{t("deviceInfo")}</span>
|
||||
<span role="columnheader" aria-label={t("deviceStatus")} />
|
||||
</div>
|
||||
<div className="account-management-device-body" role="rowgroup">
|
||||
{deviceLoadState === "loading" && <DeviceLoadingRows label={t("deviceListLoading")} />}
|
||||
{deviceLoadState === "error" && (
|
||||
<DeviceTableState icon={<AlertCircle size={18} />} message={t("deviceListLoadFailed")}>
|
||||
<button className="account-management-device-retry" type="button" onClick={() => void loadDevices()}>
|
||||
<RefreshCw size={14} strokeWidth={1.9} />
|
||||
{t("retry")}
|
||||
</button>
|
||||
) : (
|
||||
<span key={device.id} />
|
||||
)
|
||||
</DeviceTableState>
|
||||
)}
|
||||
/>
|
||||
{deviceLoadState === "ready" && devices.length === 0 && (
|
||||
<DeviceTableState icon={<Monitor size={18} />} message={t("noActiveDevices")}>
|
||||
<button className="account-management-device-retry" type="button" onClick={() => void loadDevices()}>
|
||||
<RefreshCw size={14} strokeWidth={1.9} />
|
||||
{t("retry")}
|
||||
</button>
|
||||
</DeviceTableState>
|
||||
)}
|
||||
{deviceLoadState === "ready" &&
|
||||
devices.map((device) => {
|
||||
const primaryInfo = devicePrimaryInfo(device, t);
|
||||
const secondaryInfo = deviceSecondaryInfo(device, t, locale);
|
||||
const onlineLabel = device.online ? t("deviceOnline") : t("deviceOffline");
|
||||
const DeviceIcon = device.deviceType === "mobile" ? Smartphone : Monitor;
|
||||
return (
|
||||
<div className="account-management-device-row" role="row" key={device.id}>
|
||||
<div className="account-management-device-status" role="cell" title={onlineLabel}>
|
||||
<span className={`account-management-device-dot ${device.online ? "online" : "offline"}`} aria-hidden="true" />
|
||||
<span className="account-management-sr-only">{onlineLabel}</span>
|
||||
</div>
|
||||
<div className="account-management-device-info" role="cell">
|
||||
<DeviceIcon size={17} strokeWidth={1.7} aria-hidden="true" />
|
||||
<span>
|
||||
<strong title={primaryInfo}>{primaryInfo}</strong>
|
||||
<small title={secondaryInfo}>{secondaryInfo}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="account-management-device-row-action" role="cell">
|
||||
{device.current && <span className="account-management-device-current">{t("currentDevice")}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -334,16 +404,23 @@ export function AccountManagementDialog({ open, user, onClose, onLogout, onUserU
|
||||
</main>
|
||||
</div>
|
||||
{confirmDeviceRemoval && (
|
||||
<div className="account-management-confirm-backdrop" role="presentation">
|
||||
<div
|
||||
className="account-management-confirm-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (!deviceRemoving && event.target === event.currentTarget) setConfirmDeviceRemoval(false);
|
||||
}}
|
||||
>
|
||||
<div className="account-management-confirm" role="alertdialog" aria-modal="true" aria-label={t("removeAllDevices")}>
|
||||
<h4>{t("removeAllDevicesConfirmTitle")}</h4>
|
||||
<p>{t("removeAllDevicesConfirmText")}</p>
|
||||
<p>{t("removeAllDevicesConfirmText", { count: removableDeviceCount })}</p>
|
||||
<div>
|
||||
<button type="button" onClick={() => setConfirmDeviceRemoval(false)}>
|
||||
<button type="button" onClick={() => setConfirmDeviceRemoval(false)} disabled={deviceRemoving}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button className="danger" type="button" onClick={() => void removeAllDevices()}>
|
||||
{t("removeAllDevices")}
|
||||
<button className="danger" type="button" onClick={() => void removeAllDevices()} disabled={deviceRemoving}>
|
||||
{deviceRemoving && <Loader2 className="spin" size={15} aria-hidden="true" />}
|
||||
{deviceRemoving ? t("devicesRemoving") : t("removeAllDevices")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -355,35 +432,38 @@ export function AccountManagementDialog({ open, user, onClose, onLogout, onUserU
|
||||
);
|
||||
}
|
||||
|
||||
function formatUtcTimestamp(date: Date) {
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getUTCDate()).padStart(2, "0");
|
||||
const hour = String(date.getUTCHours()).padStart(2, "0");
|
||||
const minute = String(date.getUTCMinutes()).padStart(2, "0");
|
||||
|
||||
return `${year}-${month}-${day} ${hour}:${minute}(UTC)`;
|
||||
}
|
||||
|
||||
function defaultCurrentDevice(lastSeenAt: string): AccountDevice {
|
||||
return {
|
||||
id: "current",
|
||||
online: true,
|
||||
current: true,
|
||||
deviceType: "desktop",
|
||||
system: "unknown",
|
||||
browser: "unknown",
|
||||
client: "PC-WEB",
|
||||
lastSeenAt
|
||||
};
|
||||
}
|
||||
|
||||
function deviceInfoText(device: AccountDevice, t: (key: string) => string) {
|
||||
const deviceType = device.deviceType === "desktop" ? t("accountDeviceDesktop") : device.deviceType || t("accountDeviceDesktop");
|
||||
function devicePrimaryInfo(device: AccountDevice, t: (key: string, params?: Record<string, string | number>) => string) {
|
||||
const deviceType = device.deviceType === "mobile" ? t("accountDeviceMobile") : t("accountDeviceDesktop");
|
||||
const system = device.system === "unknown" ? t("accountDeviceUnknownSystem") : device.system || t("accountDeviceUnknownSystem");
|
||||
const browser = device.browser === "unknown" ? t("accountDeviceUnknownBrowser") : device.browser || t("accountDeviceUnknownBrowser");
|
||||
return `${deviceType} · ${system} · ${browser}`;
|
||||
}
|
||||
|
||||
return `${deviceType} | ${system} | ${browser} | ${device.client || "PC-WEB"} | | ${device.lastSeenAt}`;
|
||||
function deviceSecondaryInfo(
|
||||
device: AccountDevice,
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
locale: string
|
||||
) {
|
||||
const fallbackClient = device.deviceType === "mobile" ? "MOBILE-WEB" : "PC-WEB";
|
||||
return `${device.client || fallbackClient} · ${t("deviceLastSeen", { time: formatDeviceLastSeen(device.lastSeenAt, locale) })}`;
|
||||
}
|
||||
|
||||
function formatDeviceLastSeen(value: string, locale: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "-";
|
||||
const legacyUTC = trimmed.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})(?::(\d{2}))?\(UTC\)$/);
|
||||
const instant = new Date(legacyUTC ? `${legacyUTC[1]}T${legacyUTC[2]}:${legacyUTC[3] || "00"}Z` : trimmed);
|
||||
if (Number.isNaN(instant.getTime())) return trimmed;
|
||||
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
timeZoneName: "short"
|
||||
}).format(instant);
|
||||
}
|
||||
|
||||
function avatarInitials(value: string) {
|
||||
@@ -401,17 +481,39 @@ function cleanAvatarUrl(value: string | undefined) {
|
||||
return avatarUrl;
|
||||
}
|
||||
|
||||
function DeviceColumn({ title, className, cells }: { title: string; className: string; cells: ReactNode[] }) {
|
||||
function DeviceLoadingRows({ label }: { label: string }) {
|
||||
return (
|
||||
<div className={`account-management-device-col ${className}`}>
|
||||
<div className="account-management-device-head" role="columnheader">
|
||||
{title}
|
||||
</div>
|
||||
{cells.map((cell, index) => (
|
||||
<div className="account-management-device-cell" role="cell" key={index}>
|
||||
{cell}
|
||||
<>
|
||||
{[0, 1].map((index) => (
|
||||
<div className="account-management-device-row loading" role="row" key={index} aria-hidden="true">
|
||||
<div className="account-management-device-status" role="cell">
|
||||
<span className="account-management-device-skeleton dot" />
|
||||
{index === 0 && <span className="account-management-sr-only">{label}</span>}
|
||||
</div>
|
||||
<div className="account-management-device-info" role="cell">
|
||||
<span className="account-management-device-skeleton icon" />
|
||||
<span>
|
||||
<i className="account-management-device-skeleton line primary" />
|
||||
<i className="account-management-device-skeleton line secondary" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="account-management-device-row-action" role="cell">
|
||||
<span className="account-management-device-skeleton badge" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceTableState({ icon, message, children }: { icon: ReactNode; message: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="account-management-device-state" role="row">
|
||||
<div role="cell" aria-colspan={3}>
|
||||
{icon}
|
||||
<span>{message}</span>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+38
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -91,12 +91,18 @@ type AuthConfig struct {
|
||||
RefreshTTLSeconds int64 `json:",default=2592000"`
|
||||
CodeTTLSeconds int64 `json:",default=600"`
|
||||
DevReturnCode bool `json:",default=true"`
|
||||
DeviceLimits AuthDeviceLimitsConfig
|
||||
Email EmailAuthConfig
|
||||
Turnstile TurnstileAuthConfig
|
||||
Google GoogleAuthConfig
|
||||
Wechat WechatAuthConfig
|
||||
}
|
||||
|
||||
type AuthDeviceLimitsConfig struct {
|
||||
Desktop int64 `json:",default=2"`
|
||||
Mobile int64 `json:",default=1"`
|
||||
}
|
||||
|
||||
type EmailAuthConfig struct {
|
||||
Driver string `json:",default=none"`
|
||||
Host string `json:",optional"`
|
||||
|
||||
@@ -15,6 +15,10 @@ type bearerAuthenticator interface {
|
||||
UserIDFromBearer(ctx context.Context, authorization string) (string, bool)
|
||||
}
|
||||
|
||||
type bearerIdentityAuthenticator interface {
|
||||
IdentityFromBearer(ctx context.Context, authorization string) (authmodule.Identity, bool)
|
||||
}
|
||||
|
||||
type profileAuthenticator interface {
|
||||
GetProfile(ctx context.Context, userID string) (authmodule.User, error)
|
||||
}
|
||||
@@ -30,10 +34,22 @@ func UserContextMiddleware(authenticator bearerAuthenticator, shareAuthorizers .
|
||||
}
|
||||
return func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := authmodule.ContextWithRequestMetadata(r.Context(), authmodule.RequestMetadata{
|
||||
UserAgent: r.Header.Get("User-Agent"),
|
||||
Platform: r.Header.Get("Sec-CH-UA-Platform"),
|
||||
Mobile: r.Header.Get("Sec-CH-UA-Mobile"),
|
||||
})
|
||||
userID := ""
|
||||
sessionID := ""
|
||||
authenticated := false
|
||||
if authenticator != nil {
|
||||
if tokenUserID, ok := userIDFromRequestAuth(r.Context(), r, authenticator); ok {
|
||||
if identityAuthenticator, ok := authenticator.(bearerIdentityAuthenticator); ok {
|
||||
if identity, valid := identityAuthenticator.IdentityFromBearer(ctx, r.Header.Get("Authorization")); valid {
|
||||
userID = identity.UserID
|
||||
sessionID = identity.SessionID
|
||||
authenticated = true
|
||||
}
|
||||
} else if tokenUserID, ok := userIDFromRequestAuth(ctx, r, authenticator); ok {
|
||||
userID = tokenUserID
|
||||
authenticated = true
|
||||
}
|
||||
@@ -41,7 +57,7 @@ func UserContextMiddleware(authenticator bearerAuthenticator, shareAuthorizers .
|
||||
actor := sharingmodule.Actor{Authenticated: authenticated, UserID: userID}
|
||||
if authenticated && requestUsesShareAccess(r) {
|
||||
if profiles, ok := authenticator.(profileAuthenticator); ok {
|
||||
profile, err := profiles.GetProfile(r.Context(), userID)
|
||||
profile, err := profiles.GetProfile(ctx, userID)
|
||||
if err != nil {
|
||||
writeAuthRequired(w)
|
||||
return
|
||||
@@ -54,7 +70,8 @@ func UserContextMiddleware(authenticator bearerAuthenticator, shareAuthorizers .
|
||||
}
|
||||
}
|
||||
|
||||
ctx := sharingmodule.ContextWithActor(r.Context(), actor)
|
||||
ctx = sharingmodule.ContextWithActor(ctx, actor)
|
||||
ctx = authmodule.ContextWithSessionID(ctx, sessionID)
|
||||
ctx = design.ContextWithUserID(ctx, userID)
|
||||
shareAuthorized := false
|
||||
shareID := strings.TrimSpace(r.Header.Get("X-Share-Id"))
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"img_infinite_canvas/internal/domain/design"
|
||||
authmodule "img_infinite_canvas/internal/modules/auth"
|
||||
sharingmodule "img_infinite_canvas/internal/modules/sharing"
|
||||
)
|
||||
|
||||
@@ -19,6 +20,25 @@ func (fakeBearerAuthenticator) UserIDFromBearer(_ context.Context, authorization
|
||||
return "", false
|
||||
}
|
||||
|
||||
type fakeIdentityAuthenticator struct {
|
||||
metadata authmodule.RequestMetadata
|
||||
}
|
||||
|
||||
func (f *fakeIdentityAuthenticator) UserIDFromBearer(context.Context, string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (f *fakeIdentityAuthenticator) IdentityFromBearer(ctx context.Context, authorization string) (authmodule.Identity, bool) {
|
||||
if authorization != "Bearer session-token" {
|
||||
return authmodule.Identity{}, false
|
||||
}
|
||||
f.metadata = authmodule.RequestMetadataFromContext(ctx)
|
||||
return authmodule.Identity{
|
||||
UserID: "91cd197b-8255-4172-9981-77391fd38f33",
|
||||
SessionID: "session-1",
|
||||
}, true
|
||||
}
|
||||
|
||||
type fakeShareAuthorizer struct {
|
||||
access sharingmodule.Access
|
||||
err error
|
||||
@@ -50,6 +70,33 @@ func TestUserContextMiddlewareUsesAuthorizationHeader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserContextMiddlewarePropagatesSessionAndDeviceMetadata(t *testing.T) {
|
||||
authenticator := &fakeIdentityAuthenticator{}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/account/devices", nil)
|
||||
req.Header.Set("Authorization", "Bearer session-token")
|
||||
req.Header.Set("User-Agent", "test-browser")
|
||||
req.Header.Set("Sec-CH-UA-Platform", `"macOS"`)
|
||||
req.Header.Set("Sec-CH-UA-Mobile", "?0")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
UserContextMiddleware(authenticator)(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := design.UserIDFromContext(r.Context()); got != "91cd197b-8255-4172-9981-77391fd38f33" {
|
||||
t.Fatalf("expected authenticated user in context, got %s", got)
|
||||
}
|
||||
if got := authmodule.SessionIDFromContext(r.Context()); got != "session-1" {
|
||||
t.Fatalf("expected session id in context, got %s", got)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusNoContent, rec.Code)
|
||||
}
|
||||
if authenticator.metadata.UserAgent != "test-browser" || authenticator.metadata.Platform != `"macOS"` || authenticator.metadata.Mobile != "?0" {
|
||||
t.Fatalf("unexpected request metadata %#v", authenticator.metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserContextMiddlewareIgnoresLegacyTokenHeaderAndCookie(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/projects", nil)
|
||||
req.Header.Set("token", "valid")
|
||||
|
||||
@@ -43,6 +43,27 @@ CREATE TABLE IF NOT EXISTS auth_verification_codes (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS auth_codes_target_idx ON auth_verification_codes(region, channel, target, purpose, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_device_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
device_type TEXT NOT NULL DEFAULT 'desktop',
|
||||
system TEXT NOT NULL DEFAULT 'unknown',
|
||||
browser TEXT NOT NULL DEFAULT 'unknown',
|
||||
client TEXT NOT NULL DEFAULT 'PC-WEB',
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
last_seen_at TIMESTAMPTZ NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS auth_device_sessions_user_active_idx
|
||||
ON auth_device_sessions(user_id, last_seen_at DESC)
|
||||
WHERE revoked_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS auth_device_sessions_expiry_idx
|
||||
ON auth_device_sessions(expires_at)
|
||||
WHERE revoked_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS brand_kits (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
authmodule "img_infinite_canvas/internal/modules/auth"
|
||||
"img_infinite_canvas/internal/types"
|
||||
)
|
||||
@@ -93,7 +95,7 @@ func toAPIAccountDevices(devices []authmodule.Device) []types.AccountDevice {
|
||||
System: device.System,
|
||||
Browser: device.Browser,
|
||||
Client: device.Client,
|
||||
LastSeenAt: device.LastSeenAt.UTC().Format("2006-01-02 15:04(UTC)"),
|
||||
LastSeenAt: device.LastSeenAt.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return items
|
||||
|
||||
@@ -32,5 +32,12 @@ func (l *ListAccountDevicesLogic) ListAccountDevices() (resp *types.AccountDevic
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &types.AccountDeviceListResponse{Devices: toAPIAccountDevices(devices)}, nil
|
||||
limits := l.svcCtx.AuthService.DeviceLimits()
|
||||
return &types.AccountDeviceListResponse{
|
||||
Devices: toAPIAccountDevices(devices),
|
||||
Limits: types.AccountDeviceLimits{
|
||||
Desktop: limits.Desktop,
|
||||
Mobile: limits.Mobile,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mileusna/useragent"
|
||||
)
|
||||
|
||||
const (
|
||||
unknownDeviceSystem = "unknown"
|
||||
unknownDeviceBrowser = "unknown"
|
||||
)
|
||||
|
||||
func deviceSessionFromMetadata(metadata RequestMetadata) DeviceSession {
|
||||
parsed := useragent.Parse(strings.TrimSpace(metadata.UserAgent))
|
||||
deviceType := "desktop"
|
||||
client := "PC-WEB"
|
||||
if clientHintMobile(metadata.Mobile) || parsed.Mobile || parsed.Tablet {
|
||||
deviceType = "mobile"
|
||||
client = "MOBILE-WEB"
|
||||
}
|
||||
|
||||
system := cleanDeviceLabel(parsed.OS, unknownDeviceSystem)
|
||||
if platform := cleanClientHint(metadata.Platform); platform != "" {
|
||||
system = platform
|
||||
}
|
||||
browser := cleanDeviceLabel(parsed.Name, unknownDeviceBrowser)
|
||||
if parsed.Version != "" && browser != unknownDeviceBrowser {
|
||||
browser += " " + majorVersion(parsed.Version)
|
||||
}
|
||||
|
||||
return DeviceSession{
|
||||
DeviceType: deviceType,
|
||||
System: system,
|
||||
Browser: browser,
|
||||
Client: client,
|
||||
UserAgent: limitMetadata(strings.TrimSpace(metadata.UserAgent), 1024),
|
||||
}
|
||||
}
|
||||
|
||||
func clientHintMobile(value string) bool {
|
||||
value = strings.Trim(strings.TrimSpace(value), `"`)
|
||||
return value == "1" || strings.EqualFold(value, "true") || strings.EqualFold(value, "?1")
|
||||
}
|
||||
|
||||
func cleanClientHint(value string) string {
|
||||
value = strings.Trim(strings.TrimSpace(value), `"`)
|
||||
if value == "" || strings.EqualFold(value, "unknown") {
|
||||
return ""
|
||||
}
|
||||
return limitMetadata(value, 80)
|
||||
}
|
||||
|
||||
func cleanDeviceLabel(value string, fallback string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || strings.EqualFold(value, "unknown") {
|
||||
return fallback
|
||||
}
|
||||
return limitMetadata(value, 80)
|
||||
}
|
||||
|
||||
func majorVersion(version string) string {
|
||||
version = strings.TrimSpace(version)
|
||||
if index := strings.IndexByte(version, '.'); index >= 0 {
|
||||
version = version[:index]
|
||||
}
|
||||
return limitMetadata(version, 16)
|
||||
}
|
||||
|
||||
func limitMetadata(value string, maxRunes int) string {
|
||||
if maxRunes <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(value)
|
||||
if len(runes) <= maxRunes {
|
||||
return value
|
||||
}
|
||||
return string(runes[:maxRunes])
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeviceSessionFromMetadata(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
metadata RequestMetadata
|
||||
deviceType string
|
||||
system string
|
||||
browser string
|
||||
client string
|
||||
}{
|
||||
{
|
||||
name: "unknown desktop fallback",
|
||||
metadata: RequestMetadata{},
|
||||
deviceType: "desktop",
|
||||
system: "unknown",
|
||||
browser: "unknown",
|
||||
client: "PC-WEB",
|
||||
},
|
||||
{
|
||||
name: "desktop chrome",
|
||||
metadata: RequestMetadata{
|
||||
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0.0.0 Safari/537.36",
|
||||
Platform: `"Windows"`,
|
||||
Mobile: "?0",
|
||||
},
|
||||
deviceType: "desktop",
|
||||
system: "Windows",
|
||||
browser: "Chrome 126",
|
||||
client: "PC-WEB",
|
||||
},
|
||||
{
|
||||
name: "mobile safari from client hints",
|
||||
metadata: RequestMetadata{
|
||||
UserAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1",
|
||||
Platform: `"iOS"`,
|
||||
Mobile: "?1",
|
||||
},
|
||||
deviceType: "mobile",
|
||||
system: "iOS",
|
||||
browser: "Safari 18",
|
||||
client: "MOBILE-WEB",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
session := deviceSessionFromMetadata(test.metadata)
|
||||
if session.DeviceType != test.deviceType || session.System != test.system || session.Client != test.client {
|
||||
t.Fatalf("unexpected device metadata %#v", session)
|
||||
}
|
||||
if !strings.HasPrefix(session.Browser, test.browser) {
|
||||
t.Fatalf("expected browser %q, got %q", test.browser, session.Browser)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -10,15 +11,17 @@ import (
|
||||
)
|
||||
|
||||
type MemoryStore struct {
|
||||
mu sync.RWMutex
|
||||
users map[string]User
|
||||
codes map[string]VerificationCode
|
||||
mu sync.RWMutex
|
||||
users map[string]User
|
||||
codes map[string]VerificationCode
|
||||
sessions map[string]DeviceSession
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
return &MemoryStore{
|
||||
users: make(map[string]User),
|
||||
codes: make(map[string]VerificationCode),
|
||||
users: make(map[string]User),
|
||||
codes: make(map[string]VerificationCode),
|
||||
sessions: make(map[string]DeviceSession),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +107,171 @@ func (s *MemoryStore) UpdateUserProfile(ctx context.Context, userID string, patc
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) CreateDeviceSession(ctx context.Context, session DeviceSession, activeLimit int64) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, exists := s.sessions[session.ID]; !exists {
|
||||
s.sessions[session.ID] = session
|
||||
}
|
||||
s.enforceDeviceSessionLimitLocked(session.UserID, session.DeviceType, session.ID, activeLimit, session.CreatedAt)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) FindDeviceSession(ctx context.Context, id string) (DeviceSession, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return DeviceSession{}, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
session, ok := s.sessions[strings.TrimSpace(id)]
|
||||
if !ok {
|
||||
return DeviceSession{}, design.ErrNotFound
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) ListDeviceSessions(ctx context.Context, userID string, now time.Time) ([]DeviceSession, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userID = normalizeUUID(userID)
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
sessions := make([]DeviceSession, 0)
|
||||
for _, session := range s.sessions {
|
||||
if session.UserID != userID || session.RevokedAt != nil || !session.ExpiresAt.After(now) {
|
||||
continue
|
||||
}
|
||||
sessions = append(sessions, session)
|
||||
}
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) TouchDeviceSession(ctx context.Context, session DeviceSession) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
stored, ok := s.sessions[session.ID]
|
||||
if !ok || stored.UserID != session.UserID {
|
||||
return design.ErrNotFound
|
||||
}
|
||||
if stored.RevokedAt != nil || !stored.ExpiresAt.After(session.LastSeenAt) {
|
||||
return design.ErrNotFound
|
||||
}
|
||||
if stored.LastSeenAt.After(session.LastSeenAt) {
|
||||
return nil
|
||||
}
|
||||
stored.DeviceType = session.DeviceType
|
||||
stored.System = session.System
|
||||
stored.Browser = session.Browser
|
||||
stored.Client = session.Client
|
||||
stored.UserAgent = session.UserAgent
|
||||
stored.LastSeenAt = session.LastSeenAt
|
||||
s.sessions[session.ID] = stored
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) EnforceDeviceSessionLimit(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
deviceType string,
|
||||
keepSessionID string,
|
||||
activeLimit int64,
|
||||
revokedAt time.Time,
|
||||
) (int64, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.enforceDeviceSessionLimitLocked(normalizeUUID(userID), deviceType, keepSessionID, activeLimit, revokedAt), nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) enforceDeviceSessionLimitLocked(
|
||||
userID string,
|
||||
deviceType string,
|
||||
keepSessionID string,
|
||||
activeLimit int64,
|
||||
revokedAt time.Time,
|
||||
) int64 {
|
||||
if activeLimit < 1 {
|
||||
activeLimit = 1
|
||||
}
|
||||
active := make([]DeviceSession, 0)
|
||||
for _, session := range s.sessions {
|
||||
if session.UserID != userID || session.DeviceType != deviceType || session.RevokedAt != nil || !session.ExpiresAt.After(revokedAt) {
|
||||
continue
|
||||
}
|
||||
active = append(active, session)
|
||||
}
|
||||
sort.SliceStable(active, func(i, j int) bool {
|
||||
iKeep := active[i].ID == keepSessionID
|
||||
jKeep := active[j].ID == keepSessionID
|
||||
if iKeep != jKeep {
|
||||
return iKeep
|
||||
}
|
||||
if !active[i].CreatedAt.Equal(active[j].CreatedAt) {
|
||||
return active[i].CreatedAt.After(active[j].CreatedAt)
|
||||
}
|
||||
if !active[i].LastSeenAt.Equal(active[j].LastSeenAt) {
|
||||
return active[i].LastSeenAt.After(active[j].LastSeenAt)
|
||||
}
|
||||
return active[i].ID > active[j].ID
|
||||
})
|
||||
|
||||
var removed int64
|
||||
for index := int(activeLimit); index < len(active); index++ {
|
||||
session := active[index]
|
||||
revoked := revokedAt
|
||||
session.RevokedAt = &revoked
|
||||
s.sessions[session.ID] = session
|
||||
removed++
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
func (s *MemoryStore) RevokeOtherDeviceSessions(ctx context.Context, userID string, currentSessionID string, revokedAt time.Time) (int64, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
userID = normalizeUUID(userID)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var removed int64
|
||||
for id, session := range s.sessions {
|
||||
if session.UserID != userID || session.ID == currentSessionID || session.RevokedAt != nil || !session.ExpiresAt.After(revokedAt) {
|
||||
continue
|
||||
}
|
||||
revoked := revokedAt
|
||||
session.RevokedAt = &revoked
|
||||
s.sessions[id] = session
|
||||
removed++
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) RevokeDeviceSession(ctx context.Context, userID string, sessionID string, revokedAt time.Time) (int64, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
userID = normalizeUUID(userID)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
session, ok := s.sessions[sessionID]
|
||||
if !ok || session.UserID != userID || session.RevokedAt != nil || !session.ExpiresAt.After(revokedAt) {
|
||||
return 0, nil
|
||||
}
|
||||
revoked := revokedAt
|
||||
session.RevokedAt = &revoked
|
||||
s.sessions[sessionID] = session
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) CreateVerificationCode(ctx context.Context, code VerificationCode) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
|
||||
@@ -112,6 +112,174 @@ RETURNING id, region, phone_country_code, phone, email, name, avatar_url, passwo
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateDeviceSession(ctx context.Context, session DeviceSession, activeLimit int64) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if err := lockDeviceSessions(ctx, tx, session.UserID, session.DeviceType); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO auth_device_sessions (
|
||||
id, user_id, device_type, system, browser, client, user_agent,
|
||||
created_at, last_seen_at, expires_at, revoked_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`, session.ID, toPGUUID(session.UserID), session.DeviceType, session.System, session.Browser, session.Client, session.UserAgent,
|
||||
session.CreatedAt, session.LastSeenAt, session.ExpiresAt, session.RevokedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := enforceDeviceSessionLimitPostgres(ctx, tx, session.UserID, session.DeviceType, session.ID, activeLimit, session.CreatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) FindDeviceSession(ctx context.Context, id string) (DeviceSession, error) {
|
||||
return scanDeviceSession(s.pool.QueryRow(ctx, `
|
||||
SELECT id, user_id, device_type, system, browser, client, user_agent, created_at, last_seen_at, expires_at, revoked_at
|
||||
FROM auth_device_sessions
|
||||
WHERE id = $1
|
||||
`, strings.TrimSpace(id)))
|
||||
}
|
||||
|
||||
func (s *PostgresStore) ListDeviceSessions(ctx context.Context, userID string, now time.Time) ([]DeviceSession, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, user_id, device_type, system, browser, client, user_agent, created_at, last_seen_at, expires_at, revoked_at
|
||||
FROM auth_device_sessions
|
||||
WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > $2
|
||||
ORDER BY last_seen_at DESC, created_at DESC, id ASC
|
||||
`, toPGUUID(userID), now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
sessions := make([]DeviceSession, 0)
|
||||
for rows.Next() {
|
||||
session, scanErr := scanDeviceSession(rows)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
sessions = append(sessions, session)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) TouchDeviceSession(ctx context.Context, session DeviceSession) error {
|
||||
result, err := s.pool.Exec(ctx, `
|
||||
UPDATE auth_device_sessions
|
||||
SET device_type = $3,
|
||||
system = $4,
|
||||
browser = $5,
|
||||
client = $6,
|
||||
user_agent = $7,
|
||||
last_seen_at = GREATEST(last_seen_at, $8)
|
||||
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > $8
|
||||
`, session.ID, toPGUUID(session.UserID), session.DeviceType, session.System, session.Browser, session.Client, session.UserAgent, session.LastSeenAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return design.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) EnforceDeviceSessionLimit(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
deviceType string,
|
||||
keepSessionID string,
|
||||
activeLimit int64,
|
||||
revokedAt time.Time,
|
||||
) (int64, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if err := lockDeviceSessions(ctx, tx, userID, deviceType); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
removed, err := enforceDeviceSessionLimitPostgres(ctx, tx, userID, deviceType, keepSessionID, activeLimit, revokedAt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
func lockDeviceSessions(ctx context.Context, tx pgx.Tx, userID string, deviceType string) error {
|
||||
_, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, normalizeUUID(userID)+":"+deviceType)
|
||||
return err
|
||||
}
|
||||
|
||||
func enforceDeviceSessionLimitPostgres(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
userID string,
|
||||
deviceType string,
|
||||
keepSessionID string,
|
||||
activeLimit int64,
|
||||
revokedAt time.Time,
|
||||
) (int64, error) {
|
||||
if activeLimit < 1 {
|
||||
activeLimit = 1
|
||||
}
|
||||
result, err := tx.Exec(ctx, `
|
||||
WITH overflow AS (
|
||||
SELECT id
|
||||
FROM auth_device_sessions
|
||||
WHERE user_id = $1 AND device_type = $2 AND revoked_at IS NULL AND expires_at > $4
|
||||
ORDER BY CASE WHEN id = $3 THEN 0 ELSE 1 END,
|
||||
created_at DESC,
|
||||
last_seen_at DESC,
|
||||
id DESC
|
||||
OFFSET $5
|
||||
)
|
||||
UPDATE auth_device_sessions
|
||||
SET revoked_at = $4
|
||||
WHERE id IN (SELECT id FROM overflow)
|
||||
`, toPGUUID(userID), deviceType, strings.TrimSpace(keepSessionID), revokedAt, activeLimit)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) RevokeOtherDeviceSessions(ctx context.Context, userID string, currentSessionID string, revokedAt time.Time) (int64, error) {
|
||||
result, err := s.pool.Exec(ctx, `
|
||||
UPDATE auth_device_sessions
|
||||
SET revoked_at = $3
|
||||
WHERE user_id = $1 AND id <> $2 AND revoked_at IS NULL AND expires_at > $3
|
||||
`, toPGUUID(userID), strings.TrimSpace(currentSessionID), revokedAt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) RevokeDeviceSession(ctx context.Context, userID string, sessionID string, revokedAt time.Time) (int64, error) {
|
||||
result, err := s.pool.Exec(ctx, `
|
||||
UPDATE auth_device_sessions
|
||||
SET revoked_at = $3
|
||||
WHERE user_id = $1 AND id = $2 AND revoked_at IS NULL AND expires_at > $3
|
||||
`, toPGUUID(userID), strings.TrimSpace(sessionID), revokedAt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateVerificationCode(ctx context.Context, code VerificationCode) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO auth_verification_codes (id, region, channel, target, purpose, code_hash, attempts, expires_at, created_at)
|
||||
@@ -179,6 +347,31 @@ func scanUser(row rowScanner) (User, error) {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func scanDeviceSession(row rowScanner) (DeviceSession, error) {
|
||||
var userID pgtype.UUID
|
||||
var session DeviceSession
|
||||
if err := row.Scan(
|
||||
&session.ID,
|
||||
&userID,
|
||||
&session.DeviceType,
|
||||
&session.System,
|
||||
&session.Browser,
|
||||
&session.Client,
|
||||
&session.UserAgent,
|
||||
&session.CreatedAt,
|
||||
&session.LastSeenAt,
|
||||
&session.ExpiresAt,
|
||||
&session.RevokedAt,
|
||||
); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return DeviceSession{}, design.ErrNotFound
|
||||
}
|
||||
return DeviceSession{}, err
|
||||
}
|
||||
session.UserID = fromPGUUID(userID)
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func toPGUUID(id string) pgtype.UUID {
|
||||
parsed, err := uuid.Parse(normalizeUUID(id))
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPostgresStoreEnforcesConcurrentDeviceLimit(t *testing.T) {
|
||||
dataSource := os.Getenv("POSTGRES_TEST_DSN")
|
||||
if dataSource == "" {
|
||||
t.Skip("POSTGRES_TEST_DSN is not configured")
|
||||
}
|
||||
ctx := context.Background()
|
||||
store, err := NewPostgresStore(ctx, dataSource)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = store.Close() }()
|
||||
|
||||
user, err := store.UpsertUser(ctx, User{
|
||||
ID: newID(),
|
||||
Region: RegionGlobal,
|
||||
Email: newID() + "@device-limit.test",
|
||||
Status: "active",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = store.pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, toPGUUID(user.ID))
|
||||
}()
|
||||
|
||||
createdAt := time.Date(2026, 7, 11, 13, 0, 0, 0, time.UTC)
|
||||
const loginCount = 8
|
||||
const activeLimit = 2
|
||||
errCh := make(chan error, loginCount)
|
||||
var wg sync.WaitGroup
|
||||
for index := 0; index < loginCount; index++ {
|
||||
wg.Add(1)
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
sessionTime := createdAt.Add(time.Duration(index) * time.Millisecond)
|
||||
errCh <- store.CreateDeviceSession(ctx, DeviceSession{
|
||||
ID: newID(),
|
||||
UserID: user.ID,
|
||||
DeviceType: "desktop",
|
||||
System: "macOS",
|
||||
Browser: "Chrome 149",
|
||||
Client: "PC-WEB",
|
||||
CreatedAt: sessionTime,
|
||||
LastSeenAt: sessionTime,
|
||||
ExpiresAt: sessionTime.Add(time.Hour),
|
||||
}, activeLimit)
|
||||
}(index)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
for err := range errCh {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
sessions, err := store.ListDeviceSessions(ctx, user.ID, createdAt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sessions) != activeLimit {
|
||||
t.Fatalf("expected %d active desktop sessions after concurrent login, got %d", activeLimit, len(sessions))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -3,6 +3,7 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -95,6 +96,127 @@ func TestGlobalEmailCodeLogin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceLimitsUseConfiguredValuesAndSafeDefaults(t *testing.T) {
|
||||
configured := NewService(NewMemoryStore(), Config{
|
||||
Region: RegionGlobal,
|
||||
TokenSecret: "test-secret",
|
||||
DeviceLimits: DeviceLimits{Desktop: 5, Mobile: 3},
|
||||
})
|
||||
if limits := configured.DeviceLimits(); limits.Desktop != 5 || limits.Mobile != 3 {
|
||||
t.Fatalf("unexpected configured limits %#v", limits)
|
||||
}
|
||||
|
||||
defaults := NewService(NewMemoryStore(), Config{Region: RegionGlobal, TokenSecret: "test-secret"})
|
||||
if limits := defaults.DeviceLimits(); limits.Desktop != 2 || limits.Mobile != 1 {
|
||||
t.Fatalf("unexpected default limits %#v", limits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopDeviceLimitRevokesOldestSession(t *testing.T) {
|
||||
service := NewService(NewMemoryStore(), Config{
|
||||
Region: RegionGlobal,
|
||||
TokenSecret: "test-secret",
|
||||
TokenTTL: time.Hour,
|
||||
CodeTTL: time.Minute,
|
||||
DevReturnCode: true,
|
||||
DeviceLimits: DeviceLimits{Desktop: 1, Mobile: 1},
|
||||
})
|
||||
now := time.Date(2026, 7, 11, 11, 0, 0, 0, time.UTC)
|
||||
service.now = func() time.Time { return now }
|
||||
desktopCtx := ContextWithRequestMetadata(context.Background(), RequestMetadata{
|
||||
UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/149.0.0.0 Safari/537.36",
|
||||
Platform: `"macOS"`,
|
||||
Mobile: "?0",
|
||||
})
|
||||
|
||||
login := func() Session {
|
||||
t.Helper()
|
||||
code, err := service.RequestGlobalEmailCode(desktopCtx, "limit@example.com", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := service.LoginGlobalEmailCode(desktopCtx, "limit@example.com", code.DevCode)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
first := login()
|
||||
firstIdentity, ok := service.IdentityFromBearer(desktopCtx, "Bearer "+first.AccessToken)
|
||||
if !ok {
|
||||
t.Fatal("expected first desktop session to authenticate")
|
||||
}
|
||||
now = now.Add(time.Second)
|
||||
second := login()
|
||||
secondIdentity, ok := service.IdentityFromBearer(desktopCtx, "Bearer "+second.AccessToken)
|
||||
if !ok {
|
||||
t.Fatal("expected newest desktop session to authenticate")
|
||||
}
|
||||
|
||||
if _, ok := service.IdentityFromBearer(desktopCtx, "Bearer "+first.AccessToken); ok {
|
||||
t.Fatal("expected oldest desktop session to be revoked at the configured limit")
|
||||
}
|
||||
secondCtx := ContextWithSessionID(desktopCtx, secondIdentity.SessionID)
|
||||
devices, err := service.ListDevices(secondCtx, second.User.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(devices) != 1 || devices[0].ID != secondIdentity.SessionID || !devices[0].Current {
|
||||
t.Fatalf("expected only the newest desktop session, got %#v (first=%s)", devices, firstIdentity.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDevicesReconcilesSessionsCreatedBeforeLimitEnforcement(t *testing.T) {
|
||||
service := NewService(NewMemoryStore(), Config{
|
||||
Region: RegionGlobal,
|
||||
TokenSecret: "test-secret",
|
||||
TokenTTL: time.Hour,
|
||||
CodeTTL: time.Minute,
|
||||
DevReturnCode: true,
|
||||
DeviceLimits: DeviceLimits{Desktop: 2, Mobile: 1},
|
||||
})
|
||||
now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)
|
||||
service.now = func() time.Time { return now }
|
||||
desktopCtx := ContextWithRequestMetadata(context.Background(), RequestMetadata{
|
||||
UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/149.0.0.0 Safari/537.36",
|
||||
Platform: `"macOS"`,
|
||||
Mobile: "?0",
|
||||
})
|
||||
login := func() Session {
|
||||
t.Helper()
|
||||
code, err := service.RequestGlobalEmailCode(desktopCtx, "reconcile@example.com", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := service.LoginGlobalEmailCode(desktopCtx, "reconcile@example.com", code.DevCode)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
first := login()
|
||||
now = now.Add(time.Second)
|
||||
second := login()
|
||||
secondIdentity, ok := service.IdentityFromBearer(desktopCtx, "Bearer "+second.AccessToken)
|
||||
if !ok {
|
||||
t.Fatal("expected newest session to authenticate")
|
||||
}
|
||||
service.cfg.DeviceLimits.Desktop = 1
|
||||
secondCtx := ContextWithSessionID(desktopCtx, secondIdentity.SessionID)
|
||||
devices, err := service.ListDevices(secondCtx, second.User.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(devices) != 1 || devices[0].ID != secondIdentity.SessionID {
|
||||
t.Fatalf("expected list reconciliation to preserve only the current session, got %#v", devices)
|
||||
}
|
||||
if _, ok := service.IdentityFromBearer(desktopCtx, "Bearer "+first.AccessToken); ok {
|
||||
t.Fatal("expected pre-enforcement excess session to be revoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountProfileUpdatesPersistInStore(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := NewService(NewMemoryStore(), Config{
|
||||
@@ -130,36 +252,146 @@ func TestAccountProfileUpdatesPersistInStore(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountDevicesExposeCurrentLocalDeviceOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := NewService(NewMemoryStore(), Config{
|
||||
func TestAccountDevicesTrackAndRevokeOtherSessions(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
service := NewService(store, Config{
|
||||
Region: RegionGlobal,
|
||||
TokenSecret: "test-secret",
|
||||
TokenTTL: time.Hour,
|
||||
CodeTTL: time.Minute,
|
||||
DevReturnCode: true,
|
||||
})
|
||||
now := time.Date(2026, 7, 11, 9, 0, 0, 0, time.UTC)
|
||||
service.now = func() time.Time { return now }
|
||||
|
||||
code, err := service.RequestGlobalEmailCode(ctx, "user@example.com", "")
|
||||
desktopMetadata := RequestMetadata{
|
||||
UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
||||
Platform: `"macOS"`,
|
||||
Mobile: "?0",
|
||||
}
|
||||
desktopLoginCtx := ContextWithRequestMetadata(context.Background(), desktopMetadata)
|
||||
code, err := service.RequestGlobalEmailCode(desktopLoginCtx, "user@example.com", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := service.LoginGlobalEmailCode(ctx, "user@example.com", code.DevCode)
|
||||
desktopSession, err := service.LoginGlobalEmailCode(desktopLoginCtx, "user@example.com", code.DevCode)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
devices, err := service.ListDevices(ctx, session.User.ID)
|
||||
desktopIdentity, ok := service.IdentityFromBearer(desktopLoginCtx, "Bearer "+desktopSession.AccessToken)
|
||||
if !ok {
|
||||
t.Fatal("expected desktop session to authenticate")
|
||||
}
|
||||
|
||||
now = now.Add(2 * time.Minute)
|
||||
mobileMetadata := RequestMetadata{
|
||||
UserAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1",
|
||||
Platform: `"iOS"`,
|
||||
Mobile: "?1",
|
||||
}
|
||||
mobileLoginCtx := ContextWithRequestMetadata(context.Background(), mobileMetadata)
|
||||
code, err = service.RequestGlobalEmailCode(mobileLoginCtx, "user@example.com", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mobileSession, err := service.LoginGlobalEmailCode(mobileLoginCtx, "user@example.com", code.DevCode)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mobileIdentity, ok := service.IdentityFromBearer(mobileLoginCtx, "Bearer "+mobileSession.AccessToken)
|
||||
if !ok {
|
||||
t.Fatal("expected mobile session to authenticate")
|
||||
}
|
||||
mobileCtx := ContextWithSessionID(mobileLoginCtx, mobileIdentity.SessionID)
|
||||
|
||||
devices, err := service.ListDevices(mobileCtx, mobileSession.User.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(devices) != 2 {
|
||||
t.Fatalf("expected two devices, got %#v", devices)
|
||||
}
|
||||
if !devices[0].Current || devices[0].ID != mobileIdentity.SessionID || devices[0].DeviceType != "mobile" || devices[0].System != "iOS" {
|
||||
t.Fatalf("expected current mobile device first, got %#v", devices[0])
|
||||
}
|
||||
if devices[1].Current || devices[1].ID != desktopIdentity.SessionID || devices[1].DeviceType != "desktop" || devices[1].System != "macOS" {
|
||||
t.Fatalf("expected secondary desktop device, got %#v", devices[1])
|
||||
}
|
||||
|
||||
removed, err := service.RemoveOtherDevices(mobileCtx, mobileSession.User.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if removed != 1 {
|
||||
t.Fatalf("expected one removable device, got %d", removed)
|
||||
}
|
||||
if _, ok := service.IdentityFromBearer(desktopLoginCtx, "Bearer "+desktopSession.AccessToken); ok {
|
||||
t.Fatal("expected removed desktop session to be rejected")
|
||||
}
|
||||
if _, ok := service.IdentityFromBearer(mobileLoginCtx, "Bearer "+mobileSession.AccessToken); !ok {
|
||||
t.Fatal("expected current mobile session to remain valid")
|
||||
}
|
||||
|
||||
devices, err = service.ListDevices(mobileCtx, mobileSession.User.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(devices) != 1 || !devices[0].Current {
|
||||
t.Fatalf("expected one current device, got %#v", devices)
|
||||
t.Fatalf("expected only the current device after removal, got %#v", devices)
|
||||
}
|
||||
removed, err := service.RemoveOtherDevices(ctx, session.User.ID)
|
||||
removed, err = service.Logout(mobileCtx, mobileSession.User.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if removed != 0 {
|
||||
t.Fatalf("expected no removable devices, got %d", removed)
|
||||
if removed != 1 {
|
||||
t.Fatalf("expected logout to revoke one current session, got %d", removed)
|
||||
}
|
||||
if _, ok := service.IdentityFromBearer(mobileLoginCtx, "Bearer "+mobileSession.AccessToken); ok {
|
||||
t.Fatal("expected logged-out session to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyBearerMigratesToRevocableDeviceSession(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
service := NewService(store, Config{Region: RegionGlobal, TokenSecret: "test-secret", TokenTTL: time.Hour})
|
||||
now := time.Date(2026, 7, 11, 10, 0, 0, 0, time.UTC)
|
||||
service.now = func() time.Time { return now }
|
||||
user, err := store.UpsertUser(context.Background(), User{ID: newID(), Region: RegionGlobal, Email: "legacy@example.com", Status: "active"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyToken, _, err := signToken(user.ID, "test-secret", time.Hour, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := ContextWithRequestMetadata(context.Background(), RequestMetadata{
|
||||
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0.0.0 Safari/537.36",
|
||||
})
|
||||
identity, ok := service.IdentityFromBearer(ctx, "Bearer "+legacyToken)
|
||||
if !ok || identity.UserID != user.ID || !strings.HasPrefix(identity.SessionID, "legacy:") {
|
||||
t.Fatalf("expected migrated legacy identity, got %#v / %v", identity, ok)
|
||||
}
|
||||
authenticatedCtx := ContextWithSessionID(ctx, identity.SessionID)
|
||||
devices, err := service.ListDevices(authenticatedCtx, user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(devices) != 1 || !devices[0].Current || devices[0].System != "Windows" {
|
||||
t.Fatalf("unexpected migrated device %#v", devices)
|
||||
}
|
||||
if _, err := service.Logout(authenticatedCtx, user.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := service.IdentityFromBearer(ctx, "Bearer "+legacyToken); ok {
|
||||
t.Fatal("expected revoked legacy token to stay revoked")
|
||||
}
|
||||
|
||||
nonUserToken, _, err := signToken(newID(), "test-secret", time.Hour, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := service.IdentityFromBearer(ctx, "Bearer "+nonUserToken); ok {
|
||||
t.Fatal("expected signed token for a missing user to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package auth
|
||||
|
||||
import "context"
|
||||
|
||||
type requestMetadataContextKey struct{}
|
||||
type sessionIDContextKey struct{}
|
||||
|
||||
func ContextWithRequestMetadata(ctx context.Context, metadata RequestMetadata) context.Context {
|
||||
return context.WithValue(ctx, requestMetadataContextKey{}, metadata)
|
||||
}
|
||||
|
||||
func RequestMetadataFromContext(ctx context.Context) RequestMetadata {
|
||||
metadata, _ := ctx.Value(requestMetadataContextKey{}).(RequestMetadata)
|
||||
return metadata
|
||||
}
|
||||
|
||||
func ContextWithSessionID(ctx context.Context, sessionID string) context.Context {
|
||||
return context.WithValue(ctx, sessionIDContextKey{}, sessionID)
|
||||
}
|
||||
|
||||
func SessionIDFromContext(ctx context.Context) string {
|
||||
sessionID, _ := ctx.Value(sessionIDContextKey{}).(string)
|
||||
return sessionID
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user