写在前面
大家好,我是一溪风月🌀,一名前端工程师/Node爱好者,在我们无论是开发前端应用还是服务端应用,登录都是一个比较复杂难以理解的内容,各种概念以及校验常常会使非常多的开发者比较头疼,这篇文章我们就从服务端的角度来详细的了解一下用户登录的逻辑,以及几个常见的概念,包括cookie,session,JWT等等内容,来详细的学习和理解一下登录所面临的常见问题,以及解决手段,好了,话不多说,让我们开始吧~
一.为什么需要登录凭证?
在我们日常的Web开发中,我们使用和接触最多的协议是HTTP协议,但是HTTP是一个无状态的协议,无状态的协议?什么叫做做无状态的协议哪?我们来举个例子:
在我们登录某个网站的时候,我们需要输入登录名和密码,比如用户名是一溪风月,密码是yxfy666,登录成功之后我们就会以一溪风月的身份去访问其他的数据和资源,并且是通过HTTP协议来访问的,大致过程如下:
- 服务器:你是谁?
- 用户:我是一溪风月,刚刚登录过呀!
- 服务器:怎么证明你登陆过啊?
- 用户:http...没有告诉你吗?
- 服务器:http每次的请求对于我来说都是一个单独的请求,和之前请求过没有关系。
其实这就是http的无状态,也就是服务器不知道你上一步做了什么,我们必须有一个方法来证明我们上一步做了什么,我们之前登录过!所以我们就需要一个凭证来证明我们登录过这个系统,我们一般使用的是token,我们登录凭证的验证方式与流程一般是这样的。

二.认识cookie
Cookie复数形式是Cookies又称小甜饼,类型为小型文本文件,某些网站为了辨别用户身份而存储在用户本地终端上的数据,浏览器会在特定情况下携带上cookie来发送请求,我们也可以通过cookie来获取一些信息。

Cookie总是保存在客户端中,按照在客户端的存储位置Cookie又可以分为内存Cookie和硬盘Cookie
- 内存Cookie由浏览器维护,保存在内存中,浏览器关闭Cookie就会消失,其存在时间是短暂的。
- 硬盘Cookie保存在硬盘中,有一个过期时间,用户手动清理或者过期时间到时,才会被清理。
如何判断一个Cookie是硬盘Cookie还是内存Cookie?
- 没有设置过期时间,默认情况下cookie是内存cookie,在关闭浏览器时会自动删除。
- 有设置过期时间,并且过期时间不为0或者负数的cookie,是硬盘cookie,需要手动或者到期时,才会删除。
三.常见的Cookie属性
cookie的生命周期:
- 默认情况下的cookie是内存cookie,也称之为会话cookie,也就是在浏览器关闭时会自动关闭。
- 我们可以通过expires或者max-age来设置过期时间(目前主要用max-age)
cookie的作用域:
- Domain:指定哪些主机可以接受cookie,如果不指定,那么默认是origin,不包括子域名。如果指定Domain,则包含子域名。例如,如果设置Domain=mozilla.org,则Cookie 也包含在子域名中(如developer.mozilla.org)
- Path:指定主机下哪些路径可以接受cookie 例如,设置Path=/docs,则以下地址都会匹配:
/docs/docs/Web//docs/Web/HTTP
四.在客户端中设置Cookie
虽然在开发中Cookie都是服务器端设置的,但是在浏览器端我们也可以进行Cookie的设置
html
<body>
<h2>客户端的网站</h2>
<button>设置Cookie</button>
</body>
<script>
const btnEle = document.querySelector("button")
btnEle.onclick = function () {
// 在浏览器中设置cookie开发中很少用到
document.cookie = "name=zzz;max-age=30"
document.cookie = "age=12;max-age=60"
}
</script>
我们编写如上代码,然后在浏览器中打开开发者工具Application选项可以查看Cookie

如果我们想要删除某个Cookie并不是直接在代码中清除它,而是时间设置为0
js
document.cookie = "name=zzz;max-age=30"
document.cookie = "age=12;max-age=0"

五.在服务器设置Cookie
我们更多的场景是服务器来设置Cookie,然后浏览器自动保存服务器设置的Cookie,接下来我们基于Koa2来模拟一下Cookie获取和设置(默认已经安装好koa2)
js
// 设置cookie
userRouter.get("/login", (ctx, next) => {
ctx.cookies.set("name", "aaa", {
maxAge: 1000 * 1000
})
ctx.body = "cookie设置成功"
})
// 获取cookie,浏览器会默认携带cookie
userRouter.get("/login2", (ctx, next) => {
const value = ctx.cookies.get("name")
ctx.body = "获取cookie成功" + value
})
六.什么是Session
通过上面的内容我们可以看到Cookie虽然可以通过服务器设置,也可以通过客户端设置,但是Cookie其实是非常不安全的,因为我们可以非常轻易的在客户端进行伪造这些内容,一般情况下我们会对这些内容进行加密处理,然后生成一个sessionId,其实这就是Session 在koa中我们可以借助一个叫做koa-session来实现session认证,我们直接在服务端来进行实现
js
const session = koaSession.createSession({
key: 'sessionId',
signed: false,
}, app)
app.use(session)
// 设置session
userRouter.get("/login", (ctx, next) => {
ctx.session.slogan = "ikun"
ctx.body = "cookie设置成功"
})
// 获取session
userRouter.get("/login2", (ctx, next) => {
const value = ctx.session.slogan
if (value === 'ikun') {
ctx.body = "user-list~"
} else {
ctx.body = "没有访问权限,请先登录~"
}
})
我们就会看到Cookie的值已经被加密了。

但是其实这样依然不够安全,一般情况下我们还会对session进行一些加盐操作
js
const session = koaSession.createSession({
key: 'sessionId',
signed: true,
}, app)
// 加盐操作
app.keys = ['aaa','bbb','ccc']
app.use(session)
然后再次请求就会发现在客户端存储了两个session,并且这两个都会被默认带到服务器。

这样相对来讲安全性就会增强很多,其实这种方式就是在之前登录常用的session+cookie的验证方式。
七.认识token
其实cookie和session的验证方式有很多的缺点
- Cookie会被附加在每个HTTP请求中,所以无形中增加了流量(事实上某些请求是不需要的)
- Cookie是明文传递的,所以存在安全性的问题
- Cookie的大小限制是4KB,对于复杂的需求来说是不够的
- 对于浏览器外的其他客户端(比如iOS、Android),必须手动的设置cookie和session
- 对于分布式系统和服务器集群中如何可以保证其他系统也可以正确的解析session?
如下就是一个简单的分布式系统:

如下就是一个简单的服务器集群:

所以在目前的前后端分离的开发过程中,使用token来进行身份验证的方式是最多的。token可以翻译为令牌,也就是验证用户名和密码正确的情况,会给用户颁发一个令牌,这个令牌作为后续访问一些接口或是资源凭证,我们也可以根据这个凭证来判断用户是否有权限来访问,所以Token的使用分为两个步骤
- 生成token:登录的时候,颁发token
- 验证token:访问某些资源或者接口的时候,验证token
八.JWT实现token机制
JWT生成的Token由三部分组成的:
header:
- alg:采用的加密算法,默认是HMAC SHA256(HS256)采用同一个密钥进行加密和解密。
- type:JWT,固定值,通常写成JWT即可。
- 会通过base64Url算法进行编码。
payload :
- 携带的数据,比如我们可以将用户的id和name放到payload中
- 默认也会携带iat(issued at),令牌的签发时间
- 我们也可以设置过期时间:exp(expiration time)
- 会通过base64Url算法进行编码
signature:
- 设置一个secretKey,通过将前两个的结果合并后进行HMACSHA256的算法。
- HMACSHA256(base64Url(header)+.+base64Url(payload), secretKey);
- 但是如果secretKey暴露是一件非常危险的事情,因为之后就可以模拟颁发token,也可以解密token;

九.Token的使用
当然,在真实的开发中,我们可以直接通过一个库来完成:jsonwebtoken首先我们来安装一下
shell
npm install jsonwebtoken
然后我们在koa中来模拟生成一下token,然后简单的颁发给客户端。
js
const Koa = require("koa")
const KoaRouter = require("@koa/router")
const jwt = require("jsonwebtoken")
const app = new Koa()
const userRouter = new KoaRouter({ prefix: '/users' })
const secretkey = "aaabbbccc"
// 颁发token
userRouter.get("/login", (ctx, next) => {
const payLoad = { id: 111, name: "zzz" }
const token = jwt.sign(payLoad, secretkey, {
expiresIn: 60 // 单位为秒
})
ctx.body = {
code: "0",
token,
message: "登录成功~"
}
})
app.use(userRouter.routes())
app.use(userRouter.allowedMethods())
app.listen(8000, () => {
console.log("服务启动在8000端口!")
})

我们可以看到当我们进行了请求之后客户端获取了服务器颁发的token,然后我们在服务端模拟一下开发中接收到客户端携带Token的情况,客户端携带Token一般是服务端规定的,一般放在header中,我们使用apifox来模拟一下

然后我们就可以通过如下的这种方式来验证token,需要注意的是可能Bearer右侧是有一个小空格的,用来验证替换的时候需要注意,否则token验证可能不通过。
js
// 验证token
userRouter.get("/login2", (ctx, next) => {
const authorization = ctx.header.authorization
// 删除Bearer
const token = authorization.replace("Bearer ", "")
// 验证token
try {
const result = jwt.verify(token, secretkey)
ctx.body = {
code: 0,
data: "服务端数据~"
}
} catch (error) {
ctx.body = {
code: -1001,
mssage: "token错误或者过期~"
}
}
})
十.非对称加密
我们在上面进行了token的颁发和验证,但是我们使用的是默认的加密算法,HS256加密算法,密钥暴露就是非常危险的事情:
- 比如在分布式系统中,每一个子系统都需要获取到密钥。
- 那么拿到这个密钥后这个子系统既可以发布另外,也可以验证令牌。
- 但是对于一些资源服务器来说,它们只需要有验证令牌的能力就可以了。

这个时候我们可以使用非对称加密:RS256
- 私钥(private key):用于发布令牌。
- 公钥(public key):用于验证令牌。
我们可以使用openssl来生成一对公钥和私钥,如果已经有了也可以直接使用.ssh文件夹下的。
shell
# 生成私钥
openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
# 从私钥中提取公钥
openssl rsa -pubout -in private_key.pem -out public_key.pem
十一.使用公钥和私钥签发和验证签名
js
const Koa = require("koa")
const KoaRouter = require("@koa/router")
const fs = require("fs")
const jwt = require("jsonwebtoken")
const app = new Koa()
const userRouter = new KoaRouter({ prefix: '/users' })
const privateKey = fs.readFileSync("./tools/private_key.pem")
const publicKey = fs.readFileSync("./tools/public_key.pem")
// 颁发token
userRouter.get("/login", (ctx, next) => {
const payLoad = { id: 111, name: "zzz" }
const token = jwt.sign(payLoad, privateKey, {
expiresIn: 6000, // 单位为秒
algorithm: 'RS256'
})
ctx.body = {
code: "0",
token,
message: "登录成功~"
}
})
// 验证token
userRouter.get("/login2", (ctx, next) => {
const authorization = ctx.header.authorization
// 删除Bearer
const token = authorization.replace("Bearer ", "")
// 验证token
try {
const result = jwt.verify(token, publicKey, { algorithms: ['RS256'] })
ctx.body = {
code: 0,
data: "服务端数据~"
}
} catch (error) {
ctx.body = {
code: -1001,
mssage: "token错误或者过期~"
}
}
})
app.use(userRouter.routes())
app.use(userRouter.allowedMethods())
app.listen(8000, () => {
console.log("服务启动在8000端口!")
})
当然我们在真实的开发中私钥和公钥不会像现在这样,直接在一个服务中进行编写,一般用户系统用来颁发私钥,其他系统使用公钥解密。
十二.总结
这篇文章到这里就结束了🌸,这篇文章我们了解了HTTP的无状态机制,以及常见HTTP协议下的凭证包含Cookie,Session,Token等等,并且我们进行了编写了各种验证机制的方案demo编写,当我们了解完这些内容,我们就可以编写服务端登录的逻辑的时候使用这些验证机制来完善服务端的登录安全性。