Express会话ID请求变更及Safari IPT拦截问题求助
我基于Express开发简易登录系统,登录页向服务器发请求后,服务器通过express-session创建会话,但登录后主页面检查会话状态时,始终显示未登录,且两次请求的sessionID不同。已配置CORS {credentials: true, origin: true} 和fetch的 credentials: "include",问题仍存在。排查后确认是Safari的Intelligent Tracking Prevention(ITP)导致,关闭ITP后功能正常,但不能要求所有用户关闭该设置,求解决方法。
相关代码
Express服务器代码
const express = require("express"); const app = express(); const cors = require("cors"); const session = require("express-session"); const crypto = require("crypto"); const bcrypt = require("bcrypt"); const fs = require("fs/promises"); const process = require("process"); const usersFilePath = "./users.json"; app.use(express.json()); app.use(cors({credentials: true, origin: true})); app.use( session({ name: "app.sid", secret: crypto.randomBytes(32).toString("hex"), resave: false, saveUninitialized: true, cookie: {secure: false, sameSite: "none"}, store: new session.MemoryStore() }) ) let users = []; async function loadUsers() { try { const data = await fs.readFile(usersFilePath); users = JSON.parse(data); console.log("Found existing user data, loading..."); } catch (error) { users = []; console.log("Could not find existing user data") } } async function saveUsers() { await fs.writeFile(usersFilePath, JSON.stringify(users, null, 2)); } app.get("/users", (req, res) => { res.json(users); }); app.post("/users/register", async (req, res) => { const existingUser = users.find((user) => user.name === req.body.name); if (existingUser != null) { return res.status(400).send("User already exists"); } try { const hashedPassword = await bcrypt.hash(req.body.password, 10); const user = { name: req.body.name, password: hashedPassword }; users.push(user); await saveUsers(); res.status(201).send(); } catch (error) { res.status(500).send("error LOL"); } }); app.post("/users/login", async (req, res) => { const user = users.find((user) => user.name === req.body.name); if (user == null) { return res.status(400).send("User does not exist"); } try { if (await bcrypt.compare(req.body.password, user.password)) { req.session.user = user; console.log("Logged in user: " + req.session.user.name); console.log("sessionID: " + req.sessionID); res.status(200).json({sessionID: req.sessionID}); } else { res.send("Wrong Password!"); } } catch (error) { res.status(500).send(error); } }); app.post("/users/logout", (req, res) => { req.session.destroy((err) => { if (err) { res.status(500).send("Logout failed"); } else { res.status(200).send("Logout successful"); } }) }); app.get("/users/status", (req, res) => { console.log("User: " + req.session.user); console.log("sessionID: " + req.sessionID); if (req.session.user) { res.json({loggedIn: true, username: req.session.user.name}); } else { res.json({loggedIn: false, sessionID: req.sessionID}); console.log("User not logged in") } }) // Load existing users when the server starts loadUsers().then(() => { const server = app.listen(3000, () => { console.log("Server is running on port 3000"); }); });
登录页面脚本
document.getElementById('loginForm').addEventListener('submit', async function (event) { event.preventDefault(); const username = document.getElementById('username').value; const password = document.getElementById('password').value; try { const response = await fetch('http://localhost:3000/users/login/', { method: 'POST', credentials: "include", headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ name: username, password: password }), }); const data = await response.json(); console.log("SessionID: " + data.sessionID); // setTimeout(() => { // window.open("../index.html", "_self"); // }, 1); } catch (error) { console.error('Error during login:', error); } });
登录状态检查脚本
async function checkLoginStatus() { try { const response = await fetch('http://localhost:3000/users/status', { method: "GET", credentials: "include", headers: { "Content-Type": "application/json" }, }); const data = await response.json(); console.log("SessionID: " + data.sessionID); const info = document.getElementById('userInfo'); const button = document.getElementById('logoutButton'); if (data.loggedIn) { // User is logged in info.innerText = `Logged in as ${data.username}`; button.style.display = 'Log out'; button.onclick = "logout()"; } else { // User is not logged in info.innerText = 'Not logged in'; button.innerText = "Log in"; button.onclick = () => window.open("auth/login.html", "_self"); } } catch (error) { console.error('Error checking login status:', error); } } async function logout() { try { const response = await fetch('http://localhost:3000/users/logout', { method: 'POST' }); if (response.status === 200) { // User successfully logged out checkLoginStatus(); // Update UI } else { console.error('Logout failed'); } } catch (error) { console.error('Error during logout:', error); } } checkLoginStatus();
1. 修正Session Cookie的SameSite与Secure属性
ITP对跨站Cookie限制严格,sameSite: "none"必须配合secure: true生效,因为SameSite=None的Cookie仅允许在HTTPS环境下传输。开发环境可通过ngrok等工具模拟HTTPS,生产环境必须启用HTTPS。
修改session配置中的cookie部分:
cookie: { secure: process.env.NODE_ENV === "production", // 生产环境强制开启secure sameSite: process.env.NODE_ENV === "production" ? "none" : "lax", httpOnly: true, // 禁止前端脚本访问Cookie,降低ITP跟踪识别概率 maxAge: 24 * 60 * 60 * 1000 // 设置明确过期时间,避免被ITP标记为无期限会话清理 }
2. 禁用saveUninitialized
当前saveUninitialized: true会在用户未登录时创建空会话,这类空会话更容易被ITP判定为跟踪Cookie。改为仅在用户登录成功后创建会话:
session({ // ...其他配置 saveUninitialized: false, // ... })
3. 改用Token-Based认证替代Session
ITP主要限制第三方Cookie,改用JWT Token认证,将Token存储在localStorage或sessionStorage中,请求时通过请求头携带,完全绕开Cookie限制。
示例修改:
- 后端登录接口返回JWT:
// 先安装依赖:npm install jsonwebtoken const jwt = require("jsonwebtoken"); app.post("/users/login", async (req, res) => { // ...用户验证逻辑 if (await bcrypt.compare(req.body.password, user.password)) { const token = jwt.sign({ name: user.name }, process.env.JWT_SECRET, { expiresIn: "1d" }); res.status(200).json({ token }); } else { res.send("Wrong Password!"); } })
- 前端存储并携带Token:
// 登录成功后存储Token localStorage.setItem("authToken", data.token); // 检查状态时携带Token async function checkLoginStatus() { const token = localStorage.getItem("authToken"); const response = await fetch('http://localhost:3000/users/status', { method: "GET", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` }, }); // ...后续逻辑 }
- 后端验证Token中间件:
function authenticateToken(req, res, next) { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; if (!token) return res.sendStatus(401); jwt.verify(token, process.env.JWT_SECRET, (err, user) => { if (err) return res.sendStatus(403); req.user = user; next(); }); } app.get("/users/status", authenticateToken, (req, res) => { res.json({ loggedIn: true, username: req.user.name }); })
4. 配置Cookie的Domain属性(跨域场景)
若前后端域名不同,将Cookie的domain设置为父域名,让子域名共享Cookie,减少ITP的跨站识别概率。例如后端为api.example.com,前端为app.example.com:
cookie: { domain: ".example.com", // ...其他配置 }
5. 改用持久化Session存储
当前使用MemoryStore,服务器重启后会话丢失,且ITP对临时存储的会话容忍度低。改用connect-redis等持久化存储:
// 安装依赖:npm install connect-redis redis const RedisStore = require("connect-redis").default; const { createClient } = require("redis"); const redisClient = createClient({ url: "redis://localhost:6379" }); redisClient.connect().catch(console.error); app.use(session({ // ...其他配置 store: new RedisStore({ client: redisClient }), }))
内容的提问来源于stack exchange,提问作者Cric_Hammel

