Build a Membership System with Express.js Session
使用 Express.js Session 实作会员系统
前言
根据先前的教学,我们已经可以创建基本的 CRUD 程式,但基于 HTTP 是无状态的协议,如果单纯实作会员系统,用户将会需要在每次操作登入会员相关的行为时重复验证,明显是糟糕的使用体验。
一种解决方案是在请求时伺服器将会建立 Session (存储在伺服器端的一笔数据),而用户会从回应拿到一组 session ID,通常存放于 Cookie 当中,伺服器透过这个 ID 来识别和定位特定用户的 session 资料,这样一来伺服器就能记住个别用户相关的对应资料。
Session 储存
具体来说 express-session 是常用的套件帮助我们实践伺服器 Session。建议阅读官方文件进一步设置,像是:
name
: 可自订 cookie 名称,避免使用预设的connect.sid
cookie.httpOnly
: 预设为true
,但建议明确说明cookie.sameSite
: 建议设为strict
增加安全性- …
最小范例
根据 Express.js 入门 创建一个简单的后端伺服器,并且安装express-session
协助我们建构 Session 记忆用户,预设会储存在记忆体中,实际可能会连结到文件系统、资料库或分散式储存,Session 的储存机制并没有一定。
npm install express express-session
import express from 'express';import session from 'express-session';
const app = express();
app.use( session({ secret: 'secret-key', resave: false, saveUninitialized: true, cookie: { secure: false }, }),);
app.get('/', (req, res) => { if (!req.session.views) { req.session.views = 1; } else { req.session.views++; } res.send(`你已经访问这个页面 ${req.session.views} 次`);});
app.listen(3000, () => console.log('伺服器运行在 http://localhost:3000'));
如上就是一个最小范例,当用户访问首页时会计算用户访问次数,并且将次数存放在 session 中,如果用户再次访问时会增加次数。
登入系统
- 注册页面
/register
:用户输入帐号和密码,伺服器验证帐号是否已存在,若不存在则注册成功。 - 登入页面
/login
:使用者输入帐号和密码。 - 登入处理
/login
:伺服器验证帐密,若正确则在 session 中设置isLoggedIn
状态和username
。 - 个人资料页面
/profile
:根据是否登入来决定是否显示用户资料还是跳转到登入页。 - 登出
/logout
:销毁 session 并清除 cookie。
import express from 'express';import session from 'express-session';import bodyParser from 'body-parser';
const app = express();const PORT = 3000;
// 使用 body-parser 解析 POST 请求的资料app.use(bodyParser.urlencoded({ extended: true }));
app.use( session({ secret: 'my-secret-key', resave: false, saveUninitialized: true, cookie: { secure: false }, }),);
const users = [];
app.get('/register', (req, res) => { res.send(` <h1>注册</h1> <form method="POST" action="/register"> <label>使用者名称: <input type="text" name="username" required /></label><br/> <label>密码: <input type="password" name="password" required /></label><br/> <button type="submit">註冊</button> </form> `);});
app.post('/register', (req, res) => { const { username, password } = req.body;
const existingUser = users.find((user) => user.username === username); if (existingUser) { return res.send('<h1>注册失败:使用者名称已存在。 </h1><a href="/register">返回注册页面</a>'); }
users.push({ username, password }); res.send('<h1>注册成功! </h1><a href="/login">前往登入页面</a>');});
app.get('/login', (req, res) => { res.send(` <h1>登入</h1> <form method="POST" action="/login"> <label>使用者名称: <input type="text" name="username" required /></label><br/> <label>密码: <input type="password" name="password" required /></label><br/> <button type="submit">登入</button> </form> `);});
app.post('/login', (req, res) => { const { username, password } = req.body;
const user = users.find((u) => u.username === username && u.password === password); if (user) { req.session.isLoggedIn = true; req.session.username = username; res.send(`<h1>登入成功!欢迎, ${username}!</h1><a href="/profile">查看个人资料</a>`); } else { res.send('<h1>登入失败!帐号或密码错误。</h1><a href="/login">回到登入页面</a>'); }});
app.get('/profile', (req, res) => { if (req.session.isLoggedIn) { res.send(`<h1>个人资料</h1><p>欢迎, ${req.session.username}! </p><a href="/logout">登出</a>`); } else { res.redirect('/login'); }});
app.get('/logout', (req, res) => { req.session.destroy((err) => { if (err) { return res.send('登出失败'); } res.clearCookie('connect.sid'); res.send('<h1>登出成功!</h1><a href="/login">重新登入</a>'); });});
app.listen(PORT, () => { console.log(`伺服器运行在 http://localhost:${PORT}`);});
我将专案放到 GitHub - express-in-memory-session 上,可以下载下来玩玩看。
安全最佳实践
- 使用 HTTPS:加密传输过程中的 session ID,避免中间人攻击。
- 设定HttpOnly 和[Secure Cookie](https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/Cookies# secure):防止 JavaScript 读取 session ID,并确保仅 HTTPS 传输。
- Session 过期时间:设置合理的 session 过期时间,例如 15 分钟未操作后过期。
- 重新生成 Session ID:在登入和登出时重新生成 session ID,防止 Session Fixation 攻击。
密码加密
密码应储存为加密形式(例如使用 bcrypt),通过哈希算法将密码转换为不可逆的数据,未来验证时也同样将用户输入的密码进行哈希后再进行比对,如此一来就算密码数据库外泄也不会直接暴露用户密码。
const hashedPassword = await bcrypt.hash(password, 10);users.push({ username, password: hashedPassword });
const targetUser = users.find((user) => user.username === username);const isValid = await bcrypt.compare(password, targetUser.password);
为了保护哈希值的唯一性,bcrypt 默认帮你生成一个随机的「盐」并附加在密码后面,这样即使两个用户密码相同,哈希值也不会相同,提高了破解难度。
延伸阅读
- Your complete guide to understanding the express-session library - Zach Gollwitzer
- HTTP Session 攻擊與防護 - DEVCORE Allen Own
- 寫給網頁開發者的 CSRF 理解與防範 - WebDong