主题
session与记账本项目增加注册登录功能(Day13 3/3)
本页对应 Day13_Node&会话控制,共 6 段视频,约 53 分钟。
学习目标
- 能够将登录页表单配置为
POST /users/login,正确设置用户名和密码字段的name属性,并实现登录页与注册页互相跳转。 - 能够使用表单用户名与 MD5 加密后的密码查询 MongoDB 用户记录,区分查询异常、用户不存在和登录成功三种情况,并返回对应提示或页面。
- 能够配置
express-session与connect-mongodb-session中间件,将登录用户的用户名和用户 ID 写入 session,并通过 Cookie 与 MongoDB session 集合验证登录状态被记录。 - 能够编写并挂载全局登录验证中间件,对需要保护的账单路由进行拦截:未登录时重定向到登录页,已登录时调用
next()放行。 - 能够从 session 读取用户名并渲染到账单首页,同时实现退出登录路由,通过销毁当前会话使用户无法再访问受保护页面。
- 能够为账单模型增加用户 ID 字段,在添加账单时写入当前 session 中的用户 ID,并在查询账单时按用户 ID 过滤,验证不同用户只能看到各自账单。
知识点
登录表单与登录路由
为什么需要:登录必须先有入口表单,否则后端不知道从哪里拿用户名和密码。怎么想:先把登录页的 form 补完整,再让提交地址进入 users 路由。怎么做:转写稿中把表单改为 action="/users/login"、method="post",并给两个输入框补 name:
html
<form action="/users/login" method="post">
<input type="text" name="userName">
<input type="password" name="userPWD">
<button>登录</button>
</form>同时把“注册”文字改成“登录”,并在按钮下加“还没有账号,请注册”,链接到 /users/reg。易错点:name 不写或写错,req.body 里就取不到值;登录和注册页面要能互相跳转,别把链接写死。
执行登录与密码比对
为什么需要:表单拿到的是明文密码,数据库里存的是加密后的密码,直接比对一定失败。怎么想:先打印 req.body 确认字段,再按用户名和加密后的密码查用户。怎么做:转写稿中先 console.log(req.body),看到 userName、userPWD 都是明文,于是查询时写成:
js
const { userName, userPWD } = req.body;
Account.findOne(
{ userName, userPWD: md5(userPWD) },
(err, data) => {
if (err) return res.status(500).send('未知错误');
if (!data) {
return res.render('error', { msg: '用户名或密码错误,请重新登录' });
}
// 登录成功
}
);易错点:err 表示数据库异常,data 为空表示查不到用户,两者要分开;不要只判断 data 就把数据库错误也当成密码错误。
把登录状态存入 session
为什么需要:登录成功不能只渲染一次成功页,还要让后续请求记住“你已经登录过”。怎么想:登录成功后把用户名和用户 ID 存到 session,浏览器端由 cookie 保存 session ID。怎么做:转写稿中在成功分支写:
js
req.session.userName = data.userName;
req.session.userId = data._id;
res.render('success', { msg: '登录成功', url: '/account' });项目入口要接入 express-session 中间件,并用 connect-mongoDB-store 把 session 存到 MongoDB:
js
app.use(session({
secret: '按项目配置',
resave: false,
saveUninitialized: false,
cookie: { maxAge: 7 * 24 * 60 * 60 * 1000 },
store: new MongoDBStore({
url: 'mongodb://127.0.0.1:27017/你的库名',
collection: 'sessions'
})
}));易错点:session 在 req 上,不在 res 上;存储模块要单独指定 MongoDB 地址和集合,不能以为之前查账户连过库就自动复用。
全局登录验证中间件
为什么需要:账单相关页面不能未登录就访问,否则不知道账单属于谁。怎么想:把验证逻辑抽成中间件,只加在需要验证的 account 路由上。怎么做:新建 middlewares/checkLogin.js:
js
module.exports = (req, res, next) => {
if (req.session.userName) return next();
res.redirect('/users/login');
};然后在账单列表、添加账单的 GET/POST、删除账单等路由前挂上它。易错点:不要把这个中间件放在所有路由前面,否则登录、注册页也会被拦住;验证时看 session 里有没有用户信息,不能只看浏览器有没有 cookie。
页面显示用户信息与退出登录
为什么需要:登录后首页应显示当前用户,并提供退出入口。怎么想:账单首页渲染时把 req.session.userName 传给模板,模板里输出用户名和退出链接。怎么做:路由中传 userName: req.session.userName,模板里显示“您好,xxx”,退出链接指向 /users/logout。退出登录时销毁 session:
js
req.session.destroy(() => {
res.render('success', { msg: '退出登录', url: '/users/login' });
});易错点:销毁 session 只影响发请求的浏览器;退出后再访问账单页应被中间件重定向回登录页。
账单与用户绑定
为什么需要:多个用户登录后,账单不能混在一起。怎么想:账单文档增加 userId,添加时从 session 取用户 ID,查询时只查当前用户。怎么做:账户模型加:
js
userId: mongoose.Schema.Types.ObjectId添加账单时合并请求体和用户 ID:
js
Account.create({ ...req.body, userId: req.session.userId });查询时写:
js
Account.find({ userId: req.session.userId }, (err, data) => {
res.render('account/index', {
data: data.reverse(),
userName: req.session.userName
});
});易错点:ObjectId 不能用普通字符串类型声明;旧账单没有 userId 会混数据,转写稿中是清空集合后重新添加来调试;查询条件漏掉 userId 就会看到别人的账单。
4 记账本项目增加注册登录功能
本节在记账本项目上增加注册、登录、退出、登录验证,并把账单与用户绑定。整体思路是:先定义用户相关路由,再把用户存入数据库;登录成功后把用户信息写入 session;需要保护的路由加登录验证中间件;账单写入和查询都带上当前用户 ID。
用户路由
为什么需要:注册、登录、退出都要有明确的 URL 和请求方式,前端表单才能提交到对应处理逻辑。怎么想:页面用 GET,提交数据用 POST;退出登录用 GET 即可。怎么做:本节笔记给出的路由如下:
1. 用户路由
① 注册页面
GET /users/reg
② 执行注册
POST /users/reg
③ 登录页面
GET /users/login
③ 执行登录
POST /users/login
④ 退出登录
GET /users/logout易错点:登录页表单的 action 要写 /users/login,method 必须是 post;用户名、密码输入框要加 name,转写稿中分别使用 username 和 userPWD。注册页和登录页之间要能互相跳转,例如“没有账号,请注册”链接到 /users/reg。
注册用户
为什么需要:系统要识别不同用户,必须先保存用户信息。怎么想:创建 users 集合,定义 schema 和 model;用户名唯一,密码不能明文存。怎么做:把用户信息添加到集合中,密码经过 md5 加密后再存入数据库。易错点:用户名重复时不能注册成功;注册时写入的密码必须是加密后的值,否则登录比对会对不上。
登录
为什么需要:登录要验证用户提交的账号密码是否真实存在,并记住登录状态。怎么想:先获取表单数据,再按用户名和加密后的密码去数据库查询。转写稿中的调试过程是:先打印 req.body,看到 username、userPWD 是明文;而数据库里存的是 md5 后的密码,所以查询条件里 userPWD 也要先 md5。怎么做:用 findOne 查询;回调里要区分 error 和 data。error 表示数据库异常,返回 500;data 为空表示用户名或密码错误,渲染错误模板并跳回登录页;data 有值表示登录成功。登录成功后把用户信息存入 session:
js
req.session.username = data.username;
req.session.userId = data._id;易错点:session 在 req 上,不在 res 上;项目要在入口引入并配置 express-session 中间件。转写稿中还将 session 存到 MongoDB,使用 connect-mongodb-session,配置 store 时指定 MongoDB 地址和集合名;maxAge 可控制登录状态有效期。注意查询不到用户不是数据库异常,data 为空即可判断登录失败。
全局登录验证
为什么需要:账单页、添加账单、删除账单等必须登录后才能访问,否则不知道数据属于哪个用户。怎么想:把需要登录的路由列出来:GET /account、GET /account/create、POST /account/create、GET /account/delete/:id;注册、登录、退出相关路由不需要登录。怎么做:单独写 middlewares/checkLogin.js,判断 req.session.username 是否存在:存在执行 next(),不存在 res.redirect('/users/login')。然后在 account 路由前加这个中间件。易错点:中间件不能随便加到所有路由前面,否则注册登录也会被拦截;中间件必须放在需要保护的路由方法中,并且判断的是 session 中确实存过的用户信息。
页面显示用户信息与退出登录
为什么需要:登录后首页应显示当前用户,并提供退出入口。怎么做:在 /account 渲染模板时多传一个 username: req.session.username,模板中显示“您好,用户名”,并加退出链接 /users/logout。退出登录时销毁当前 session:
js
req.session.destroy(function (err) {
// 渲染退出成功页,并跳转登录页
});易错点:销毁的是发请求浏览器的 session,不会销毁别人的;退出后再访问账单页应被中间件重定向到登录页。
账单与用户绑定
为什么需要:不同用户的账单不能混在一起。怎么想:账单集合每个文档增加 userId,添加账单时写入当前用户 ID,查询时只查当前用户 ID。怎么做:在 accounts schema 中增加:
js
userId: {
type: mongoose.Schema.Types.ObjectId
}添加账单时把请求体和 session 中的用户 ID 合并:
js
{ ...req.body, userId: req.session.userId }查询时使用:
js
Account.find({ userId: req.session.userId })易错点:ObjectId 要写 mongoose.Schema.Types.ObjectId;添加和查询都要从 req.session.userId 取,大小写要一致;转写稿中曾把 session 里的字段写错导致查询报错,改回后只显示当前用户账单。
课堂画面






代码示例
js
// app.js —— 入口文件:引入 session 中间件,把会话存到 MongoDB
const path = require('path');
const express = require('express');
const session = require('express-session');
// 官方推出的、把 session 存进 mongoDB 的模块
const MongoDBStore = require('connect-mongodb-session')(session);
const app = express();
app.set('view engine', 'ejs');
app.set('views', path.resolve(__dirname, 'views'));
// 解析表单请求体,登录时才能拿到 req.body.username / userpwd
app.use(express.urlencoded({ extended: false }));
// 指定 session 存储位置:mongoDB
const store = new MongoDBStore({
uri: 'mongodb://127.0.0.1:27017/project', // 这个模块要单独指定地址和库名
collection: 'sessions' // 会话保存在这个集合里
});
app.use(session({
secret: 'itheima',
resave: false,
saveUninitialized: false,
store: store,
cookie: {
maxAge: 1000 * 60 * 60 * 24 * 7 // 会话有效期:一星期
}
}));
app.use('/users', require('./routes/users'));
app.use('/account', require('./routes/account'));
app.listen(3000, () => {
console.log('服务已启动,端口 3000');
});关键行与易错点
require('connect-mongodb-session')(session):模块导出的是函数,必须传 session 进去调用一次,返回的才是 Store 构造函数。store的uri里要写全mongodb://127.0.0.1:27017/库名——它和之前 mongoose 的连接是两套独立配置,不能复用。- session 中间件必须写在路由之前,否则
req.session是 undefined。 cookie.maxAge决定状态记多久(模拟"一星期免登录"),单位是毫秒,别忘*1000。- 浏览器端只会产生一个
connect.sidcookie,里面存的是 sessionId;服务端重启或 session 被销毁后,浏览器带着 cookie 也拿不到会话。
js
// routes/users.js —— 注册、登录、退出登录
const express = require('express');
const md5 = require('md5');
const UserModel = require('../models/UserModel');
const router = express.Router();
// ① 注册页面
router.get('/reg', (req, res) => {
res.render('reg');
});
// ② 执行注册
router.post('/reg', (req, res) => {
const { username, userpwd } = req.body;
// 密码必须 md5 加密后再入库;用户名在 schema 里设 unique
UserModel.create({ username, userpwd: md5(userpwd) }, (err, data) => {
if (err) {
return res.status(500).send('注册失败,用户名已存在');
}
res.render('success', { msg: '注册成功', url: '/users/login' });
});
});
// ③ 登录页面
router.get('/login', (req, res) => {
res.render('login');
});
// ④ 执行登录
router.post('/login', (req, res) => {
const { username, userpwd } = req.body;
// 表单密码是明文,数据库里是密文,比较前必须先加密
UserModel.findOne({ username, userpwd: md5(userpwd) }, (err, data) => {
if (err) {
return res.status(500).send('未知错误');
}
// err 是"数据库异常",data 为空才是"没这个用户",两种情况要分开判断
if (!data) {
return res.render('error', {
msg: '用户名或密码错误,请重新登录',
url: '/users/login'
});
}
// 登录成功:把用户信息写入 session,记住登录状态
req.session.username = data.username;
req.session.userId = data._id;
res.render('success', { msg: '登录成功', url: '/account' });
});
});
// ⑤ 退出登录
router.get('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) return res.status(500).send('退出失败');
res.render('success', { msg: '退出登录成功', url: '/users/login' });
});
});
module.exports = router;关键行与易错点
- 表单
name要和req.body解构出来的属性名一致(如name="username"、name="userpwd"),否则拿到 undefined。 - 最容易错:直接把明文密码去
findOne,永远查不到。必须md5(userpwd)。 err与data语义不同:err表示数据库操作异常,data为null表示账号密码不对,要分别处理。- session 挂在请求对象
req.session上,不是res.session。 - 存的是
data._id(MongoDB 的 ObjectId),后面查账单要用它做条件。 destroy只销毁当前浏览器对应的那条会话,不会影响别人。
js
// middlewares/checkLogin.js —— 全局登录验证中间件
module.exports = (req, res, next) => {
// 能取到 session 中的用户名,说明登录过
if (req.session.username) {
next(); // 放行,执行后面的回调
} else {
res.redirect('/users/login'); // 没登录,重定向到登录页
}
};js
// routes/account.js —— 账单路由:加上登录验证,账单与用户绑定
const express = require('express');
const AccountModel = require('../models/AccountModel');
const checkLogin = require('../middlewares/checkLogin');
const router = express.Router();
// 首页:只查当前用户的账单
router.get('/', checkLogin, (req, res) => {
AccountModel.find({ userId: req.session.userId })
.sort({ time: -1 })
.exec((err, data) => {
if (err) return res.status(500).send('读取失败');
res.render('index', {
data: data.reverse(),
username: req.session.username // 模板中显示用户名
});
});
});
// 添加账单表单
router.get('/create', checkLogin, (req, res) => {
res.render('create');
});
// 执行添加:把 userId 一起写进去
router.post('/create', checkLogin, (req, res) => {
AccountModel.create({ ...req.body, userId: req.session.userId }, (err, data) => {
if (err) return res.status(500).send('添加失败');
res.render('success', { msg: '添加成功', url: '/account' });
});
});
// 删除账单
router.get('/delete/:id', checkLogin, (req, res) => {
AccountModel.deleteOne({ _id: req.params.id, userId: req.session.userId }, (err) => {
if (err) return res.status(500).send('删除失败');
res.render('success', { msg: '删除成功', url: '/account' });
});
});
module.exports = router;js
// models/AccountModel.js —— 账单 schema 增加 userId 字段
const mongoose = require('mongoose');
const AccountSchema = new mongoose.Schema({
title: String,
time: Date,
type: { type: String, enum: ['收入', '支出'] },
amount: Number,
remark: String,
// 注意:JS 没有 ObjectId 内置类型,必须写全 mongoose.Schema.Types.ObjectId
userId: {
type: mongoose.Schema.Types.ObjectId
}
});
module.exports = mongoose.model('Account', AccountSchema);关键行与易错点
- 中间件放在路由回调第一个参数位置(
router.get('/', checkLogin, fn)),只写在 account 路由上,/users下的登录注册页不需要验证,否则会陷入"登录要先登录"的死循环。 - 中间件里必须
next()才会继续执行后面的回调;不调用next又没有响应,请求会一直挂着。 userId的类型只能写mongoose.Schema.Types.ObjectId,写成String会报错(这不是 JS 内置类型)。- 查询一定要带
{ userId: req.session.userId },否则会查出所有人的账单;删除时也建议带上,防止越权删别人的数据。 { ...req.body, userId: ... }展开顺序不能反,反了userId会被表单数据覆盖。
常见错误
- 现象:提交登录后
req.body为空或取不到username/userPWD。原因:表单缺name、method不是post、action路径写错,或未启用请求体解析。修法:确认form action="/users/login" method="post",两个input分别有name="username"、name="userPWD",并先打印req.body验证。 - 现象:账号密码正确也登录失败。原因:数据库存的是 md5 后的密码,查询时却拿表单明文直接比对。修法:查询条件中先把
userPWD做 md5,再与库中密码比对。 - 现象:用户名或密码错误时返回 500,而不是提示登录失败。原因:把
findOne的data为空误判为数据库异常,混淆了err与“查不到”。修法:err才走 500;data为空走“用户名或密码错误”模板或重定向。 - 现象:登录成功刷新后
req.session没有用户信息,或重启服务后登录态丢失。原因:未配置express-session中间件,或仍使用默认内存存储。修法:在路由前配置 session,并用connect-mongodb-session指定 MongoDB 的uri/database/collection。 - 现象:访问注册页/登录页也被重定向到登录页,甚至死循环。原因:登录验证中间件挂载范围过大,误拦截了
/users相关路由。修法:只把checkLogin挂到/account需要保护的路由上,注册、登录、退出路由放行。 - 现象:有 session 对象但仍能未登录访问账单页。原因:只判断
req.session是否存在,没有判断其中的用户标识。修法:判断req.session.username或req.session.userId是否存在,存在才next()。 - 现象:添加账单成功但列表查不到,或报类型/字段错误。原因:
userId字段名大小写不一致,或 schema 中类型写成String/Number。修法:统一使用userId,schema 写mongoose.Schema.Types.ObjectId,添加时传req.session.userId,查询时按同一字段过滤。 - 现象:点击退出后仍能访问受保护页面。原因:只跳转了登录页,没有销毁服务端 session。修法:调用
req.session.destroy(),在回调中再重定向到登录页。
练习
- 补全登录页表单:
action="/users/login"、method="post"、两个输入框的name分别为username和userPWD;提交后在服务端打印req.body,确认数据完整。 - 完成登录校验:对表单密码做 md5 后查询用户;分别处理
err、查不到用户、查到用户三种情况;登录成功把username和userId写入 session。 - 编写
checkLogin中间件,并挂到/account、/account/create(GET/POST)、/account/delete/:id上;验证未登录重定向登录页、登录后可正常访问。 - 给账单 schema 增加
userId,修改“添加账单”和“账单列表”逻辑;用两个账号分别登录,确认各自只能看到自己的账单。 - 实现退出登录:销毁 session 后重定向登录页;再手动访问账单页,确认无法进入。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 记账本项目-登录01 | 10:12 |
| 2 | 记账本项目-登录02 | 10:31 |
| 3 | 记账本项目-全局登录验证 | 13:58 |
| 4 | 记账本项目-页面中显示用户信息 | 04:02 |
| 5 | 记账本项目-退出登录 | 02:42 |
| 6 | 记账本项目-账单与用户绑定 | 11:37 |
小结
- 登录不是渲染“登录成功”页,而是校验凭据后在服务端建立可识别会话。
- 密码必须按数据库存储形态比对,md5 加密要在查询条件中体现。
findOne的err与data为空是两件事,错误处理要分开。- session 中间件要在路由前配置,持久化 store 可避免重启后登录态丢失。
- 鉴权中间件要精确挂载,保护账单路由,放行注册、登录、退出路由。
- 判断登录状态要看 session 中的用户标识,而不是只看 session 对象是否存在。
- 账单数据隔离必须同时落实“写入 userId”和“查询按 userId 过滤”。
- 退出登录要销毁服务端 session,并用两个账号完整回归注册、登录、访问、添加、列表、退出流程。