JWT 鉴权的优势
JWT (JSON Web Token) 是现今比较主流的的登录鉴权方式。
token 类似一个令牌,成功登录时用户信息被加密到 token 中返回给客户端,然后客户端再次发起请求时将 token 携带在请求头中;服务端收到 token 解密后就可得知是哪个用户。
与传统 session 模式比较
首先,关于 cookie、session 和 token,以及如何让无状态的 http 服务器记住用户身份,这一篇说得很详尽【一文彻底搞懂Cookie、Session、Token到底是什么】,这边也就不展开了。
session 类似存储在服务端的一份用户信息表,而存储在客户端 cookie 中的 sessionId 就是获取这个信息的通行证。
浏览器第一次访问服务端时,服务端通过响应头的set-cookie
字段将创建的 sessionId 返回给客户端。客户端接收到set-cookie
携带的信息会自动存储到cookie
。然后下次访问时请求头的cookie
字段会自动带上此 Id,就能据此找到存储在服务端的用户信息了。
由此我们可以看出 session 和 token 的第一点区别:
-
session 在处理响应头和请求头上是自动的,而且它的实现依赖于 cookie,当然也可以配置一些参数(如使用
koa-session
),但仍然扩展性较差。 -
token 就比较灵活,服务端一般在响应体中返回,客户端可以保存在
localStorage
或者cookie
,然后在任意的请求头字段中携带给服务端。也可以作为Url
甚至post
参数。这整个过程都是开发者手动处理的,可控性更好。
第二点是 token 串已经包含了用户的信息,而 session 的用户信息是存在服务端的,服务端拿到 sessionId 后还要再去所有 session 列表中查找相应 session 信息。如果服务端将 session 数据持久化(如写入数据库),还会有多次查询数据库的开销。
用解析 token 的计算时间换取 session 的存储空间,从而减轻服务器的压力。
第三点:因为 session 是存储在服务端的,如果服务器采用分布式或集群,就要实现多服务器 session 数据同步和负载均衡问题。而token
不存在这个问题,特别适用于分布式微服务。
另外再说几点 JWT 的优势:
- JWT 除了用于认证,还可以用于交换信息。有效使用 JWT,可以降低服务器查询数据库的次数。
- cookie 是不可跨域的,JWT 正是一个跨域认证的方案。你可以使用任何域名提供你的 API 服务而不需要担心跨域资源共享问题(CORS)。token 完全由应用管理,所以它可以避开同源策略。
- token 跨端支持度(如移动端设备)比较友好。浏览器会自动管理 cookie,无需人为干涉,但移动端开发则要手动传递 cookie 和服务端交流。session 需要基于 cookie 实现,而移动端对 cookie 的支持不是很好。
- token可以抵抗 csrf 攻击,cookie + session不行。不过为了减少盗用,JWT 不应使用 HTTP 协议明码传输,建议用 HTTPS 协议传输。
最后 session 和 token 并不矛盾,如果需要实现有状态的会话,仍然可以增加 session 来在服务端保存一些状态。
关于 session 的原理可以看一下【从 koa-session 中间件学习 cookie 与 session】,感觉对于理解 session 机制由一定帮助。
总体思路
登录签发
token
:在前端登录时先验证传递来的账户信息,如比对成功,就生成 token 令牌,返回给前端。(也可以像 session 那样直接ctx.cookies.set(key, value, [options])
写入 cookie,比如 koa-session``)前端拿到 token 并进行保存(通常使用 localStorage, 也可以是 cookie),在之后每次请求时由请求头携带(一般是
Authorization
字段) 发送给服务端。访问验证
token
:对于需要登录权限才能访问的接口,先进行token
认证(可以单独写一个验证中间件做一层拦截),确认token
正确并还在有效期内,才能进行后续处理。客户端拿到错误知道需要(重新)登录。用户退出登录时,清理存在客户端的
token
。
实例:
安装包
npm install jsonwebtoken
我们先写一个登录验证拦截中间件,需要验证token
的 Api 接口都需要先过这里。它负责判断token
是否正确/是否过期,并作出相应处理。
auth
中间件:
const jwt = require('jsonwebtoken')
// 需要登录权限的路由都要经过这个中间件
const auth = async (ctx, next) => {
const token = ctx.header.authorization.replace('Bearer ', '')
try {
const user = jwt.verify(token, 'koa.animal_secret')
ctx.state.user = user
} catch (err) {
switch (err.name) {
case 'TokenExpiredError':
// ctx.app.emit('error', tokenExpiredError, ctx)
throw new errs.AuthFailed('token已过期', 10101)
case 'JsonWebTokenError':
throw new errs.AuthFailed('无效的token', 10102)
default:
throw new errs.AuthFailed()
}
}
await next()
}
module.exports = auth
throw new errs.AuthFailed()
是我自定义的错误类型,等效于作出如下响应:
ctx.status = 401
ctx.body = {
code: 401,
message: err.message,
errorCode: err.errorCode,
request: `${ctx.method} ${ctx.path}`,
}
详见另一篇:【Koa 错误捕获和处理】
譬如游客状态可以浏览网站/App 上的所有文章,一旦想要点赞/收藏/评论文章的时候,就需要登录用户状态。那么请求文章列表和详情的 Api 接口不需要经过auth
中间件,而点赞/收藏/评论的 Api 就需要。
再看下用户登录时后端如何生成token
:
jwt.sign(payload, secretOrPrivateKey, [options, callback])
-
payload
:一般用一个对象字面量。内容为用户信息的 js 对象; -
secretOrPrivateKey
:用来生成 token 的 key 值 -
options
: 配置项
配置项中expiresIn, notBefore, audience, subject, issuer
没有默认值。
这些配置项也可以用 exp、nbf、aud、sub 和 iss 直接在第一个参数对象中提供,但不能同时在出现在这两个位置。通常就用到expiresIn
,其余详见【jsonwebtoken 官方文档】
以下为登录请求的 Api,为篇幅考虑,删减了一些错误判断。
import { User } from '../../model/user'
const jwt = require('jsonwebtoken')
const bcrypt = require('bcrypt')
export default async (ctx, next) => {
const { username, password } = ctx.request.body
//如果没有查询到用户,user变量为 null
const user = await User.findOne({
username: username,
})
if (user) {
// 将客户端输入的密码和用户信息中的密码进行比对
const isValid = await bcrypt.compare(password, user.password)
if (isValid) {
// 密码比对成功
const { _id, username } = user
const token = jwt.sign({ _id, username }, 'koa.animal_secret', {
expiresIn: '1d', // 有效期一天,如果数字类型单位为秒(s),字符串数字则单位会被解析为毫秒(ms)
})
// 第一个参数
ctx.body = {
code: 200,
message: '登录成功',
data: {
token
},
}
}
}
}
前端通过登录拿到返回的token
,将它保存起来,比如放在localStorage
或cookie
。同时建议用如vuex
之类的全局状态管理来维护。
前端登录处理:(封装在 vuex 的 actions 里了)
// store 的 user 模块
import { login, logout } from '@api/user'
import {
getToken,
removeToken,
setToken,
} from '@utils/authToken'
const getDefaultState = () => {
return {
token: getToken(),
...
}
}
const state = getDefaultState()
const mutations = {
RESET_STATE: state => {
Object.assign(state, getDefaultState())
},
SET_TOKEN: (state, token) => {
state.token = token
},
}
const actions = {
// user login
login({ commit }, userInfo) {
const { username, password } = userInfo
login({ username, password })
.then(response => {
const { data } = response
if (!data) {
reject('认证失败,请重新登陆')
}
const { token } = data
commit('SET_TOKEN', token)
setToken(token) // 通过 js-cookie 存在 cookie 里
resolve(data)
})
.catch(error => {
reject(error)
})
})
},
... // 退出登录、获取用户信息等方法,下面会补充
}
export default {
namespaced: true,
state,
mutations,
actions,
}
上面用到的setToken
方法单独封装在这个文件里:
// src/utils/authToken.js
import Cookies from 'js-cookie'
const TokenKey = 'animal_token'
export function getToken() {
return Cookies.get(TokenKey)
}
export function setToken(token: String) {
return Cookies.set(TokenKey, token)
}
export function removeToken() {
return Cookies.remove(TokenKey)
}
我们对请求进行了封装,在每次请求时去判断是否存在token
,有则将其放在请求头的Authorization
里带给服务端。
以 axios 为例,在发送请求之前,通过请求拦截器把token
塞到header
里:
import store from '@store'
import { getToken } from '@utils/authToken'
import axios from 'axios'
// create an axios instance
const service = axios.create({
baseURL: import.meta.env.VITE_APP_BASE_API, // url = base url + request url
// withCredentials: true, // 发送跨域请求时携带 cookie 和 session,后端要配置相应的 cors
timeout: 10000, // request timeout
})
// 请求拦截器
service.interceptors.request.use(
config => {
// const token = localStorage.getItem('tokenName'); // 如果存在 localStorage
if (store.getters.token) {
config.headers['Authorization'] = `Bearer ${getToken()}`
}
return config
},
error => {
// do something with request error
return Promise.reject(error)
}
)
退出登录以及token
过期时,清理存在本地的token
。同样是封装在 vuex 的 actions 里:
const actions = {
...
// user logout
logout({ commit, state, dispatch }) {
return new Promise((resolve, reject) => {
logout(state.token)
.then(() => {
commit('SET_TOKEN', '')
removeToken() // must remove token first
commit('RESET_STATE')
resolve()
})
.catch(error => {
reject(error)
})
})
},
// remove token
resetToken({ commit }) {
return new Promise(resolve => {
commit('SET_TOKEN', '')
removeToken()
resolve()
})
},
}
以上 koa 服务端完整代码 GitHub 地址是 https://github.com/aizawasayo/animal_server.git,仍在更新维护中,仅供参考。
koa-jwt 中间件
验证中间件也可以直接用 koa 的这个包,在app.js
里对指定路径之外的所有路由进行验证。
先装下包:
npm install koa-jwt
const Koa = require('koa')
const koaJwt = require('koa-jwt');
const app = new Koa()
// 路由权限控制,除了`path`里匹配的路径,都需要验证 token
app.use(koaJwt({ secret: 'jwt-secret' })
.unless({ path: [/^\/login/, /^\/register/, /^\/news/] }));
app.listen(3003);