Skip to content

session与记账本项目增加注册登录功能(Day13 3/3) ​

本页对应 Day13_Node&会话控制,共 6 段视频,约 53 分钟。

学习目标 ​

  1. 能够将登录页表单配置为 POST /users/login,正确设置用户名和密码字段的 name 属性,并实现登录页与注册页互相跳转。
  2. 能够使用表单用户名与 MD5 加密后的密码查询 MongoDB 用户记录,区分查询异常、用户不存在和登录成功三种情况,并返回对应提示或页面。
  3. 能够配置 express-session 与 connect-mongodb-session 中间件,将登录用户的用户名和用户 ID 写入 session,并通过 Cookie 与 MongoDB session 集合验证登录状态被记录。
  4. 能够编写并挂载全局登录验证中间件,对需要保护的账单路由进行拦截:未登录时重定向到登录页,已登录时调用 next() 放行。
  5. 能够从 session 读取用户名并渲染到账单首页,同时实现退出登录路由,通过销毁当前会话使用户无法再访问受保护页面。
  6. 能够为账单模型增加用户 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 里的字段写错导致查询报错,改回后只显示当前用户账单。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

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.sid cookie,里面存的是 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 会被表单数据覆盖。

常见错误 ​

  1. 现象:提交登录后 req.body 为空或取不到 username/userPWD。原因:表单缺 name、method 不是 post、action 路径写错,或未启用请求体解析。修法:确认 form action="/users/login" method="post",两个 input 分别有 name="username"、name="userPWD",并先打印 req.body 验证。
  2. 现象:账号密码正确也登录失败。原因:数据库存的是 md5 后的密码,查询时却拿表单明文直接比对。修法:查询条件中先把 userPWD 做 md5,再与库中密码比对。
  3. 现象:用户名或密码错误时返回 500,而不是提示登录失败。原因:把 findOne 的 data 为空误判为数据库异常,混淆了 err 与“查不到”。修法:err 才走 500;data 为空走“用户名或密码错误”模板或重定向。
  4. 现象:登录成功刷新后 req.session 没有用户信息,或重启服务后登录态丢失。原因:未配置 express-session 中间件,或仍使用默认内存存储。修法:在路由前配置 session,并用 connect-mongodb-session 指定 MongoDB 的 uri/database/collection。
  5. 现象:访问注册页/登录页也被重定向到登录页,甚至死循环。原因:登录验证中间件挂载范围过大,误拦截了 /users 相关路由。修法:只把 checkLogin 挂到 /account 需要保护的路由上,注册、登录、退出路由放行。
  6. 现象:有 session 对象但仍能未登录访问账单页。原因:只判断 req.session 是否存在,没有判断其中的用户标识。修法:判断 req.session.username 或 req.session.userId 是否存在,存在才 next()。
  7. 现象:添加账单成功但列表查不到,或报类型/字段错误。原因:userId 字段名大小写不一致,或 schema 中类型写成 String/Number。修法:统一使用 userId,schema 写 mongoose.Schema.Types.ObjectId,添加时传 req.session.userId,查询时按同一字段过滤。
  8. 现象:点击退出后仍能访问受保护页面。原因:只跳转了登录页,没有销毁服务端 session。修法:调用 req.session.destroy(),在回调中再重定向到登录页。

练习 ​

  1. 补全登录页表单:action="/users/login"、method="post"、两个输入框的 name 分别为 username 和 userPWD;提交后在服务端打印 req.body,确认数据完整。
  2. 完成登录校验:对表单密码做 md5 后查询用户;分别处理 err、查不到用户、查到用户三种情况;登录成功把 username 和 userId 写入 session。
  3. 编写 checkLogin 中间件,并挂到 /account、/account/create(GET/POST)、/account/delete/:id 上;验证未登录重定向登录页、登录后可正常访问。
  4. 给账单 schema 增加 userId,修改“添加账单”和“账单列表”逻辑;用两个账号分别登录,确认各自只能看到自己的账单。
  5. 实现退出登录:销毁 session 后重定向登录页;再手动访问账单页,确认无法进入。

原视频对照 ​

#视频时长
1记账本项目-登录0110:12
2记账本项目-登录0210:31
3记账本项目-全局登录验证13:58
4记账本项目-页面中显示用户信息04:02
5记账本项目-退出登录02:42
6记账本项目-账单与用户绑定11:37

小结 ​

  • 登录不是渲染“登录成功”页,而是校验凭据后在服务端建立可识别会话。
  • 密码必须按数据库存储形态比对,md5 加密要在查询条件中体现。
  • findOne 的 err 与 data 为空是两件事,错误处理要分开。
  • session 中间件要在路由前配置,持久化 store 可避免重启后登录态丢失。
  • 鉴权中间件要精确挂载,保护账单路由,放行注册、登录、退出路由。
  • 判断登录状态要看 session 中的用户标识,而不是只看 session 对象是否存在。
  • 账单数据隔离必须同时落实“写入 userId”和“查询按 userId 过滤”。
  • 退出登录要销毁服务端 session,并用两个账号完整回归注册、登录、访问、添加、列表、退出流程。