Koa 使用 JWT 实现鉴权

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 机制由一定帮助。

总体思路

  1. 登录签发token:在前端登录时先验证传递来的账户信息,如比对成功,就生成 token 令牌,返回给前端。(也可以像 session 那样直接ctx.cookies.set(key, value, [options])写入 cookie,比如 koa-session``)

  2. 前端拿到 token 并进行保存(通常使用 localStorage, 也可以是 cookie),在之后每次请求时由请求头携带(一般是Authorization字段) 发送给服务端。

  3. 访问验证token:对于需要登录权限才能访问的接口,先进行token认证(可以单独写一个验证中间件做一层拦截),确认token正确并还在有效期内,才能进行后续处理。客户端拿到错误知道需要(重新)登录。

  4. 用户退出登录时,清理存在客户端的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,将它保存起来,比如放在localStoragecookie。同时建议用如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);
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,444评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,421评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,036评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,363评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,460评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,502评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,511评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,280评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,736评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,014评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,190评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,848评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,531评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,159评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,411评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,067评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,078评论 2 352

推荐阅读更多精彩内容