系统通常只有登录成功后才能访问,而 http 是无状态的。倘若直接请求需要登录
才可访问的接口,假如后端反复查询数据库,而且每个请求还得带上用户名和密码,这都是不很好。
【资料图】
作为前端,我们听过 cookie
(session) 和 token
,他们都是登录标识
,各有特色,本篇都将完整实现。
Tip:在上文(起步篇)基础上进行
cookie 和 sessionexpress-sessionexpress-session —— 用于 Express 中使用 session,对于前端是无感知的,因为 cookie 会自动发送给后端。
安装 express-session
包:
PS E:\pjl-back-end> npm install express-sessionadded 6 packages, and audited 85 packages in 9s2 packages are looking for funding run `npm fund` for details4 vulnerabilities (3 high, 1 critical)To address all issues (including breaking changes), run: npm audit fix --forceRun `npm audit` for details.
代码app.jsapp.js 中注册 session,以及设置拦截器。
$ git diff app.js+// session+var session = require("express-session") var indexRouter = require("./routes/index"); var usersRouter = require("./routes/users"); const UserRouter = require("./routes/UserRouter") // 跨域代码省略... // 使用跨域中间件 app.use(allowCors);+// 注册 session 中间件+app.use(session({+ // sessionId 的名字。The name of the session ID cookie to set in the response+ name: "pjl-system",+ // 秘钥+ secret: "pjl-system-demo",+ cookie: {+ maxAge: 1000*60, // 60秒过期+ // true - 只有 https 才能访问 cookie+ secure: false+ },+ // true - 初始就给到 cookie。例如没有登录,直接点击“获取用户列表”,也会给到 cookie+ saveUninitialized: true,+})) // 放开静态资源 app.use(express.static(path.join(__dirname, "public")));+// 请求拦截器+app.use(function(req, res, next) {+ // 如果是登录或注销,则放行+ if(req.url.includes("login")){+ next()+ // 否则还会执行+ return+ }++ // 登录 并且 session 有效+ if(req.session.user){+ // 只要来请求,就更新过期时间+ req.session.date = Date.now()+ next()+ }else{+ // session 失效,返回 401,通知“请重新登录”+ res.status(401).json({code: "-1", msg: "请重新登录"})+ }+}); app.use("/", indexRouter);
Tip:更新过期时间重新设置 session,例如这里的 req.session.date = Date.now()
。任意自定义属性都可以,只要标识 session 有改变即可
增加3个接口:登录
、注销
、用户列表
。
$ git diff routes/UserRouter.js var express = require("express"); var router = express.Router(); const UserController = require("../controllers/UserController.js")+// 登录+router.post("/user/login", UserController.login);+// 注销+router.get("/user/loginout", UserController.loginOut);+// 用户列表+router.get("/user/list", UserController.userList);
UserController.js实现3个接口。登录后设置
session,注销后销毁
session。
$ git diff controllers/UserController.js const UserController = { error: "用户名密码不匹配" }) }else{+ // 登录成功+ /*+ result[0] {+ _id: new ObjectId("6441f499113fbc9501443c70"),+ username: "pjl",+ password: "123456"+ }+ */+ console.log("result[0]", result[0])+ // 删除密码+ delete result[0].password+ // 设置 session。好比给 session 这个房子里放点东西+ req.session.user = result[0] res.send({ code: "0", error: "" }) }- }+ },+ // 注销+ loginOut: async (req, res) => {+ req.session.destroy(() => {+ res.send({code: 0, msg: "注销成功"})+ })+ },+ // 用户列表+ userList: async (req, res) => {+ var result = await UserService.userList()+ if(result.length === 0){+ res.send({+ code: "-1",+ msg: "用户列表获取失败"+ })+ return+ }++ res.send({+ code: "0",+ data: {+ rows: result,+ },+ msg: "用户列表获取成功"+ })+ }, }
UserService.js实现用户列表数据库的查询。
$ git diff services/UserService.jsconst UserService = { login: async ({username, password}) => { return UserModel.find({ username, password }) }, +// 用户列表 +userList: async () => { + return UserModel.find() +}}
校验后端启动后端服务,允许前端访问新增的三个接口
PS E:\pjl-back-end> npm run start> pjl-back-end@0.0.0 start> nodemon ./bin/www[nodemon] 2.0.22[nodemon] to restart at any time, enter `rs`[nodemon] watching path(s): *.*[nodemon] watching extensions: js,mjs,json[nodemon] starting `node ./bin/www`express-session deprecated undefined resave option; provide resave option app.js:30:9数据库连接成功
Tip:笔者这里的环境需要启动数据库,以及允许跨域请求。
前端笔者直接使用 amis-editor
(amis 低代码编辑器,更多了解请看这里)花费5分钟绘制一个如下前端页面:Tip: 在线的 amis-editor 编辑器感觉有点慢,直接下载到本地启动。笔者使用的是 chrome 109
。firefox 报错(不管)
现在浏览器开发工具查看cookie是空的
笔者故意输错密码
,点击登录
,返回{"code":"-1","error":"用户名密码不匹配"}
。虽然登录失败,但 cookie 也会有值(saveUninitialized: true,
的作用,如果为 false 则需要登录成功后 cookie 才有值)。
// GenaralRequest URL: http://localhost:3000/user/loginRequest Method: POSTStatus Code: 200 OKRemote Address: [::1]:3000Referrer Policy: strict-origin-when-cross-origin// Response{"code":"-1","error":"用户名密码不匹配"}
用户列表
需要登录后才能获取,现在没有登录,点击获取用户列表
,返回 401。前端可以根据这个返回做路由跳转至登录页。
输入正确的密码,登录成功
再次点击获取用户列表
,返回用户列表信息:
// GenaralRequest URL: http://localhost:3000/user/listRequest Method: GETStatus Code: 200 OKRemote Address: [::1]:3000Referrer Policy: strict-origin-when-cross-origin// Response HeadersSet-Cookie: pjl-system=s%3AuMMniH85GEC5T1c5vW5BXH0mlDlt91RT.AL7IEIrnkw5mh%2FFjARknIkWziJNWSjgSe37u5LtSLek; Path=/; Expires=Tue, 25 Apr 2023 06:27:25 GMT; HttpOnly// Request Headers// 自动发出 cookie,容易引起安全问题Cookie: pjl-system=s%3AuMMniH85GEC5T1c5vW5BXH0mlDlt91RT.AL7IEIrnkw5mh%2FFjARknIkWziJNWSjgSe37u5LtSLek// Response{"code":"0","data":{"rows":[{"_id":"6441f499113fbc9501443c70","username":"pjl","password":"123456"}]},"msg":"用户列表获取成功"}
Tip: 这里也说明 cookie 会自动
发出去
而且过期时间也从 27:03
推迟到 27:25
,说明只要用户操作了,session 的过期时间就会更新。比如笔者这里设置 1 分钟,只要在这个时间内不停的与后端交互,session 就不会过期。
Tip:限制访问 Cookie 有 Secure
属性和 HttpOnly
属性。这里的是 HttpOnly,所以通过浏览器控制台 document.cookie
返回空。
点击注销
,再次获取用户列表,则报 401。
目前有个问题
:登录后,能请求到用户列表,只要保存后端,服务就会重启,再次请求用户列表就报 401,因为现在 session 存在内存
中,重启服务内存中的数据就清空了。
...[nodemon] restarting due to changes...[nodemon] starting `node ./bin/www`express-session deprecated undefined resave option; provide resave option app.js:32:9数据库连接成功
我们可以借助 connect-mongo
将 session 存入mongo 数据库中。
用法很简单,首先安装包,然后配置如下即可:
PS E:\pjl-back-end> npm i connect-mongoadded 7 packages, and audited 92 packages in 12s2 packages are looking for funding run `npm fund` for details4 vulnerabilities (3 high, 1 critical)To address all issues (including breaking changes), run: npm audit fix --forceRun `npm audit` for details.
$ git diff app.js // session var session = require("express-session")+// 将session 存入 mongo+var MongoStore = require("connect-mongo") app.use(session({ saveUninitialized: true,+ store: MongoStore.create({+ mongoUrl: "mongodb://192.168.1.123:27017/pjl_session_db",+ // 默认情况下,connect-mongo使用MongoDB的TTL收集功能(2.2+)让mongodb自动删除过期的会话+ ttl: 1000 * 60+ }) }))
重启后端服务,进入 mongo shell 发现 pjl_session_db
数据库被自动创建:
> show dbsadmin 0.000GBconfig 0.000GBlocal 0.000GBpjl_db 0.000GBpjl_session_db 0.000GB
目前里面有一张 sessions 的空表:
> use pjl_session_dbswitched to db pjl_session_db> db.getCollectionNames()[ "sessions" ]> db.session.find()>
Tip: 为方便测试,笔者将过期时间(和 ttl)设置成 10 秒。并设置 saveUninitialized:false
输入正确的用户名和密码,登录成功,保存后端让服务重启,直接点击获取用户信息
,用户信息正常返回,说明session不在保存在内存中。继续不停的点击获取用户信息
时,通过浏览器开发模式发现 cookie 过期时间也在不停的更新。
此刻 sessions 表中有一条数据:
> db.sessions.find(){ "_id" : "Y0rWLhjaDSB6eDwjIcsoF91AmJDYm9mn", "expires" : ISODate("2023-04-25T08:13:52.597Z"), "session" : "{\"cookie\":{\"originalMaxAge\":10000,\"expires\":\"2023-04-25T08:13:52.597Z\",\"secure\":false,\"httpOnly\":true,\"path\":\"/\"},\"user\":{\"_id\":\"6441f499113fbc9501443c70\",\"username\":\"pjl\",\"password\":\"123456\"},\"date\":1682410422595}" }// 点击注销后执行> db.sessions.find()>
点击 注销
后发现 sessions 又为空了。
再次登录后,等待过期,虽然笔者设置的 ttl 是 10 秒,但是笔者在 34 秒查询还有该条数据,38 秒时清空了 —— 说明 数据库 ttl 自动清除
session 生效,但时间不一定是我们设置的。暂未深入研究。
在react 高效高质量搭建后台系统 系列 —— 登录中我们使用了 Token。
Token 使用的大致流程:输入用户名、密码,登录成功后,后端返回数据中包含 token(即后端分配给用户的一个登录标识
),前端将其保存在 localStorage 中,后续前端所有的请求都将会带上这个标识(token),后端接受请求后,验证 token 是否有效,有效则放行请求,如果无效(比如 token 过期、token 伪造),则返回 401 告诉前端“会话过期,请重新登录”。
Token 是个什么东西,为什么可以用作登录标识
? 请看 jsonwebtoken。
安装 jsonwebtoken(JSON Web Tokens 的实现):
PS E:\pjl-back-end> npm i jsonwebtokenadded 10 packages, and audited 102 packages in 12s2 packages are looking for funding run `npm fund` for details4 vulnerabilities (3 high, 1 critical)To address all issues (including breaking changes), run: npm audit fix --forceRun `npm audit` for details.
在 app.js 末尾添加如下代码对 jsonwebtoken 进行初步体验:
// test tokenvar jwt = require("jsonwebtoken")// 秘钥。随便写var private_key = "pjl-system-private-key"// 数据。例如以后的系统登录用户名数据var payload = { id: 1, username: "pjl"}// 过期时间var expire = "10s"var token = jwt.sign(payload, private_key, { expiresIn: expire})console.log("token", token)// 验证 tokenvar decoded = jwt.verify(token, private_key)console.log("decoded", decoded)// 过期后验证setTimeout(() => { var decoded = jwt.verify(token, private_key) console.log("过期后解码decoded", decoded)}, 11 * 1000)
启动服务,控制台输出:
[nodemon] restarting due to changes...[nodemon] starting `node ./bin/www`// tokentoken eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJwamwiLCJpYXQiOjE2ODI0ODg4NzQsImV4cCI6MTY4MjQ4ODg4NH0.mk91VodC-nuUqjUwu2idqrgQDyy_sjASXSmH6g3Go3I// decodeddecoded { id: 1, username: "pjl", iat: 1682488874, exp: 1682488884 }数据库连接成功// 11秒后再次验证 token 报错E:\pjl-back-end\node_modules\jsonwebtoken\verify.js:40 if (err) throw err; ^TokenExpiredError: jwt expired at E:\pjl-back-end\node_modules\jsonwebtoken\verify.js:190:21 at getSecret (E:\pjl-back-end\node_modules\jsonwebtoken\verify.js:97:14) at Object.module.exports [as verify] (E:\pjl-back-end\node_modules\jsonwebtoken\verify.js:101:10) at Timeout._onTimeout (E:\pjl-back-end\app.js:131:21) at listOnTimeout (node:internal/timers:559:17) at processTimers (node:internal/timers:502:7) { expiredAt: 2023-04-26T06:01:24.000Z}[nodemon] app crashed - waiting for file changes before starting...
我们将 token 放入 jwt.io 能看到解码后的信息:
jwt 通常是 xxx.yyy.zzz
的字符串。包含3部分:Header
、Payload
、Signature
(签名)。
验证时,取出 token 的 Header
和Payload
,并配合秘钥生成签名
,在对比 token 中的Signature
,如果相同则说明验证通过。
Tip:由于验证失败时会报错,所以下文需要对 jwt 进行封装
代码jwt.js对 token 封装,导出生成 token 和校验 token 的方法。
// jwt.js// test tokenconst jwt = require("jsonwebtoken")// 秘钥。随便写const private_key = "pjl-system-private-key"const JWT = { // 生成 token generate(payload, expire){ return jwt.sign(payload, private_key, { expiresIn: expire}) }, // 验证 token verify(token){ // 验证失败会报错,所以需要 try...catch try{ return jwt.verify(token, private_key) }catch(e){ return false } }}module.exports = JWT
UserController.js登录时将 token 设置给 request 的头部,客户端将 token 保存,下次请求则再次携带 X-Token:
$ git diff controllers/UserController.js const UserModel = require("../models/UserModel")+const JWT = require("../libs/jwt") const UserController = { login: async (req, res) => { // req.body - 例如 {"username":"pjl","password":"123456"} const UserController = {+ // payload 必须是 plain object,所以不能直接写 result[0]+ const payload = {+ _id: result[0]._id,+ username: result[0].username+ }+ let token = JWT.generate(payload, "1h")+ res.header("X-Token", token) res.send({ code: "0", error: ""
app.js如果是 login 的请求则放行。其他请求如果没有 token 则返回 401,有 token 则校验是否有效,有效则生成新的 token。
$ git diff app.js+var JWT = require("./libs/jwt") // 跨域参考:https://blog.csdn.net/gdutRex/article/details/103636581 var allowCors = function (req, res, next) { res.header("Access-Control-Allow-Origin", "http://localhost");- res.header("Access-Control-Allow-Headers", "Content-Type,lang,sfopenreferer ");+ // 增加 X-Token,否则报错:+ res.header("Access-Control-Allow-Headers", "Content-Type,lang,sfopenreferer,X-Token"); res.header("Access-Control-Allow-Credentials", "true");- next();+ // 预检。参考:https://troyyang.com/2017/06/06/Express_Cors_Preflight_Request/+ if (req.method == "OPTIONS") {+ res.send(200);+ }+ else {+ next();+ } };+app.use(function(req, res, next) {+ // 如果是登录则放行+ if(req.url.includes("login")){+ next()+ // 否则还会执行+ return+ }++ // console.log("req.headers", req.headers)+ // X-Token 接收的是小写 x-token+ const token = req.headers[("X-Token").toLowerCase()]+ const payload = JWT.verify(token)+ console.log("token", token)+ // // 存在 token 并校验成功则通过,否则401+ if(token && payload){+ const newPayload = {+ _id: payload._id,+ username: payload.username+ }+ console.log("newPayload", newPayload)+ // 直接用 payload 浏览器控制台报错:Bad "options.expiresIn" option the payload already has an "exp" property.+ const newToken = JWT.generate(newPayload, "10s")+ // console.log("newToken", newToken)+ res.header("X-Token", newToken)+ next()+ }else{+ res.status(401).json({code: "-1", msg: "请重新登录"})+ }+});
Tip:其中笔者环境涉及跨域,通过添加 X-Token
和 OPTIONS
的代码用于解决如下两个问题:
// 已被 CORS 策略阻止:预检响应中的访问控制允许标头不允许请求标头字段 x 令牌index.html#/edit/2:1 Access to XMLHttpRequest at "http://localhost:3000/user/list" from origin "http://localhost" has been blocked by CORS policy: Request header field x-token is not allowed by Access-Control-Allow-Headers in preflight response.
// 已被 CORS 策略阻止:对预检请求的响应未通过访问控制检查:它没有 HTTP 正常状态。index.html#/edit/2:1 Access to XMLHttpRequest at "http://localhost:3000/user/list" from origin "http://localhost" has been blocked by CORS policy: Response to preflight request doesn"t pass access control check: It does not have HTTP ok status.
测试笔者仍旧在 amis-editor 中进行,首先登陆,请求返回 X-Token,给获取用户列表
增加 X-Token,服务端正常接收,并设置新的 X-Token 给前端
"api": { "url": "http://localhost:3000/user/list", "method": "get", "headers": { "X-Token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2NDQxZjQ5OTExM2ZiYzk1MDE0NDNjNzAiLCJ1c2VybmFtZSI6InBqbCIsImlhdCI6MTY4MjQ5NDM2MCwiZXhwIjoxNjgyNDk3OTYwfQ.fuA9FiqnE15i6YEicGZVdzhzIkNpZhkPzGvWVQZ7qdY" }}
// 第二次请求“用户列表”,返回新的 TokeneyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2NDQxZjQ5OTExM2ZiYzk1MDE0NDNjNzAiLCJ1c2VybmFtZSI6InBqbCIsImlhdCI6MTY4MjQ5NTUwNiwiZXhwIjoxNjgyNDk1NTE2fQ.mtg9l9-hh33HHFX8PwKRrerUs61JxHbdRY3jwLTZVbI
Session Vs Tokensession 的流程:用户登录,session 在服务端生成一个房间(房间放在数据库中),并在房间中放点东西,接着把房间钥匙(即 session Id)设置回 cookie,后续客户端的所有请求都会自动
带上这个 cookie(钥匙),服务端则会根据钥匙去找房间,能找到房间则请求通过,否则告诉前端重新登录
token 的流程:用户登录,服务器生成 token 并返回,前端将 token 存入 localStorage,后续所有请求手动将 token 传回服务端,服务端通过秘钥验证 token,验证成功则请求通过,否则告诉前端重新登录
Session 特点上文通过 express-session 实现登录授权,对前端来说是无感知的cookie 会随着 http 自动发送,容易引起安全问题Session 存在数据库,如果用户数过多,服务端开销就会大后端如果有集群,可能就得涉及多个 session 之间的同步。如果将多个 session 数据库提取出一个公共服务,存在一个机器中,假如该服务宕机,所有用户得重新登录Token 特点占带宽,每次请求都带上 Tokentoken 不会自动发送,得手动发送无法在服务端注销(可配合服务端实现注销 token)登录标识 vs 权限本篇的 token 和 session 仅仅是登录标识,而非权限
,笔者之前写过 前端权限,而前端权限是不靠谱的,所以后端权限通常更重要。
比如直接通过发送一个请求给后端,后端就得查询这个用户(token、cookie都可以获取用户名等信息)的角色、权限,所以每个请求过来,都需要查询数据库。
Tip:更多请自行查阅 RABC
权限
下一篇:最后一页
“谢谢选择我做你的妈妈!” 这封信请18年后查收 扬子晚报讯(通讯员 刘威 记者 朱鼎兆)小时候,母亲常常在家里给我们留字条,
跟新冠病毒“赛跑” 他要让机器人完成核酸检测 经常学生们还不知道我怎么想的时候,我就把自己否定了。工作中需要有自我否定的勇气
助力无接触配送 上海无人车“上岗” 【疫情防控新举措】 科技日报讯 (记者符晓波)眼下,上海疫情蔓延趋势得到有效控制,不少
“态靶辨治” 帮助患者快速转阴 近日,随着患者清零,吉林省长春市北湖奥体中心篮球馆方舱医院等多个方舱陆续“休舱”,各医疗队也
四省市联合医疗队为患者全方位“解忧” 【同心守沪抗疫】 在上海城市足迹馆定点医院的宣传墙上,各类慢性病、基础病的健康宣教手
周美亮: 搜寻野生荞麦的“追种人” ◎本报记者 马爱平 一走进位于国家作物种质库新库内的中国农业科学院作物科学研究所研究员
防晒“神器”竟是珊瑚“杀手” 科技日报北京5月8日电 (实习记者张佳欣)珊瑚礁是地球上生物最丰富、最具经济价值的生态系统之一。
X 关闭
X 关闭