Skip to content

Latest commit

 

History

History
685 lines (652 loc) · 28.2 KB

04会话控制.md

File metadata and controls

685 lines (652 loc) · 28.2 KB

目录 回到目录

<style> .back{width:40px;height:40px;display:inline-block;line-height:20px;font-size:20px;background-color:lightyellow;position: fixed;bottom:50px;right:50px;z-index:999;border:2px solid pink;opacity:0.3;transition:all 0.3s;color:green;} .back:hover{color:red;opacity:1} img{vertical-align:bottom;} </style>

写在前面:此笔记来自b站课程尚硅谷Node.js零基础视频教程 P168-P192 / 资料下载 提取码:s3wj

会话控制

会话:客户端向服务端发送一次请求,服务端响应一次信息,就是一次会话 HTTP是一种无状态的协议,无法区分多次请求是否来自同一个客户端,即无法区分用户。这时就需要会话控制解决该问题,常见的会话控制有三种: - cookie - session - token

cookie

cookie是HTTP服务器发送到用户浏览器并保存在本地的一小块数据,是按照域名划分的,形式类似于键值对 特点:浏览器向服务器发送请求时,会自动将当前域名下可用的cookie设置在请求头中,传递给服务器 这个请求头的名称也叫cookie,因此也可将cookie理解成一个HTTP请求头 运行流程:以登录操作为例

  • 在浏览器输入账号密码后,该信息被发送给服务器,服务器校验正确后,将属于该客户的cookie返回(响应头set-cookiecookie1{:width=300 height=300}
  • 有了cookie之后,再向服务器发送请求时,就会自动携带cookie cookie2{:width=250 height=250}
浏览器操作cookie

使用较少,了解即可

  • 禁用所有cookie:打开网页设置页面,搜索cookie,找到管理和删除 cookie 和站点数据->阻止第三方Cookie,开启后很多网页无法正常使用 cookie3{:width=150 height=150}
  • 删除cookie:还是上面的页面,点击查看所有 Cookie和站点数据,就可以查看和删除各网页的cookie cookie4{:width=200 height=200} 如果在一个网站上已经登录,此时删除了该网站的cookie,就无法自动登录了
  • 查看cookie:
    • edge:还是上面的查看所有 Cookie和站点数据,点击右侧箭头展开 cookie5{:width=100 height=100} cookie6{:width=500 height=500} 其中名称是键、内容是值
    • chrome:新版chrome无法通过简单的方式查看,以下是旧版的操作 cookie7{:width=150 height=150} 点击箭头展开 cookie8{:width=300 height=300}
    • 所有浏览器都可使用的方法:f12->控制台->document.cookie cookie11{:width=100 height=100} 注:不同浏览器的cookie不共享。在一个浏览器中登录一个网页,在另一个浏览器中打开这个网页时仍需登录 为什么打开一个网页,会有显示有其它网页的cookie:网页中有其它的组件也需要cookie,进入一个网页时,会向其它多个网页发送cookie。此网页的cookie被称为第一方cookie,其它发送的cookie被称为第三方cookie
express框架中的cookie

设置cookie

  • res.cookie(cookie名, cookie值)这种方式在浏览器关闭时销毁cookie
  • res.cookie(cookie名, cookie值, {maxAge: cookie存在时间})指定cookie的存在时间,单位是ms

例1

const express = require("express");
const app = express();
app.get('/set-cookie', (req, res) => {
    res.cookie('name', 'abc'); //设置cookie
    res.send("home");
});
app.listen(9000);

进入http://127.0.0.1:9000/set-cookie后,响应头中可以看到set-cookie cookie9{:width=150 height=150} 刷新页面,重新发送请求,就可以在请求体中看见刚才设置的cookie cookie10{:width=250 height=250} 一个问题:如果访问http://127.0.0.1:9000,请求中会不会携带cookie?因为域名没变,所以还是会带这个cookie 例2:cookie的生命周期

const express = require("express");
const app = express();
app.get('/set-cookie', (req, res) => {
    res.cookie('name', 'abc', { maxAge: 30*1000 }); //设置cookie生命周期
    res.send("home");
});
app.listen(9000);

先进入http://127.0.0.1:9000/set-cookie设置cookie,再打开http://127.0.0.1:9000,可以在请求头中看到cookie,30s后再次进入,就不会看到cookie了 cookie12{:width=150 height=150} 可以看到cookie的生命周期、过期时间 注:这里面Max-Age的单位是s


删除cookieres.clearCookie(cookie名) 例:

const express = require("express");
const app = express();
app.get('/set-cookie', (req, res) => {
    res.cookie('name', 'abc');
    res.cookie('theme', 'blue');
    res.send("home");
});
app.get('/remove-cookie', (req, res) => {
    res.clearCookie('name', 'abc');
    res.send("删除成功");
});
app.listen(9000);

进入http://127.0.0.1:9000/set-cookie设置cookie cookie13{:width=35 height=35} 进入http://127.0.0.1:9000/remove-cookie删除cookie cookie14{:width=50 height=50} 刷新页面,请求头中cookie只剩下theme cookie15{:width=50 height=50} 可以看出删除cookie的原理:将过期时间设为1970年,让cookie过期


读取cookie:使用包cookie-parser,安装npm i cookie-parser,它本质是一个中间件 使用req.cookies获取,返回一个对象,键是cookie名,值是cookie值 例:

const express = require("express");
const cookie_parser = require("cookie-parser");
const app = express();
app.use(cookie_parser());
app.get('/set-cookie', (req, res) => {
    res.cookie('name', 'abc');
    res.cookie('theme', 'blue');
    res.send("home");
});
app.get('/get-cookie', (req, res) => {
    console.log(req.cookies);
    res.send(`你好,${req.cookies.name}`);
});
app.listen(9000);

进入http://127.0.0.1:9000/set-cookie设置cookie,之后访问http://127.0.0.1:9000/get-cookie cookie16{:width=300 height=300} 就可以获取到之前设置的cookie

session

基于cookie实现,形式类似于cookie(对象--键值对) 在cookie中,设置完cookie后,访问网址时会发送一个请求头Cookie,其中包含例如name/theme等信息;而在session中,它发送一个唯一的sessionid,用于识别每个用户的session数据,这个sessionid的键名可以自定义 与cookie的区别

  • 存放位置:cookie在浏览器端,session在服务器端
  • 安全性:cookie以明文形式存放在客户端,安全性低;session存放在服务器端,用户无法查看,安全性相对较好
  • 存放数据大小:
    • cookie设置内容过多会增大报文体积,影响传输效率,并且浏览器限制单个cookie最大存放量为4K,单个域名下存储数量也有限制
    • session只通过报文传输sid,数据都在服务器中,不影响传输效率,也没有存储限制

使用express-session包对session进行操作,使用connect-mongo包将session存放到mongodb中(因为默认情况下express-session是将session存到内存中,不易查看) 安装:npm i express-session connect-mongo

基本使用
const session = require("express-session");
const mongo_store = require("connect-mongo");
app.use(session({})); //设置中间件
//路由中
req.session.键名 = 键值; //获取/设置session
req.session.destroy(()=>{}); //销毁session,回调函数在删除成功时执行

session({})中传入的对象参数:

  • namesessionid的键名,默认为"connect.sid"
  • secret密钥/签名/加盐,提高加密等级,有了密钥之后,即使知道sid也无法破解
  • saveUninitialized是否为每次请求都自动设置一个cookie来存储sid,就是如果用户没有用session时,还要不要创建一个session。如设置为false,就是不创建;如果想对匿名用户也做信息记录,就可设为true
  • resave是否在每次请求时重新保存session。因为session有生命周期,假设生命周期为20min,第一次访问后设置session,正常情况下20min后session过期;在这20min内,客户向服务器发送请求,如果设为true,则发送时更新session,重新计时20min;反之不重新计时,仍会在20min后过期
  • store存储位置,这里使用mongo_store.create({mongoUrl:"mongodb://主机名:端口号/数据库名称"})连接数据库进行存储,它会创建一个名为session的集合
  • cookie服务器响应cookie(set-cookie)的特性
    • httpOnly设置为true时,限制前端通过js操作cookie(例如使用document.cookie获取当前页面的cookie)
    • maxAgesession生命周期,单位为ms

为什么用的是req.session而不是res.session:可以理解为用户发送请求,中间件自动去查询session,将结果返回,之后我们根据结果渲染页面就行了

const express = require("express");
const session = require("express-session");
const mongo_store = require("connect-mongo");
const app = express();
app.use(session({
    name: "sid",
    secret: 'jiamizifuchuan',
    saveUninitialized: false,
    resave: true,
    store: mongo_store.create({
        mongoUrl: "mongodb://127.0.0.1:27017/test"
    }),
    cookie: {
        httpOnly: true,
        maxAge: 1000 * 3600 //1h
    }
}));
app.get('/login', (req, res) => { //设置
    //这里通过查询字符串判断是否满足登录条件
    if (req.query.username === "admin" && req.query.password === "woshimima") {
        //设置session
        req.session.username = 'admin';
        //登录成功
        res.send("登录成功");
    } else {
        res.send("登录失败");
    }
});
app.get('/cart', (req, res) => { //获取
    if (req.session.username) {
        res.send(`您好${req.session.username},这里是购物车页面`);
    } else {
        res.send("您还没有登录");
    }
});
app.get('/logout', (req, res) => { //销毁
    req.session.destroy(() => {
        res.send("退出成功");
    })
});
app.listen(9000);
  • 登录:进入http://127.0.0.1:9000/login?username=admin&password=woshimima登录,可以看到响应头中包含set-cookie,之后再次访问,请求头中可以看到cookie。在session的有效期内,访问页面时,请求头中会一直包含cookie session1{:width=150 height=150} session2{:width=300 height=300} 数据库也可以看到设置的session,其中包含username项 session3{:width=70 height=70} 注:响应头中的set-cookie只在第一次登录成功时才有,因为后面已经登录,不用再设置
  • 访问页面:进入http://127.0.0.1:9000/cart session4{:width=70 height=70} 请求头中包含cookie(sid)
  • 退出登录:进入http://127.0.0.1:9000/logout,之后再进入http://127.0.0.1:9000/cart,就是未登录状态了 session5{:width=150 height=150} 虽然请求头中也会包含cookie,但因为数据库中这条sid已被删除,其sid不能与数据库匹配,所以是未登录状态
CSRF跨站请求伪造

即B网站向A网站发送请求,同时携带A网站的cookie 比如我现在在A网站上已经登录,在另一个标签页中打开B网站,它有一个link链接

<link rel="stylesheet" href="A网站/logout">

如果A网站的logout路由(退出登录)是get请求,就能实现访问B网站,退出A网站的登录 一种解决方法:在A网站中使用post路由 因为link、script、img等标签的src/href都是get请求,所以如果是增删改数据、需要对cookie进行操作的路由尽量用post

记账本案例

在routes/web中新建文件auth.jslogin.js,分别用于实现注册和登录功能,并在app.js中引入

var authRouter = require('./routes/web/auth');
var loginRouter = require('./routes/web/login');
app.use('/reg', authRouter);
app.use('/login', loginRouter);

在models中新建模型文件UserModel.js,用于存放用户的session数据

const mongoose = require('mongoose');
let UserSchema = new mongoose.Schema({
    username: {
        type: String,
        require: true
    },
    password: {
        type: String,
        require: true
    }
});
let UserModel = mongoose.model('users', UserSchema);
module.exports = UserModel;

注册功能:将注册的静态网页存入views/auth中,该文件夹用于存放注册相关内容 修改表单:为输入框添加name属性,为表单添加action,并将后缀从html改成ejs

<!-- reg.js -->
<form method="post" action="/reg">
    <input name="username" type="text" class="form-control" id="item" />
    <input name="password" type="text" class="form-control" id="time" />
</form>

密码加密:使用md5包,它提供一种单向加密算法(只能通过原数据找到加密数据,不能通过加密数据找原数据),安装npm i md5

/* auth.js */
const express = require("express");
const md5 = require("md5");
const router = express.Router();
const UserModel = require("../../models/UserModel"); //导入模型
router.get('/', (req, res) => { //渲染注册页面
    res.render("auth/reg");
});
router.post('/', (req, res) => { //登录的post请求
    const { username, password } = req.body;
    if (!(username && password)) {
        res.render('error', { msg: "注册失败", url: "/reg" });
    }
    const md5_pw = md5(password);
    UserModel.create({ username: username, password: md5_pw }, (err, data) => {
        if (err) {
            res.render('error', { msg: "注册失败", url: "/reg" });
            return;
        }
        res.render('success', { msg: "注册成功", url: "/login" });
    });
});
module.exports = router;

记账本案例会话控制2{:width=250 height=250} 记账本案例会话控制3{:width=250 height=250}


登录功能:在views/auth中新建login.ejs,将“注册”改成“登录”,表单提交路径改成/login

/* login.js */
const express = require("express");
const md5 = require("md5");
const router = express.Router();
const UserModel = require("../../models/UserModel"); //导入模型
router.get('/', (req, res) => { //渲染注册页面
    res.render("auth/login");
});
router.post('/', (req, res) => { //登录的post请求
    const { username, password } = req.body;
    if (!(username && password)) {
        res.render('error', { msg: "用户名或密码不能为空", url: "/login" });
    }
    UserModel.findOne({ username: username, password: md5(password) }, (err, data) => {
        if (err) {
            res.render('error', { msg: "登录,请稍后再试", url: "/login" });
            return;
        }
        if (!data) {
            res.render('error', { msg: "账号或密码错误", url: "/login" });
            return;
        }
        res.render('success', { msg: "登录成功", url: "/account" });
    });
});
module.exports = router;

记账本案例会话控制4{:width=400 height=400} 记账本案例会话控制5{:width=400 height=400}


登录后写入session,并返回sessionid,一遍下次能自动登录

  • 在app.js中引入包,并设置中间件
    /* app.js */
    const session = require("express-session");
    const mongo_store = require("connect-mongo");
    const { DBHOST, DBPORT, DBNAME } = require("./config/config");
    app.use(session({
    name: "sid",
    secret: 'jiamizifuchuan',
    saveUninitialized: false,
    resave: true,
    store: mongo_store.create({
        mongoUrl: `mongodb://${DBHOST}:${DBPORT}/${DBNAME}`
    }),
    cookie: {
        httpOnly: true,
        maxAge: 1000 * 3600 * 24 * 7 //7天
    }
    }));
  • 因为是在登录成功后才写入session,所以代码应写在login.jsres.render('success', { msg: "登录成功", url: "/account" })
    /* login.js */
    router.post('/', (req, res) => { //登录的post请求
        ... //获取输入的用户名和密码
        UserModel.findOne({ username: username, password: md5(password) }, (err, data) => {
            ... //登录失败return
            //写入session
            req.session.username = data.username;
            req.session._id = data._id; //数据库中id,方便后续查询
            res.render('success', { msg: "登录成功", url: "/account" });
        });
    });

登录成功后,可以看到响应头中设置的session和数据库中存储的session 记账本案例会话控制6{:width=400 height=400}


登录检测:当用户访问account/create页时,根据是否登录以及登录的session来返回结果,因此应写在account.js和create.js的路由中 因为account.js的所有路由都需要进行登录检测,所以封装到中间件中,新建一个文件夹middleware来存储中间件,登录检测的中间件写在其中的check_login.js中

/* check_login.js */
//检测登录中间件
module.exports = (req, res, next) => {
    if (!req.session.username) { //没登录
        return res.redirect('/login'); //跳转到登录界面
    }
    next();
}

在account.js和create.js中调用:

/* account.js */
//导入检测登录中间件
const check_login = require("../../middleware/check_login");
//响应表单提交的post请求
router.post('/', check_login, function (req, res) {...});
router.get('/', check_login, function (req, res) {...});
router.get('/:id', check_login, function (req, res) {...});
/* create.js */
//导入检测登录中间件
const check_login = require("../../middleware/check_login");
router.get('/', check_login, function (req, res, next) {...});

退出登录,同时防止CSRF跨站请求伪造:在routes/web文件夹中新建路由规则logout.js,并在app.js中调用

/* app.js */
var logoutRouter = require('./routes/web/logout');
app.use('/logout', logoutRouter);
/* logout.js */
const express = require("express");
const router = express.Router();
router.post('/', (req, res) => {
    req.session.destroy(() => {
        res.render('success', { msg: "退出登录成功", url: "/login" });
    });
});
module.exports = router;

在list.ejs中添加对应的按钮:因为是post请求,要用表单而不能是a

<!-- list.ejs -->
<div class="row">
    <div class="col-xs-12 col-lg-8 col-lg-offset-2">
        <div class="row text-right" style="padding-top: 5px;">
            <div class="col-xs-12">
                <form action="/logout" method="post">
                    <button class="btn btn-danger">退出登录</button>
                </form>
            </div>
        </div>
        <div class="row">...</div>
        ...
    </div>
</div>

首页和404页面:在routes/web文件夹中新建首页路由规则index.js,并在app.js中调用;404的路由在app.js中已经提供了,直接更改即可

/* app.js */
var indexRouter = require('./routes/web/index');
app.use('/', indexRouter);
// catch 404 and forward to error handler
app.use(function (req, res, next) {
  res.render('404');
});
/* index.js */
const express = require("express");
const router = express.Router();
router.get('/', (req, res) => {
    res.redirect('/account');
});
module.exports = router;

在views中新建404.ejs

<!-- 404.ejs -->
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>404</title>
</head>
<body>
    <h1>404 NOT FOUND</h1>
</body>
</html>

如果还想插入图片,要注意保存到public文件夹中,不要放到views中

token

token是服务端生存并返回给HTTP客户端的一串加密字符串,其中保存有用户信息,主要用于移动端APP 工作流程

  • 和cookie/session类似,都是先根据请求校验身份,校验通过后响应token(一般是在响应体中) token1{:width=300 height=300}
  • 后续发送请求时,将token添加在请求报文中(一般是请求头),服务器对token校验并取出用户信息 token2{:width=300 height=300} 注:token必须手动添加在请求报文中,而cookie/session都是自动携带

特点

  • 服务端压力更小:数据存储在客户端
  • 相对更安全:数据加密、可避免CSRF跨站请求伪造(不能自动携带token)
  • 扩展性更强:服务间可以共享(一个token用于多个服务)、增加服务器节点更简单(服务器只需检验token,无需存储,这样增加服务器时就不用将原来的信息全部移入新服务器)
JWT

JWT(JSON Web Token)目前最流行的跨域认证解决方案,可用于token的身份验证 使用jsonwebtoken包创建/校验token,安装:npm i jsonwebtoken 基本使用

const jwt = require("jsonwebtoken");
//创建token
let token = jwt.sign(用户数据, 加密字符串, 配置对象);
//校验token
jwt.verify(token, 加密字符串, (err, data)=>{});
  • 用户数据一般用对象形式
  • 配置对象:{expiresIn: 生命周期},单位是秒
  • err是错误对象;data是一个对象,包括用户数据、token创建时间和过期时间
  • 当token过期时,就无法通过token校验

const jwt = require("jsonwebtoken");
let token = jwt.sign({
    name: "abc"
}, 'woshijiamizifuchuan', {
    expiresIn: 10 //10s
});
console.log(token);
jwt.verify(token, 'woshijiamizifuchuan', (err, data) => {
    if (err) {
        console.log('校验失败');
        return;
    }
    console.log(data);
});

过10s后进行校验:

let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiYWJjIiwiaWF0IjoxNzI3NjAwNDAxLCJleHAiOjE3Mjc2MDA0MTF9.cdI2VXez3Akd2JzSrYN07zAlNWdrwnCzkIqVJJR2--U";
jwt.verify(token, 'woshijiamizifuchuan', (err, data) => {
    if (err) {
        console.log('校验失败');
        return;
    }
    console.log(data);
});

显示“校验失败” token3{:width=100 height=100}

记账本案例

在session的记账本案例中,我们进行了网页端的登录校验,但没有进行接口(APP端)的登录校验 接口端只作登录校验,因此只需将之前在routes/web中写的login.js复制到routes/api文件夹中,将res.render改成res.json,并在app.js中引入 注:首先需要安装jwtnpm i jsonwebtoken 登录响应token: 将密钥写在config/config.js中:

/* config.js */
module.exports = {
    DBHOST: '127.0.0.1',
    DBPORT: 27017,
    DBNAME: "test",
    secret: 'woshijiamizifuchuan'
}
/* login.js */
const jwt = require("jsonwebtoken");
const { secret } = require("../../config/config");
router.post('/', (req, res) => { //登录的post请求
    const { username, password } = req.body;
    UserModel.findOne({ username: username, password: md5(password) }, (err, data) => {
        if (err) {
            res.json({
                code: '2001',
                msg: "数据库读取失败",
                data: null
            });
            return;
        }
        if (!data) {
            res.json({
                code: '2002',
                msg: "用户名或密码错误",
                data: null
            });
            return;
        }
        //创建token
        const token = jwt.sign({
            username: data.username,
            _id: data._id
        }, secret, {
            expiresIn: 3600 * 24 * 7 //7天
        });
        //响应token
        res.json({
            code: '0000',
            msg: "登录成功",
            data: token
        });
    });
});
/* app.js */
const loginRouterAPI = require('./routes/api/login');
app.use("/api/login", loginRouterAPI);

记账本案例token1{:width=400 height=400} 记账本案例token2{:width=230 height=230}


token校验:在获取记账本列表的路由(account.js)中进行 一个问题:如何获取服务端传来的token?这是服务端自行决定的,一般放在请求头中,名称不固定(token/tk/userkey/...) 因为其中的所有路由都需要token校验,所以封装成中间件check_token.js存入middleware文件夹中 改进:校验token成功后,保存用户的信息 因为在校验token后的查询数据库操作中,需要根据用户的相关信息进行查询,而不是查询全部数据 方法:将用户的信息保存在请求对象req

/* check_token.js */
const jwt = require("jsonwebtoken");
const { secret } = require("../config/config");
module.exports = (req, res, next) => {
    //获取token
    const token = req.get('token');
    if (!token) {
        res.json({
            code: "2003",
            msg: 'token缺失',
            data: null
        });
        return;
    }
    //校验token
    jwt.verify(token, secret, (err, data) => {
        if (err) {
            res.json({
                code: "2004",
                msg: 'token校验失败',
                data: null
            });
            return;
        }
        //保存用户信息
        req.user = data;
        //校验成功,继续执行代码
        next();
    });
}
/* account.js */
const check_token = require("../../middleware/check_token");
router.post('/', check_token, function (req, res) {
    console.log(req.user); //获取用户信息
});
//主页面
router.get('/', check_token, function (req, res) {...});
//删除数据
router.delete('/:id', check_token, function (req, res) {...});
//获取单个账单信息
router.get('/:id', check_token, function (req, res) {...});
//更新单个账单信息
router.patch('/:id', check_token, function (req, res) {...});

记账本案例token3{:width=400 height=400} 注:参数值的token不加引号 记账本案例token4{:width=100 height=100} 这里的思路其实与req.session是一样的,为什么req中有用户数据?是因为中间件函数对req对象进行了处理

补充:本地域名

即只能在本机使用的域名,一般在开发阶段使用 编辑文件C:\Windows\System32\drivers\etc\hosts 加上127.0.0.1 www.baidu.com,此时在新窗口中进入网址www.baidu.com:3000,就可以访问之前的127.0.0.1:3000


还可以更改服务的端口号,这样直接输入www.baidu.com就可以访问127.0.0.1(HTTP改成80端口) 在express框架中,是修改/bin/www的var port = normalizePort(process.env.PORT || '3000')的3000为80


原理:在地址栏输入域名后,浏览器会先进行DNS(Domain Name System)查询,获取该域名对应的IP,浏览器向这个IP发送请求

  • DNS查询:浏览器向DNS服务器发送请求,它根据域名返回IP (可通过命令行ipconfig /all查看本机的DNS服务器)
  • hosts文件可以设置域名与IP的映射关系。请求发送前,会优先查询该文件来获取域名的IP地址;如果hosts文件中有该域名,就不会再向DNS服务器发送请求,而是直接使用hosts文件中对应的IP