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 的储存机制并没有一定。

Terminal window
npm install express express-session
index.js
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 中,如果用户再次访问时会增加次数。

登入系统

  1. 注册页面 /register:用户输入帐号和密码,伺服器验证帐号是否已存在,若不存在则注册成功。
  2. 登入页面 /login:使用者输入帐号和密码。
  3. 登入处理 /login:伺服器验证帐密,若正确则在 session 中设置 isLoggedIn 状态和 username
  4. 个人资料页面 /profile:根据是否登入来决定是否显示用户资料还是跳转到登入页。
  5. 登出 /logout:销毁 session 并清除 cookie。
index.js
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🔗 上,可以下载下来玩玩看。

安全最佳实践

密码加密

密码应储存为加密形式(例如使用 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 默认帮你生成一个随机的「盐」并附加在密码后面,这样即使两个用户密码相同,哈希值也不会相同,提高了破解难度。

延伸阅读