基于 JWT 的身份验证

1,151 阅读4分钟

HTTP 是一个无状态的协议,一次请求结束后,再次给服务器发送请求,服务器就不知道这个请求是谁发来的了(同一个 IP 不代表同一个用户),在 Web 应用中,用户的认证和鉴权是非常重要的一环,在实践中有多种解决方案,这里,我们使用 JWT(JSON Web Token) 来实现用户的认证。

JWT是什么

JWT 是跨域认证的一种解决方案,JWT由三部分组成:Header(头部)、Payload(负载)、Signature(签名)

一个JWT的结构通常如下:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7InVzZXJuYW1lIjoiYWJjIiwicGFzc3dvcmQiOiIxMTExMTEifSwiZXhwIjoxNTkxOTMzODcyLCJpYXQiOjE1OTE5MzAyNzJ9.oKAj1dYjiHaNmKB4l5hUU84yycwZMIMLg47Rt5QxKFQ

它通过 点(.) 分隔成三个部分,第一份部分是 Header(头部),第二部分是 Payload(负载),第三部分是Signature(签名)

Header(头部)

Header 是一个JSON 对象,描述JWT的元数据,数据结构通常如下:

{
    "alg": 'HS256',
  "tpy": "JWT"
}

alg 属性表示签名的算法,默认是 HS256;

typ 属性表示这个令牌(token)的类型,JWT 令牌统一写为:JWT;

需要将这个JSON 对象转换成 base64;

Payload(负载)

Payload 也是一个JSON 对象,用来存放实际需要传递的数据。JWT 规定了 7 个官方字段供选用:

  • iss (issuer):签发人

  • exp (expiration time):过期时间

  • sub (subject):主题

  • aud (audience):受众

  • nbf (Not Before):生效时间

  • iat (Issued At):签发时间

  • jti (JWT ID):编号

在 Payload 里,除了官方字段,还可以添加自定义字段,也就是我们要传递的实际数据,如:

{
  username: 'Tom', // 用户名
    exp: Math.floor(Date.now() / 1000) + 60 * 60 // 过期时间
}

同样需要将 Payload 转换成 base64

Signature(签名)

Signature 是对 Header 和 Payload 两部分的签名,其目的是为了防止篡改数据。

在生成签名之前,需要先指定一个秘钥,该秘钥只有服务器知道,然后使用 Header 里面指定的签名算(默认是 HMAC SHA256)法生成签名。

生成签名后,把 Header、Payload、Signature 三个部分通过 点(.) 分隔拼成一个字符串,返回给客户端。

基于Token的身份验证

原理

使用基于 Token 的身份验证方法,在服务端不需要存储用户的登录记录。其原理如下:

  1. 客户端使用用户名跟密码请求登录;

  2. 服务端收到请求,去验证用户名与密码;

  3. 验证成功后,服务端会签发一个 Token,再把这个 Token发送给客户端;

  4. 客户端收到 Token 以后把它存储起来,比如放在Cookie 里或者 Local Storage 里;

  5. 客户端每次向服务端请求资源的时候需要带着服务端签发的 Token;

  6. 服务端收到请求,然后去验证客户端请求里面带着的 Token,如果验证成功,就向客户端返回请求的数据;

基于JWT的实践

下面,我们使用 Nodejs 搭建一个简单的 Token 验证。

token 生成

使用 jsonwebtoken 依赖包生成 token

const jwt = require("jsonwebtoken");
const token = jwt.sign(
      {
        data: username,
        // 设置 token 过期时间,一小时后,秒为单位
        exp: Math.floor(Date.now() / 1000) + 60 * 60
      },
      secret
    )

token 存储

客户端收到服务端发送的 token 后,将其存储到 Local Storage 里

async login() {
  // 登录请求
  const res = await axios.post("/login-token", {
    username: this.username,
    password: this.password
  });
  // 将 token 保存到 localStorage
  localStorage.setItem("token", res.data.token);
}

发送请求时携带 token

客户端每次向服务端请求资源时,带上服务端签发的 Token。在请求拦截里,给请求头设置 Authorization 来携带 token

// 请求拦截,给请求头信息设置 Authorization
axios.interceptors.request.use(
  config => {
    const token = window.localStorage.getItem("token");
    if (token) {
      // 判断是否存在token,如果存在的话,则每个http header都加上token
      // Bearer是JWT的认证头部信息
      config.headers.common["Authorization"] = "Bearer " + token;
    }
    return config;
  },
  err => {
    return Promise.reject(err);
  }
);

登录接口

router.post("/login-token", async ctx => {
  const { body } = ctx.request;

  const { username, password } = body
  // password 应根据 username 从数据库中获取
  if (username === 'test' && password === 'test') {
    ctx.body = {
      message: "登录成功",
      user: username,
      // 使用 jsonwebtoken 依赖包 生成 token 返回给客户端
      token: jwt.sign(
        {
          data: username,
          // 设置 token 过期时间,一小时后,秒为单位
          exp: Math.floor(Date.now() / 1000) + 60 * 60
        },
        secret
      )
    };
  } else {
    ctx.status = 401;
    ctx.body = { code: 0, message: '用户名或密码错误' }
  }
  
});

退出登录

客户端退出登录时只需从 localStorage 中清除 token 即可,服务端不需要任何操作

async logout() {
  // 退出登录时将 token 从 localStorage 中删除
  localStorage.removeItem("token");
}

完整代码

前端登录页面

<html>
  <head>
    <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
      <!-- 引入样式 -->
    <link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
    <!-- 引入组件库 -->
    <script src="https://unpkg.com/element-ui/lib/index.js"></script>
  </head>

  <body>
    <div id="app">


      <el-row style="margin-top: 15px ;">
        <el-input v-model="username" placeholder="请输入用户名"></el-input>
      </el-row>
      <el-row style="margin-top: 15px ;">
        <el-input v-model="password" show-password placeholder="请输入密码"></el-input>
      </el-row>
  
      <el-row style="margin-top: 15px ;">
        <el-button type="primary" plain v-on:click="login" size="small">Login</el-button>
        <el-button type="primary" plain v-on:click="logout" size="small">Logout</el-button>
        <el-button type="primary" plain v-on:click="getUser" size="small">GetUser</el-button>
        <el-button plain @click="logs=[]" size="small">Clear Log</el-button>
      </el-row>

      <!-- 日志 -->
      <ul>
        <li v-for="(log,idx) in logs" :key="idx">
          {{ log }}
        </li>
      </ul>
    </div>
    <script>
      // 请求拦截,给请求头信息设置 Authorization
      axios.interceptors.request.use(
        config => {
          const token = window.localStorage.getItem("token");
          if (token) {
            // 判断是否存在token,如果存在的话,则每个http header都加上token
            // Bearer 是JWT的认证头部信息
            config.headers.common["Authorization"] = "Bearer " + token;
          }
          return config;
        },
        err => {
          return Promise.reject(err);
        }
      );

      // 响应拦截
      axios.interceptors.response.use(
        response => {
          app.logs.push(JSON.stringify(response.data));
          return response;
        },
        err => {
          app.logs.push(err.message);
          return Promise.reject(err);
        }
      );
      var app = new Vue({
        el: "#app",
        data: {
          username: "test",
          password: "test",
          logs: []
        },
        methods: {
          async login() {
            const res = await axios.post("/login-token", {
              username: this.username,
              password: this.password
            });
            // 将 token 保存到 localStorage
            localStorage.setItem("token", res.data.token);
          },
          async logout() {
            // 退出登录时将 token 从 localStorage 中删除
            localStorage.removeItem("token");
          },
          async getUser() {
            await axios.get("/getUser-token");
          }
        }
      });
    </script>
  </body>
</html>

后端

const Koa = require('koa')
const router = require('koa-router')()
const static = require('koa-static')
const bodyParser = require('koa-bodyparser')
const app = new Koa();
const jwt = require("jsonwebtoken");
const jwtAuth = require("koa-jwt");

const secret = "it's a secret";
app.use(bodyParser())
app.use(static(__dirname + '/'));

router.post("/login-token", async ctx => {
  const { body } = ctx.request;

  const { username, password } = body
  // password 应根据 username 从数据库中获取
  if (username === 'test' && password === 'test') {
    ctx.body = {
      message: "登录成功",
      user: username,
      // 使用 jsonwebtoken 依赖包 生成 token 返回给客户端
      token: jwt.sign(
        {
          data: username,
          // 设置 token 过期时间,一小时后,秒为单位
          exp: Math.floor(Date.now() / 1000) + 60 * 60
        },
        secret
      )
    };
  } else {
    ctx.status = 401;
    ctx.body = { code: 0, message: '用户名或密码错误' }
  }
  
});

router.get(
  "/getUser-token",
  jwtAuth({
    secret
  }),
  async ctx => {
    // 验证通过,state.user
    console.log(ctx.state.user);
    
    //获取session
    ctx.body = {
      message: "获取数据成功",
      userinfo: ctx.state.user.data 
    };
  }
)

app.use(router.routes());
app.use(router.allowedMethods());
app.listen(3000)

案例GitHub地址