feat(console): enforce guest sandbox session uuid rotation and unify guest-mode copy

This commit is contained in:
Haitao Pan 2026-02-05 22:45:54 +08:00
parent adaf5782fa
commit f695a0e937
2 changed files with 119 additions and 11 deletions

View File

@ -655,7 +655,7 @@ export const translations: Record<'en' | 'zh', Translation> = {
title: 'Account',
register: 'Register',
login: 'Login',
demo: 'Demo',
demo: 'Guest user演示模式',
welcome: 'Welcome, {username}',
logout: 'Sign out',
userCenter: 'User Center',
@ -698,7 +698,7 @@ export const translations: Record<'en' | 'zh', Translation> = {
userNotFound: 'We could not find an account with that username.',
genericError: 'We could not sign you in. Please try again later.',
serviceUnavailable: 'The account service is temporarily unavailable. Please try again shortly.',
disclaimer: 'This demo login keeps your username in memory only to personalize navigation while you browse.',
disclaimer: 'This Guest user演示模式 login keeps your username in memory only to personalize navigation while you browse.',
},
termsTitle: 'Terms of Service',
termsPoints: [
@ -1442,7 +1442,7 @@ export const translations: Record<'en' | 'zh', Translation> = {
title: '账户',
register: '注册',
login: '登录',
demo: '演示',
demo: 'Guest user演示模式)',
welcome: '欢迎,{username}',
logout: '退出登录',
userCenter: '用户中心',
@ -1485,7 +1485,7 @@ export const translations: Record<'en' | 'zh', Translation> = {
userNotFound: '未找到该用户名对应的账户。',
genericError: '登录失败,请稍后再试。',
serviceUnavailable: '账户服务暂时不可用,请稍后再试。',
disclaimer: '此演示登录仅会在浏览期间保留用户名,以便展示个性化的导航体验。',
disclaimer: '此 Guest user演示模式) 登录仅会在浏览期间保留用户名,以便展示个性化的导航体验。',
},
termsTitle: '服务条款',
termsPoints: [

View File

@ -65,6 +65,16 @@ const KNOWN_ROLE_MAP: Record<string, UserRole> = {
member: 'user',
}
const GUEST_SESSION_STORAGE_KEY = 'xcontrol.guest.session'
const GUEST_SESSION_TTL_MS = 60 * 60 * 1000
const GUEST_SANDBOX_TENANT_ID = 'guest-sandbox'
const GUEST_SANDBOX_TENANT_NAME = 'Guest Sandbox'
type GuestSession = {
uuid: string
issuedAt: number
}
function normalizeRole(input?: string | null): UserRole {
if (!input || typeof input !== 'string') {
return 'guest'
@ -78,6 +88,92 @@ function normalizeRole(input?: string | null): UserRole {
return KNOWN_ROLE_MAP[normalized] ?? 'guest'
}
function createUUID(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
function readGuestSession(): GuestSession | null {
if (typeof window === 'undefined') {
return null
}
const raw = window.sessionStorage.getItem(GUEST_SESSION_STORAGE_KEY)
if (!raw) {
return null
}
try {
const parsed = JSON.parse(raw) as GuestSession
if (
typeof parsed?.uuid === 'string' &&
parsed.uuid.trim().length > 0 &&
typeof parsed?.issuedAt === 'number' &&
Number.isFinite(parsed.issuedAt)
) {
return {
uuid: parsed.uuid.trim(),
issuedAt: parsed.issuedAt,
}
}
} catch (error) {
console.warn('Failed to parse guest session payload', error)
}
return null
}
function writeGuestSession(session: GuestSession) {
if (typeof window === 'undefined') {
return
}
window.sessionStorage.setItem(GUEST_SESSION_STORAGE_KEY, JSON.stringify(session))
}
function resolveGuestUUID(now = Date.now()): string {
const existing = readGuestSession()
if (!existing || now - existing.issuedAt >= GUEST_SESSION_TTL_MS) {
const next: GuestSession = { uuid: createUUID(), issuedAt: now }
writeGuestSession(next)
return next.uuid
}
return existing.uuid
}
function buildGuestUser(): User {
const identifier = resolveGuestUUID()
return {
id: identifier,
uuid: identifier,
email: 'guest@sandbox.local',
name: 'Guest user',
username: 'guest',
mfaEnabled: false,
mfaPending: false,
mfa: {
totpEnabled: false,
totpPending: false,
},
role: 'guest',
groups: ['guest', 'sandbox'],
permissions: ['read'],
isGuest: true,
isUser: false,
isOperator: false,
isAdmin: false,
isReadOnly: true,
tenantId: GUEST_SANDBOX_TENANT_ID,
tenants: [
{
id: GUEST_SANDBOX_TENANT_ID,
name: GUEST_SANDBOX_TENANT_NAME,
role: 'guest',
},
],
}
}
async function fetchSessionUser(): Promise<User | null> {
try {
const response = await fetch('/api/auth/session', {
@ -89,7 +185,7 @@ async function fetchSessionUser(): Promise<User | null> {
})
if (!response.ok) {
return null
return buildGuestUser()
}
const payload = (await response.json()) as {
@ -121,7 +217,7 @@ async function fetchSessionUser(): Promise<User | null> {
const sessionUser = payload?.user
if (!sessionUser) {
return null
return buildGuestUser()
}
const { id, uuid, email, name, username, mfaEnabled, mfa, mfaPending, role, groups, permissions } = sessionUser
@ -133,7 +229,7 @@ async function fetchSessionUser(): Promise<User | null> {
: ''
if (!identifier) {
return null
return buildGuestUser()
}
const normalizedName = typeof name === 'string' && name.trim().length > 0 ? name.trim() : undefined
const normalizedUsername =
@ -224,6 +320,18 @@ async function fetchSessionUser(): Promise<User | null> {
.filter((tenant): tenant is TenantMembership => Boolean(tenant))
: undefined
const effectiveTenantId = normalizedRole === 'guest' ? GUEST_SANDBOX_TENANT_ID : normalizedTenantId
const effectiveTenants =
normalizedRole === 'guest'
? [
{
id: GUEST_SANDBOX_TENANT_ID,
name: GUEST_SANDBOX_TENANT_NAME,
role: 'guest' as UserRole,
},
]
: normalizedTenants
return {
id: identifier,
uuid: identifier,
@ -242,13 +350,13 @@ async function fetchSessionUser(): Promise<User | null> {
isUser: normalizedRole === 'user',
isOperator: normalizedRole === 'operator',
isAdmin: normalizedRole === 'admin',
isReadOnly: normalizedReadOnly,
tenantId: normalizedTenantId,
tenants: normalizedTenants,
isReadOnly: normalizedRole === 'guest' ? true : normalizedReadOnly,
tenantId: effectiveTenantId,
tenants: effectiveTenants,
}
} catch (error) {
console.warn('Failed to resolve user session', error)
return null
return buildGuestUser()
}
}