项目中是使用Koa的搭建服务器。
端口配置放在.env
文件中,上传时应该忽略该文件,因为每个设备有自己的端口号。
这里使用dotenv来加载env文件。
登录凭证
web开发中,使用最多的就是http协议,但http是一个无状态的协议。也就是每一次http请求都是一个独立的请求,并不知道之前的状态。比如我们登录成功之后,再去调用其他接口获取信息时会发送一个新的请求,而这次的请求并不知道用户是否已经登录过。
所以登录成功之后服务器需要返回给客户端一个登录凭证,下次请求时拿着登录凭证证明该用户已经登录。
常见的登录凭证:
cookie+session
Token令牌
Cookie
cookie类型为小型文本文件,某些网站为了辨别用户身份而存储在用户本地终端的数据。浏览器会在特定情况下携带上cookie来发送网络请求。
cookie总是抱存在客户端,按照在客户端的存储位置可以分为内存coolie和硬盘cookie
内存cookie由浏览器维护,保存在内存中,浏览器关闭时cookie会消失,存在时间是短暂的
硬盘cookie保存在硬盘中,有一个过期时间,手动清除或者到了过期时间才会消失
如何判断一个cookie是内存cookie还是硬盘cookie?
没有设置过期时间,默认是内存cookie
有设置过期时间,并且过期时间不为0或者负数的cookie是硬盘cookie,需要手动或者到期时才会删除
cookie的生命周期
默认情况下cookie是内存cookie,也称之为会话cookie。可以设置expires或者max-age来设置过期时间:
expires:设置的是Date.toUTCString(),设置格式是:expires=date-in-GMTString-format
max-age:设置过期的时间:max-age=max-age-in-seconds(例如一年为60 * 60 * 24 * 365)
cookie的作用域
cookie的作用域也就是允许cookie发送给哪些URL
-
Domain:指定哪些主机可以接收cookie
如果不指定,默认为origin,不包括子域名
如果指定Domain,则包含子域名。比如,如果设置Domain=mozilla.org, 则cookie也包含在子域名中(比如develop.mozilla.org)
-
Path:指定主机下哪些路径可以接收cookie
- 比如设置Path=/docs,则/docs、/docs/web/、/docs/web/http都会匹配到
设置cookie
cookie可以由客户端设置,也可以由服务端设置。一般都是由服务器设置cookie,客户端来删除cookie
谷歌浏览器查看cookie的方法:F12进入控制台 => Application => Storage => Cookies
客户端设置cookie
// document.cookie= 'name=coderwy';
// 5s之后删除cookie
document.cookie = 'age=18;max-age=5'
服务端设置cookie
testRouter.get('/test', (ctx, next) => {
// 设置cookie
ctx.cookies.set('name','Lucy',{
maxAge:5 * 1000 // 单位是毫秒
})
ctx.body = "test";
})
服务端获取cookie
// domain默认为origin 所以同域名下可以获取到cookie
testRouter.get('/demo', (ctx, next) => {
// 获取cookie
const value = ctx.cookies.get('name')
ctx.body = '你的cookie是:' + value
})
浏览器在发送请求时,会自动将cookie添加到请求头中
Session
session是基于cookie来实现的,在koa项目中可以借助koa-session来实现session认证
const session = Session({
key:'sessionid',
maxAge:10 * 1000,
signed:false // 暂时设置为不需要签名
}, app)
app.use(session)
// 登录接口
testRouter.get('/test', (ctx, next) => {
// 假设用户通过name和password登录 登录成功之后从数据库查询
// 得到id和name
const id = 110
const name = 'Lucy'
// 设置session
// 因为有app.use(session)操作 所有有session这个属性
ctx.session.user = {id, name}
ctx.body = "test";
})
其中Value是我们设置的session.user的base64的编码。可以看到session本质上还是一个cookie
获取session:
// 获取session
testRouter.get('/demo', (ctx, next) => {
console.log(ctx.session.user); // { id: 110, name: 'lucy' }
ctx.body = 'demo'
})
由于浏览器会自定在请求时带上cookie,也就是上面的sessionid和其value,服务器这边会对其进行解析,最终获得登录凭证(id: 110, name: 'lucy'
)
现在来看下使用签名的情况:
const session = Session({
key:'sessionid',
maxAge:30 * 1000,
signed:true // 使用加密签名
}, app)
app.keys = ['aaaaa']
app.use(session)
此时设置的session内容为:
在我们获取session时能正常获取,而当我们修改了sessionid对应的value时,就不会获取到对应的信息
这里是随便修改的value,但我们知道value是根据id和name的内容转成的base64,如果别人也通过其他的id来生成base64来修改value的话,服务端也是能获取到对应的登录凭证的,所以需要进行加密签名
此时获取session时:
// 获取session
testRouter.get('/demo', (ctx, next) => {
console.log(ctx.session.user);
// 判断session内容
// ...
ctx.body = 'demo'
})
此时打印:
undefined
token
cookie和session的方式有很多缺点:
- cookie会附加在每个http请求中,无形中增加了流量
- cookie是明文传输的,所以存在安全性问题
- cookie的大小限制为4kb,对于复杂的需求是不够的
- 对于浏览器其他的客户端(比如iOS,Android)需要手动设置cookie
- 对于分布式系统和服务器集群中如何可以保证其他系统也可以正确的解析session?
所以目前的前后端分离的开发过程中,经常使用token进行身份验证:
token也就是令牌,在验证了用户账号密码正确的情况下,给用户颁发一个令牌,这个令牌作为用户访问其他接口或者资源的凭证。
JWT实现Token机制
JWT实现Token
JWT(Json Web Token)生成的Token由三部分组成:
- header
- alg:采用的加密算法。默认是HMAC SHA256(也就是HS256,一种对称加密算法),采用同一个密钥进行加密解密
- typ:JWT。固定值,通常写成JWT即可
- 会通过base64Url算法对上面两部分进行编码,生成一串字符串(也就是header部分了);
- payload
- 携带的数据,比如可以讲用户的id和name放到payload中
- 默认会携带iat(issued at),令牌签发时间
- 也可以设置过期时间:exp(expiration time)
- 通过base64Url算法对携带的数据进行编码
- signature:因为通过进行base64Url编码的结果很容易被反编码的。所以除了header和payload之外还需要签名
- 设置一个secretKey,然后将前两个的结果和secretKey进行HS256算法的加密:
HS256(baseUrl(header) + . + baseUrl(payload), secretKey);
- 所以secretKey暴露是一件非常危险的事情,因为之后就可以模拟颁发token,也可以解密token。
const Koa = require('koa')
const Router = require('koa-router')
const jwt = require('jsonwebtoken')
const app = new Koa()
const testRouter = new Router()
const SECRET_KEY = 'secretkey'
// 登录接口
testRouter.post('/test', (ctx, next) => {
const payload = {id:119, name:'lwy'}
// 生成token
const token = jwt.sign(payload,SECRET_KEY,{
expiresIn:10
})
// 返回token
ctx.body = token
})
app.use(testRouter.routes())
app.use(testRouter.allowedMethods())
app.listen(8080, () => {
console.log('服务器启动成功');
})
使用postman进行请求可以看到返回的token为:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTE5LCJuYW1lIjoibHd5IiwiaWF0IjoxNjA2NzI0MTg1LCJleHAiOjE2MDY3MjQxOTV9.D8q8-8zIZ5CamXi-sSZdWnPoN_yQ-A4Y8zzIYJW77yQ
可以看到,其中是以 . 分隔成三部分的,对应的就是上面说的token的组成
接下来就可以通过postman来模拟把token传递给服务端。一种方式是把token放到body里,不过很少这样使用,一般都是将token放到header中。
可以看到有很多的认证方式,常用的是Bearer(送信人) Token方式
获取token:
testRouter.get('/demo', (ctx, next) => {
// 获取token
console.log(ctx.headers.authorization);
})
Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTE5LCJuYW1lIjoibHd5IiwiaWF0IjoxNjA2NzI0MTg1LCJleHAiOjE2MDY3MjQxOTV9.D8q8-8zIZ5CamXi-sSZdWnPoN_yQ-A4Y8zzIYJW77yQ
接下来可以获取到token:
testRouter.get('/demo', (ctx, next) => {
// 获取token
console.log(ctx.headers.authorization);
const auth = ctx.header.authorization
const token = auth.replace('Bearer ', '')
try {
// 验证token verify验证失败之后会抛出异常
const result = jwt.verify(token, SECRET_KEY)
console.log(result);
ctx.body = result
} catch (error) {
console.log('验证失败:', error);
ctx.body = 'token 无效'
}
})
token验证成功之后的返回:
{
"id": 119,
"name": "lwy",
"iat": 1606874354,
"exp": 1606884354
}
非对称加密
前面用到的HS256加密算法是一种对称加密(维基百科),一旦密钥暴露是一种很危险的事情。比如在分布式系统中,每一个子系统都需要获取到密钥,那么每个子系统既可以发布令牌,也可以验证令牌,但对一些资源服务器来说,只需要有验证令牌的功能即可。
这个时候就可以使用非对称加密(维基百科):
私钥(private key):用于发布令牌
公钥(public key):用于验证令牌
通常我们使用公钥加密,用私钥解密。而在数字签名中,我们使用私钥加密(相当于生成签名),公钥解密(相当于验证签名)
我们可以使用openssl来生成私钥,然后根据私钥生成对应的公钥。mac中自带的有openssl工具。
比如生成一个密钥到指定的目录中:
cd 指定目录
-
openssl
:进入到ssl的命令行交互页面` -
genrsa -out private.key 1024
: 生成私钥-
genrsa
: gen是generate的缩写,rsa是常用的非对称加密算法 -
-out
:代表我们要导出 -
private.key
: 导出的文件名(代表我们生成的是私钥) -
1024
:生成的私钥的长度,也可以搞成其他长度
-
接下来还要生成用于验证签名的公钥:
rsa -in private.key -pubout -out public.key
-
-in private.key
:表示将刚刚生成的密钥作为输入 -
-pubout
:表示这次生成的是公钥 -
-out
:表示我们要导出 -
public.key
:导出的文件名
使用非对称加密生成token:
const PRIVATE_KEY = fs.readFileSync('./keys/private.key')
const PUBLIC_KEY = fs.readFileSync('./keys/public.key')
// 使用非对称加密
testRouter.post('/test', (ctx, next) => {
const payload = {id:110, name:'lwy'}
// 私钥用来生成签名
const token = jwt.sign(payload, PRIVATE_KEY,{
expiresIn:10 * 1000,
algorithm:"RS256" // 指明用到的算法
})
ctx.body = token
})
testRouter.get('/demo', (ctx, next) => {
const auth = ctx.headers.authorization
const token = auth.replace('Bearer ', '')
try {
const res = jwt.verify(token, PUBLIC_KEY,{
algorithms:["RS256"]
})
ctx.body = res
} catch (error) {
console.log('token验证错误:', error);
ctx.body = 'token失效'
}
})
ps: 项目中相对路径是相对于process.cwd()的,也就是当前项目启动的目录