图片 5

Node.js 应用:Koa2 使用 JWT 进行鉴权

前言

前言

在前后端分离的开发中,通过 Restful API 进行数据交互时,如果没有对 API
进行保护,那么别人就可以很容易地获取并调用这些 API
进行操作。那么服务器端要如何进行鉴权呢?

Json Web Token 简称为
JWT,它定义了一种用于简洁、自包含的用于通信双方之间以 JSON
对象的形式安全传递信息的方法。JWT 可以使用 HMAC 算法或者是 RSA
的公钥密钥对进行签名。

说得好像跟真的一样,那么到底要怎么进行认证呢?

图片 1

首先用户登录时,输入用户名和密码后请求服务器登录接口,服务器验证用户名密码正确后,生成token并返回给前端,前端存储token,并在后面的请求中把token带在请求头中传给服务器,服务器验证token有效,返回正确数据。

既然服务器端使用 Koa2 框架进行开发,除了要使用到 jsonwebtoken
库之外,还要使用一个 koa-jwt 中间件,该中间件针对 Koa 对 jsonwebtoken
进行了封装,使用起来更加方便。下面就来看看是如何使用的。

前面的话

  实现用户登录认证的方式常见的有两种:一种是基于 cookie
的认证,另外一种是基于 token 的认证
。本文以基于cookie的认证为参照,详细介绍JWT标准,并实现基于该标签的用户认证接口

 

在前后端分离的开发中,通过 Restful API 进行数据交互时,如果没有对 API
进行保护,那么别人就可以很容易地获取并调用这些 API
进行操作。那么服务器端要如何进行鉴权呢?

生成token

这里注册了个 /login 的路由,用于用户登录时获取token。

const router = require('koa-router')();
const jwt = require('jsonwebtoken');
const userModel = require('../models/userModel.js');

router.post('/login', async (ctx) => {
    const data = ctx.request.body;
    if(!data.name || !data.password){
        return ctx.body = {
            code: '000002',
            data: null,
            msg: '参数不合法'
        }
    }
    const result = await userModel.findOne({
        name: data.name,
        password: data.password
    })
    if(result !== null){
        const token = jwt.sign({
            name: result.name,
            _id: result._id
        }, 'my_token', { expiresIn: '2h' });
        return ctx.body = {
            code: '000001',
            data: token,
            msg: '登录成功'
        }
    }else{
        return ctx.body = {
            code: '000002',
            data: null,
            msg: '用户名或密码错误'
        }
    }
});

module.exports = router;

在验证了用户名密码正确之后,调用 jsonwebtoken 的 sign()
方法来生成token,接收三个参数,第一个是载荷,用于编码后存储在 token
中的数据,也是验证 token
后可以拿到的数据;第二个是密钥,自己定义的,验证的时候也是要相同的密钥才能解码;第三个是options,可以设置
token 的过期时间。

cookie认证

  传统的基于 cookie 的认证方式基本有下面几个步骤:

  1、用户输入用户名和密码,发送给服务器

  2、服务器验证用户名和密码,正确的话就创建一个会话( session
),同时会把这个会话的 ID 保存到客户端浏览器中,因为保存的地方是浏览器的
cookie ,所以这种认证方式叫做基于 cookie 的认证方式

  3、后续的请求中,浏览器会发送会话 ID
到服务器,服务器上如果能找到对应 ID
的会话,那么服务器就会返回需要的数据给浏览器

  4、当用户退出登录,会话会同时在客户端和服务器端被销毁

  这种认证方式的不足之处有两点

  1、服务器端要为每个用户保留 session
信息,连接用户多了,服务器内存压力巨大

  2、适合单一域名,不适合第三方请求

  cookie认证的后端典型代码如下所示

const express = require('express');
const app = express();
const bodyParser = require('body-parser');
app.use(bodyParser.urlencoded({ extended: false }));

const session = require('express-session')
const pug = require('pug');

app.set('view engine', 'pug');

app.use(session({
  secret: 'keyboard cat',
  resave: false,
  saveUninitialized: true
}))


app.get('/', function(req, res){
  let currentUser = req.session.username;
  res.render('index', {currentUser});
})

app.get('/login', function(req, res){
  res.sendFile('login.html', {root: 'public'});
})

app.post('/login', function(req, res){
  let username = req.body.username;
  req.session.username = username;
  res.redirect('/');
})

app.get('/logout', function(req, res){
  req.session.destroy();
  res.redirect('/');
})

app.listen(3006, function(){
  console.log('running on port 3006...');
})

 

Json Web Token 简称为
JWT,它定义了一种用于简洁、自包含的用于通信双方之间以 JSON
对象的形式安全传递信息的方法。JWT 可以使用 HMAC 算法或者是 RSA
的公钥密钥对进行签名。

获取token

接下来就是前端获取 token,这里是在 vue.js 中使用 axios
进行请求,请求成功之后拿到 token 保存到 localStorage
中。这里登录成功后,还把当前时间存了起来,除了判断 token
是否存在之外,还可以再简单的判断一下当前 token
是否过期,如果过期,则跳登录页面

submit(){
    axios.post('/login', {
        name: this.username,
        password: this.password
    }).then(res => {
        if(res.code === '000001'){
            localStorage.setItem('token', res.data);
            localStorage.setItem('token_exp', new Date().getTime());
            this.$router.push('/');
        }else{
            alert(res.msg);
        }
    })
}

然后请求服务器端API的时候,把 token
带在请求头中传给服务器进行验证。每次请求都要获取 localStorage 中的
token,这样很麻烦,这里使用了 axios 的请求拦截器,对每次请求都进行了取
token 放到 headers 中的操作。

axios.interceptors.request.use(config => {
    const token = localStorage.getItem('token');
    config.headers.common['Authorization'] = 'Bearer ' + token;
    return config;
})

token认证

  下面来介绍token认证。详细认证过程如下

  1、用户输入用户名密码,发送给服务器

  2、服务器验证用户名和密码,正确的话就返回一个签名过的 token( token
可以认为就是个长长的字符串),客户端浏览器拿到这个 token

  3、后续每次请求中,浏览器会把 token 作为 http header
发送给服务器,服务器可以验证一下签名是否有效,如果有效那么认证就成功了,可以返回客户端需要的数据

  4、一旦用户退出登录,只需要客户端销毁一下 token
即可,服务器端不需要有任何操作

  这种方式的特点就是客户端的 token
中自己保留有大量信息,服务器没有存储这些信息,而只负责验证,不必进行数据库查询,执行效率大大提高

 

说得好像跟真的一样,那么到底要怎么进行认证呢?

验证token

通过 koa-jwt 中间件来进行验证,用法也非常简单

const koa = require('koa');
const koajwt = require('koa-jwt');
const app = new koa();

// 错误处理
app.use((ctx, next) => {
    return next().catch((err) => {
        if(err.status === 401){
            ctx.status = 401;
            ctx.body = 'Protected resource, use Authorization header to get access\n';
        }else{
            throw err;
        }
    })
})

app.use(koajwt({
    secret: 'my_token'
}).unless({
    path: [/\/user\/login/]
}));

通过 app.use 来调用该中间件,并传入密钥 {secret: 'my_token'},unless
可以指定哪些 URL 不需要进行 token 验证。token
验证失败的时候会抛出401错误,因此需要添加错误处理,而且要放在
app.use(koajwt()) 之前,否则不执行。

如果请求时没有token或者token过期,则会返回401。

JWT

  上面介绍的token-based 认证过程是通过 JWT 标准来完成的

  JWT 是 JSON Web Token
的简写,它定义了一种在客户端和服务器端安全传输数据的规范。通过 JSON 格式
来传递信息

  让我们来假想一下一个场景。在A用户关注了B用户的时候,系统发邮件给B用户,并且附有一个链接“点此关注A用户”。链接的地址可以是这样的

https://your.awesome-app.com/make-friend/?from_user=B&target_user=A

  上面这样做有一个弊端,那就是要求用户B一定要先登录。可不可以简化这个流程,让B用户不用登录就可以完成这个操作。JWT允许我们做到这点

【组成】

图片 2

  一个JWT实际上就是一个字符串,它由三部分组成,第一段是 header
(头部),第二段是 payload (主体信息或称为载荷),第三段是
signature(数字签名)

aaaaaaaaaa.bbbbbbbbbbb.cccccccccccc

  头部用于描述关于该JWT的最基本的信息,例如其类型以及签名所用的算法等。这可以被表示成一个JSON对象

{
  "typ": "JWT",
  "alg": "HS256"
}

  将上面的添加好友的操作描述成一个JSON对象。其中添加了一些其他的信息,帮助今后收到这个JWT的服务器理解这个JWT

{
    "iss": "John Wu JWT",
    "iat": 1441593502,
    "exp": 1441594722,
    "aud": "www.example.com",
    "sub": "jrocket@example.com",
    "from_user": "B",
    "target_user": "A"
}

  将上面的JSON对象进行[base64编码]可以得到下面的字符串。这个字符串称作JWT的Payload(载荷)

eyJpc3MiOiJKb2huIFd1IEpXVCIsImlhdCI6MTQ0MTU5MzUwMiwiZXhwIjoxNDQxNTk0NzIyLCJhdWQiOiJ3d3cuZXhhbXBsZS5jb20iLCJzdWIiOiJqcm9ja2V0QGV4YW1wbGUuY29tIiwiZnJvbV91c2VyIjoiQiIsInRhcmdldF91c2VyIjoiQSJ9

  将上面的两个编码后的字符串都用句号.连接在一起(头部在前)

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJmcm9tX3VzZXIiOiJCIiwidGFyZ2V0X3VzZXIiOiJBIn0

  最后,我们将上面拼接完的字符串用HS256算法进行加密。在加密的时候,我们还需要提供一个密钥(secret)。如果我们用mystar作为密钥的话,那么就可以得到我们加密后的内容。这一部分叫做签名

rSWamyAYwuHCo7IFAgd1oRpSP7nzL7BF5t7ItqpKViM

  最后将这一部分签名也拼接在被签名的字符串后面,我们就得到了完整的JWT

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJmcm9tX3VzZXIiOiJCIiwidGFyZ2V0X3VzZXIiOiJBIn0.rSWamyAYwuHCo7IFAgd1oRpSP7nzL7BF5t7ItqpKViM

  于是,我们就可以将邮件中的URL改成

https://your.awesome-app.com/make-friend/?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJmcm9tX3VzZXIiOiJCIiwidGFyZ2V0X3VzZXIiOiJBIn0.rSWamyAYwuHCo7IFAgd1oRpSP7nzL7BF5t7ItqpKViM

  再强调一下数字签名的运算过程

var encodedString = base64UrlEncode(header) + "." + base64UrlEncode(payload);

HMACSHA256(encodedString, 'secret');

  签名是由服务器完成的,secret 是服务器上存储的密钥,信息签名后整个
token 会发送给浏览器,每次浏览器 发送请求中都包含
secret。所以可以跟服务器达成互信,完成认证过程

图片 3

图片 4

解析koa-jwt

我们上面使用 jsonwebtoken 的 sign() 方法来生成 token 的,那么 koa-jwt
做了些什么帮我们来验证 token。

认证接口

  新建 server/routes.js 文件,导入 User 模型并赋值给 User 变量:

let User = require('./models/user')

  接下来定义用户认证接口,将实现的接口名称为 /auth/login:

module.exports = app => {
  app.post('/auth/login', (req, res) => {
    User.findOne({ username: req.body.username }, (err, user) => {
      if (err) return console.log(err)
      if (!user) return res.status(403).json({ error: '用户名不存在!' })
      user.comparePassword(req.body.password, (err, isMatch) => {
        if (err) return console.log(err)
        if (!isMatch) return res.status(403).json({ error: '密码无效!' })
        return res.json({
          token: generateToken({ name: user.username }),
          user: { name: user.username }
        })
      })
    })
  })
}

  用户从客户端向服务器提交用户名和密码,服务器端通过body-parser中间件把客户端传送过的数据抽取出来并存放到
req.body 中,这样就可以通过 req.body.username 获取到用户名。然后在
MongoDB
数据库中查找这个用户,若查找过程中出错,则打印错误信息到终端;若数据库中不存在这个用户,则向客户端响应错误信息;若数据库中存在这个用户,则验证客户端提交的密码
req.body.password
是否与用户保存在数据库中的密码匹配。若密码不匹配,则向客户端返回错误信息;若密码匹配,则给客户端返回用户信息

  使用NPM安装jsonwebtoken包,jsonwebtoken 包可以生成、验证和解码 JWT
认证码

npm install --save jsonwebtoken

  打开 server/routes.js 文件,导入 jsonwebtoken 模块:

let jwt = require('jsonwebtoken')

  然后,定义生成 JWT 的 generateToken 方法

let generateToken = (user) => {
  return jwt.sign(user, 'xiaohuochai', { expiresIn: 3000 })
}

  调用 jsonwebtoken 模块提供的 sign() 接口生成 JWT。 其中,xiaohuochai
是生成 JWT 认证码的秘钥,为了安全,最好把秘钥放到配置文件中。 user
是要传递给前端的信息,前端可以利用工具解码 JWT 认证码,从而得到 user
数据。 expiresIn
选项用来指定认证码自生成到失效的时间间隔(过期间隔),上述代码中数字
3000
的单位是秒,意思说这个认证码自生成后,再过50分钟就失效了。认证码失效之后,客户端就不能使用失效的认证码访问服务器端的受保护资源了

  完整代码如下

let User = require('./models/user')
let jwt = require('jsonwebtoken')
let secret = require('./config.js').secret
let generateToken = (user) => {
  return jwt.sign(user, secret, { expiresIn: 3000 })
}
module.exports = app => {
  app.post('/auth/login', (req, res) => {
    User.findOne({ username: req.body.username }, (err, user) => {
      if (err) return console.log(err)
      if (!user) return res.status(403).json({ error: '用户名不存在!' })
      user.comparePassword(req.body.password, (err, isMatch) => {
        if (err) return console.log(err)
        if (!isMatch) return res.status(403).json({ error: '密码无效!' })
        return res.json({
          token: generateToken({ name: user.username }),
          user: { name: user.username }
        })
      })
    })
  })
}

  最后在index.js中引入并使用routes

let routes = require('./routes.js')
routes(app)

  使用postman来测试接口,已经在数据库中存了用户名为admin,密码为123456的用户。测试结果如下

图片 5

 

首先用户登录时,输入用户名和密码后请求服务器登录接口,服务器验证用户名密码正确后,生成token并返回给前端,前端存储token,并在后面的请求中把token带在请求头中传给服务器,服务器验证token有效,返回正确数据。

resolvers/auth-header.js

module.exports = function resolveAuthorizationHeader(ctx, opts) {
    if (!ctx.header || !ctx.header.authorization) {
        return;
    }
    const parts = ctx.header.authorization.split(' ');
    if (parts.length === 2) {
        const scheme = parts[0];
        const credentials = parts[1];
        if (/^Bearer$/i.test(scheme)) {
            return credentials;
        }
    }
    if (!opts.passthrough) {
        ctx.throw(401, 'Bad Authorization header format. Format is "Authorization: Bearer <token>"');
    }
};

在 auth-header.js 中,判断请求头中是否带了 authorization,如果有,将
token 从 authorization 中分离出来。如果没有
authorization,则代表了客户端没有传 token 到服务器,这时候就抛出 401
错误状态。

最后

  JWT适合于应用在『无状态的REST
API』,也就是说适用于Android/iOS等移动端,或前后端分离的WEB前端。关于JWT的更多资源移步官网

 

既然服务器端使用 Koa2 框架进行开发,除了要使用到 jsonwebtoken
库之外,还要使用一个 koa-jwt 中间件,该中间件针对 Koa 对 jsonwebtoken
进行了封装,使用起来更加方便。下面就来看看是如何使用的。

verify.js

const jwt = require('jsonwebtoken');

module.exports = (...args) => {
    return new Promise((resolve, reject) => {
        jwt.verify(...args, (error, decoded) => {
            error ? reject(error) : resolve(decoded);
        });
    });
};

在 verify.js 中,使用 jsonwebtoken 提供的 verify()
方法进行验证返回结果。jsonwebtoken 的 sign() 方法来生成 token 的,而
verify() 方法则是用来认证和解析 token。如果 token
无效,则会在此方法被验证出来。

生成token

index.js

const decodedToken = await verify(token, secret, opts);
if (isRevoked) {
    const tokenRevoked = await isRevoked(ctx, decodedToken, token);
    if (tokenRevoked) {
        throw new Error('Token revoked');
    }
}
ctx.state[key] = decodedToken;  // 这里的key = 'user'
if (tokenKey) {
    ctx.state[tokenKey] = token;
}

在 index.js 中,调用 verify.js 的方法进行验证并解析 token,拿到上面进行
sign() 的数据 {name: result.name, _id: result._id},并赋值给
ctx.state.user,在控制器中便可以直接通过 ctx.state.user 拿到 name
_id

这里注册了个 /login 的路由,用于用户登录时获取token。

安全性

  • 如果 JWT 的加密密钥泄露的话,那么就可以通过密钥生成
    token,随意的请求 API
    了。因此密钥绝对不能存在前端代码中,不然很容易就能被找到。
  • 在 HTTP 请求中,token 放在 header
    中,中间者很容易可以通过抓包工具抓取到 header 里的数据。而 HTTPS
    即使能被抓包,但是它是加密传输的,所以也拿不到
    token,就会相对安全了。
const router = require('koa-router')();
const jwt = require('jsonwebtoken');
const userModel = require('../models/userModel.js');

router.post('/login', async (ctx) => {
  const data = ctx.request.body;
  if(!data.name || !data.password){
    return ctx.body = {
      code: '000002',
      data: null,
      msg: '参数不合法'
    }
  }
  const result = await userModel.findOne({
    name: data.name,
    password: data.password
  })
  if(result !== null){
    const token = jwt.sign({
      name: result.name,
      _id: result._id
    }, 'my_token', { expiresIn: '2h' });
    return ctx.body = {
      code: '000001',
      data: token,
      msg: '登录成功'
    }
  }else{
    return ctx.body = {
      code: '000002',
      data: null,
      msg: '用户名或密码错误'
    }
  }
});

module.exports = router;

总结

这上面就是 jwt
基本的流程,这或许不是最完美的,但在大多数登录中使用已经足够了。
上面的代码可能不够具体,这里使用 Koa + mongoose + vue.js 实现的一个例子

jwt-demo,可以做为参考。

在验证了用户名密码正确之后,调用 jsonwebtoken 的 sign()
方法来生成token,接收三个参数,第一个是载荷,用于编码后存储在 token
中的数据,也是验证 token
后可以拿到的数据;第二个是密钥,自己定义的,验证的时候也是要相同的密钥才能解码;第三个是options,可以设置
token 的过期时间。

更多文章:lin-xin/blog

获取token

接下来就是前端获取 token,这里是在 vue.js 中使用 axios
进行请求,请求成功之后拿到 token 保存到 localStorage
中。这里登录成功后,还把当前时间存了起来,除了判断 token
是否存在之外,还可以再简单的判断一下当前 token
是否过期,如果过期,则跳登录页面