Featured image of post SvelteKit 认证竞速:当你登录成功但应用却不认账

SvelteKit 认证竞速:当你登录成功但应用却不认账

你是否遇到过明明登录成功,应用却还把你当陌生人?本文讲述我是如何追踪一个让认证用户凭空消失的隐蔽竞态条件的。

你是否有过这种感觉:明明觉得自己功能写得没毛病,但它却总是以最让人抓狂的方式出错?

我刚刚花了好几个星期,精心打造了一个自认为完美的认证系统。现代、安全,使用了 httpOnly cookie——应有尽有。登录后,后端会愉快地返回 200,浏览器也会乖乖存下会话 cookie。一切看起来都很完美。

但当我跳转到仪表盘时,系统却像对待陌生人一样迎接我。没有用户数据,没有案件,什么都没有。原本应该显示认证内容的地方变成了一片荒原。

最让人崩溃的是?有时候刷新页面就神奇地恢复了,有时候却不行。我的应用变成了老虎机,而我疯狂地拉着刷新杆,理智却在逐渐流失。

这就是我追踪那个幽灵的故事——一个隐蔽到极致的竞态条件,让我不得不深入 SvelteKit 生命周期的核心,才终于明白到底发生了什么。

场景搭建:纸面上的完美遇上现实

让我给你描绘一下本该没问题的流程。我们的认证流程堪称教科书级:

第1步: 用户填写登录表单
第2步: SvelteKit 服务器将凭证发送给后端
第3步: 后端验证并返回会话 token
第4步: SvelteKit 服务器设置 httpOnly cookie(安全,JavaScript 无法访问)
第5步: 后续每个请求都通过服务器 hook 验证会话
第6步: 用户数据通过 layout 流向各组件

你可以把它想象成一栋安全大楼,有个聪明的门卫。你在前台出示身份证,前台把门禁卡寄给门卫,门卫本该让你进入任何你需要的房间。

但问题就出在这里:我总是比门禁卡处理完毕还早到达房间。门卫一脸茫然地说:“抱歉,查无此卡。”

第一回合:“肯定是客户端的问题”

我的第一反应是怪客户端。通常都是客户端惹的祸,对吧?仪表盘页面肯定是在认证系统就绪前就尝试获取数据了。

于是我写了一个自认为很聪明的“认证就绪”门控:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 我在 src/lib/utils/auth-client.ts 的第一次尝试
let authReadyPromise: Promise<void>;
let resolveAuthReady: () => void;

// 创建一个等待认证就绪的 promise
authReadyPromise = new Promise((resolve) => {
    resolveAuthReady = resolve;
});

export function awaitAuthReady(): Promise<void> {
    return authReadyPromise;
}

export function initializeApiAuth(sessionToken: string | null) {
    // 用 token 设置 API 客户端
    if (sessionToken) {
        apiClient.setAuthToken(sessionToken);
    }
    resolveAuthReady(); // 通知认证已就绪
}

在仪表盘页面:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<!-- +page.svelte 中的“等待认证”模式 -->
<script>
    import { awaitAuthReady } from '$lib/utils/auth-client';
    import { onMount } from 'svelte';

    onMount(async () => {
        // 等待认证系统就绪
        await awaitAuthReady();
        await loadCases();
    });
</script>

这看起来很对。典型的竞态条件解决方案。但它没用。

日志揭示了残酷的真相:客户端确实在耐心等待 awaitAuthReady(),但 promise 解析时 session token 还是 null。问题不是客户端太着急,而是门禁卡根本没送到门卫手里。

第二回合:“也许是 SvelteKit 路由器的问题”

好吧,客户端登录后没拿到最新数据。这明显像是 SvelteKit 路由缓存的问题。enhance 函数本想聪明地优化更新,但有时聪明反被聪明误。

官方文档建议在这种场景下用 invalidateAll()。这就像对 SvelteKit 说:“别耍小聪明了,全部重载!”

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<!-- 在 auth/+page.svelte 中 -->
<script>
    import { invalidateAll } from '$app/navigation';
    import { enhance } from '$app/forms';

    const loginEnhance = enhance(() => {
        return async ({ result }) => {
            if (result.type === 'redirect') {
                // 强制下个页面完全刷新数据
                await invalidateAll();
            }
        };
    });
</script>

<form method="POST" use:loginEnhance>
    <!-- 表单字段 -->
</form>

这次真的有效了!算是吧。登录流程现在成功了,但那个神秘的刷新问题依然存在。偶尔还是会遇到“幽灵登出”,应用忘了我是谁,直到我手动刷新。

这是一个关键线索。它证明 cookie 设置没问题,但服务器处理认证状态的方式还存在更深层的问题。

不能再靠猜了,该当侦探了。我在整个认证流程里,从服务器 hook 到客户端组件,到处加了 console 日志。我需要看到 session token 究竟消失在哪一步。

关键线索出现在登录成功后的服务器日志里:

1
2
3
4
🔵 [Hook] 找到 session cookie: abc123...
🔵 [Hook] 正在向后端验证 session...
🔵 [Hook] 会话验证失败。用户已登出。
🔵 [Hook] Session cookie 已删除。

浏览器端:

1
2
🔵 [Layout] 收到 session token: null
🔵 [Dashboard] 没有 session token,显示登录提示

真相大白!服务器 hook 能找到 cookie,但向后端验证时失败了。我的代码太激进了——任何验证失败都会直接销毁整个会话。

我的服务器 hook 是这样的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// hooks.server.ts 里过于激进的版本
export const handle: Handle = async ({ event, resolve }) => {
    const sessionId = event.cookies.get('session');

    if (sessionId) {
        const user = await validateSession(sessionId);
        
        if (user) {
            event.locals.user = user;
            event.locals.isAuthenticated = true;
        } else {
            // 💥 问题所在:太激进了!
            // 任何验证失败都直接销毁 session
            event.cookies.delete('session');
            event.locals.user = null;
            event.locals.isAuthenticated = false;
        }
    }

    return resolve(event);
};

门卫不仅没处理好门禁卡,甚至一有风吹草动就把卡扔进了垃圾桶。SvelteKit 服务器和后端之间哪怕短暂的网络抖动,也会导致用户永久登出。

第四回合:让服务器更宽容

看到这里,修复方法就很明显了:别再一出错就删 session。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// hooks.server.ts 里更健壮的版本
export const handle: Handle = async ({ event, resolve }) => {
    const sessionId = event.cookies.get('session');

    if (sessionId) {
        const user = await validateSession(sessionId);
        
        if (user) {
            event.locals.user = user;
            event.locals.isAuthenticated = true;
        } else {
            // 💡 修复点:验证失败时不要删除 cookie
            // 只把本次请求标记为未认证即可
            event.locals.user = null;
            event.locals.isAuthenticated = false;
        }
    } else {
        event.locals.user = null;
        event.locals.isAuthenticated = false;
    }

    return resolve(event);
};

这大大提升了健壮性。现在 session cookie 能在网络抖动时幸存下来。但还有最后一个幽灵——登录后应用有时还是会显示“未认证”,而手动刷新总能恢复。

第五回合:最终 Boss——理解水合时机

这是最隐晦的 bug。已知如下:

✅ 服务器正确设置了 cookie
✅ 服务器 hook 正确验证了 cookie
✅ layout 能从服务器拿到用户数据
✅ 手动刷新总能恢复

那为什么仪表盘还是会把我当成未登录?

答案就在于理解 命令式响应式 代码的区别。我的仪表盘用 onMount 判断是否加载数据:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<!-- 依赖时机的写法 -->
<script>
    import { onMount } from 'svelte';
    import { sessionStore } from '$lib/stores/auth';

    onMount(() => {
        // 组件挂载时只执行一次
        if ($sessionStore) {
            loadCases();
        }
    });
</script>

你可能会想:“这有啥问题?不是登录后才加载数据吗?”

但实际时间线是这样的:

0ms: 用户点击登录,SvelteKit 开始跳转到仪表盘
1ms: 仪表盘组件被创建
2ms: 仪表盘的 onMount 执行,检查 $sessionStore(此时还是 null),决定不加载数据
5ms: layout 组件从服务器拿到新用户数据
6ms: layout 更新 $sessionStore,填入真实用户数据
7ms: UI 头部显示用户名,但仪表盘的 onMount 早就做了决定

就像新员工第一天报到,经理的工牌还在桌上,员工却直接冲去实验室门口,结果被门禁拒之门外,心灰意冷。即使经理随后拿着工牌出来,也晚了——员工已经认定自己没权限。

解决方案:拥抱响应式

解决方法就是:别再用“某个时机做一次”的命令式思维,而要用“状态变化时自动响应”的响应式思维。

SvelteKit 的响应式系统让这一切变得优雅:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<!-- 响应式写法 -->
<script>
    import { sessionStore } from '$lib/stores/auth';
    
    let hasLoadedCases = $state(false);

    // 这不是一次性函数,而是对状态变化的反应
    $effect(() => {
        if ($sessionStore && !hasLoadedCases) {
            hasLoadedCases = true;
            loadCases();
        } else if (!$sessionStore) {
            // 用户登出时重置
            hasLoadedCases = false;
        }
    });
</script>

这段代码讲述了完全不同的故事:“我不关心挂载时机,我只关心 session 状态。只要 $sessionStore 有用户,就加载数据。”

总结与收获

这次调试之旅让我收获了三条宝贵经验:

1. 服务器健壮性至关重要
你的服务器 hook 是应用的守门员。它们要能抵御短暂的错误。偶尔的网络抖动不该导致用户 session 被清空。

2. 竞态条件喜欢命令式代码
只要你写下“某个时机做一次”,就容易踩中时序陷阱。现代框架给了我们响应式工具——一定要用起来。

3. 用“状态”思考,而不是“事件”
不要想着“组件挂载时检查是否认证”,而要想着“每当认证状态变化时,自动更新 UI”。这样能彻底消灭一大类时序 bug。

幽灵终于消失了。用户现在可以登录后立刻看到自己的数据,无论网络如何抖动、水合如何延迟,认证流程都安全且健壮。

你在 SvelteKit 应用中遇到过类似的时序问题吗?欢迎在评论区分享你的调试故事。有时候,最隐蔽的 bug 反而最能让我们理解工具的本质。