《琢·磨》系列技术分享:04 三种常见鉴权方式的原理及实现 ——session-cookie、JWT、OAuth

721 阅读12分钟

image.png

内容介绍:

本篇文章是《琢·磨》系列技术分享第四讲,分享三种常见鉴权方式的原理及实现,包括session-cookie、JWT、OAuth; 本篇文章使用的是koa + MongoDB + Vue实现的demo逻辑。

session-cookie

1.cookie的诞生

说到cookie的诞生,首先要说到http是个无状态协议。每个请求都是完全独立的,服务端无法确认当前访问者的身份信息,无法分辨上一次的请求发送者和这一次的发送者是不是同一个人。所以服务器与浏览器为了进行会话跟踪(知道是谁在访问我),就必须主动的去维护一个状态。为了解决会话跟踪的问题,cookie诞生了。

2.什么是cookie

cookie是由服务器发送给客户端(浏览器)的小量信息,以{key:value}的形式存在,再次请求时会再发回服务器。

3.cookie的工作原理

image.png 客户端请求服务器时,如果服务器需要记录该用户状态,就会在响应头里通过Set-cookie头部字段设置一个Cookie。而浏览器这边检查到响应头里有Set-cookie字段,就会把Cookie保存起来。当浏览器再请求 服务器时,浏览器把请求的网址连同该Cookie一同提交给服务器。服务器通过检查该Cookie来获取用户状态。

接下来做个cookie的代码演示:

const Koa  = require('koa');
const app = new Koa();

app.use(ctx => {

  // 从请求里获取cookie为sid的字段
  const cookieSid = ctx.cookies.get('sid');
  console.log('cookieSid :>> ', cookieSid);
  if (cookieSid) {
    ctx.body = `常客儿, 欢迎${cookieSid}`;
  } else {
    // Set-Cookie 约定
    // 如果没有拿到值,就给它设置一个值
    ctx.cookies.set('sid', 'yupeng');
    ctx.body = '第一次来';
  }
})

app.listen(3000);

我们运行一下,可以看到第一次的时候显示的是第一次来,并且设置了sid: 'yupeng' image.png

当我们再次刷新,因为获取到了cookie数据,所以显示常客: image.png

4.cookie的缺陷

1、通常大小4KB,太小 2、在浏览器端存储不安全,易被篡改 3、每次请求都携带,浪费带宽

为了解决上面cookie的一些问题,就有了session

5.session的工作原理

image.png 用户第一次登录后,浏览器会将用户信息发送给服务器,服务器会为该用户创建一个Session,并在响应内容(Cookie)中将SessionId一并返回给浏览器,浏览器将这些数据保存在本地。当用户再次发送请求时,浏览器会自动的把上次请求存储的Cookie数据自动的携带给服务器。 服务器接收到请求信息后,会通过浏览器请求的数据中的SessionId判断当前是哪个用户,然后根据SessionId在Session库中获取用户的Session数据返回给浏览器。

// 这个代码和上边的cookie的基本一致,只是我们创建了一个session对象来存储cookie信息,然后把key返还给客户端
const Koa  = require('koa');
const app = new Koa();

const session = {};

app.use(ctx => {
  const cookieSid = ctx.cookies.get('sid');
  console.log('cookieSid :>> ', cookieSid);
  if (cookieSid) {
    console.log('session :>> ', session[cookieSid]);
    ctx.body = `常客儿, 欢迎${session[cookieSid].name}`;
  } else {
    // Set-Cookie 约定
    const sid = (Math.random() * 1000000).toFixed();
    ctx.cookies.set('sid', sid);
    session[sid] = { name: 'yupeng' };
    ctx.body = '第一次来';
  }
})

app.listen(3000);

我们来看一下运行结果: 第一次来的时候同样设置了cookie,这里设置的值为服务端随机生成的字符串 image.png

当我们再次刷新的时候可以看到,cookie没有变化,这是服务器也已经返回了,我们存储在服务器端的session对象里的数据 image.png

6.koa中session的使用

上面了解session的工作原理,接下来我们看一下koa中,session的真实使用案例。 这里我做了一个页面计数的功能:

const Koa = require('koa');
// 引用了koa处理session的库
const session = require('koa-session');

const app = new Koa();

// 加密时所使用的key
app.keys = ['some secret'];

// sessionde的配置,key是客户端存储的cookie key值;maxAge是过期时间;httpOnly是是否只在http请求中使用,不允许客户端操作;signed是是否加密;
const SESS_CONFIG = {
  key: 'koa.sess',
  maxAge: 10000, // ms
  httpOnly: true,
  signed: false,
};

app.use(session(SESS_CONFIG, app));

app.use((ctx) => {
  // 这里做了个计数的功能,来一次累加一次
  let n = ctx.session.views || 0;
  ctx.session.views = ++n;
  ctx.body = n + ' views';
});

app.listen(3000);

我们看一下效果: image.png

7.使用koa-session实现登陆注册

接下来我们使用koa实现一个简单的登陆注册案例 先来看一下代码结构 image.png model里存储use表操作模型 utils里提供了连接数库的逻辑,和一个检测账号密码的方法 axios.min.jsvue.jsindex.html是前端使用的代码,为了便于演示,我将这个项目都设置成了一个静态资源服务器,这样就能直接访问页面了 index.js里写着服务端的逻辑

我们先来看一下model里的逻辑:

// 这里使用了mongoose库做MongoDB的操作
const mongoose = require('mongoose');
// 这里定义了表的数据模型
const schema = mongoose.Schema({
  username: String,
  password: String,
});

// 这里挂了两个方法,获取用户和设置用户
schema.statics.getUser = function(username) {
  return this.model('user')
    .findOne({ username })
    .exec();
};

schema.statics.createUser = function({ username, password }) {
  return this.model('user')
    .create({
      username,
      password,
    });
};

// 这里对表与模型做了关联
const model = mongoose.model('user', schema);

module.exports = model;

下面看一下utils里的逻辑,先是checkLogin.js

// checkLogin.js
// 这里引用了UserModel,简单的判断了下登陆的密码和数据库存的一不一样
const UserModel = require('../models/user');

exports.checkPassword =  async function ({ username, password }) {
  const res = await UserModel.getUser(username);
  if (res && res.password === password) {
    return true;
  }
  return false
}

然后是mongoose.js:

// 同样引用mongoose做处理
const mongoose = require('mongoose');

// 做了数据库的连接
mongoose.connect('mongodb://127.0.0.1:27027/loginshare', {
  useNewUrlParser: true,
  useUnifiedTopology: true,
}).catch(error => {
  console.log('数据库error', error)
});;
const conn = mongoose.connection;

conn.on('error', () => console.log('数据库连接失败'));
conn.once('open', () => console.log('数据库连接成功'));

接下来看一下服务器端的代码:

const Koa = require('koa');

// koa-router来处理路由
const router = require('koa-router')();
const session = require('koa-session');

// 用来解析post请求的数据,会挂在ctx.request.body中
const bodyParser = require('koa-bodyparser');

// 用来做静态服务的处理 
const static = require('koa-static');
require('./utils/mongoose');
const UserModel = require('./models/user');

const {
  checkPassword
} = require('./utils/checkLogin');

const app = new Koa();

app.keys = ['some secret'];

// 将根目录设置为静态服务
app.use(static(__dirname + '/'));

// 使用bodyParser中间件,解析post 
app.use(bodyParser());
app.use(session({
  key: 'koa.sess',
  maxAge: 10000,
  httpOnly: true,
  signed: false,
}, app));

// 登陆接口,密码不对提示密码错误,正确提示登陆成功,设置session信息
router.post('/login', async (ctx) => {
  const {
    body: {
      username,
      password,
    }
  } = ctx.request

  // 检验账号密码
  if (!(await checkPassword({
      username,
      password,
    }))) {
    ctx.body = {
      message: '账号或者密码不对'
    }
    return;
  }

  ctx.session.userinfo = {
    username,
    password
  };
  ctx.body = {
    message: "登录成功"
  }
})

// 注册接口,在数据库存储用户信息
router.post('/register', async (ctx, next) => {
  const {
    body: {
      username,
      password,
    }
  } = ctx.request

  await UserModel.create({
    username,
    password
  })
  ctx.body = {
    message: "注册成功",
  }
})

// 登出接口,删除session中的信息
router.post('/logout', async (ctx) => {
  //设置session
  delete ctx.session.userinfo
  ctx.body = {
    message: "登出系统"
  }
})

// 获取用户信息接口,返回用户数据
router.get('/getUser', async (ctx) => {
  ctx.body = {
    message: "获取数据成功",
    userinfo: ctx.session.userinfo
  }
})

// 这里写了个中间件,用于过滤登陆和注册不用检测登陆态,其他接口需要登陆才能使用
app.use(async (ctx, next) => {
  if (ctx.url.indexOf('login') > -1 || ctx.url.indexOf('register') > -1) {
    await next()
  } else {
    if (!ctx.session.userinfo) {
      ctx.body = {
        message: "未登陆"
      }
    } else {
      await next()
    }
  }
})

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

以上是后端逻辑,接下来看下前端逻辑,前端比较简单,就是写了一个调用后端接口的方法:

<body>
  <div id="app">
    <div>
      <input v-model="username">
      <input v-model="password">
    </div>
    <div>
      <button v-on:click="login">登陆</button>
      <button v-on:click="register">注册</button>
      <button v-on:click="logout">登出</button>
      <button v-on:click="getUser">获取用户信息</button>
    </div>
    <div>
      <button onclick="document.getElementById('log').innerHTML = ''">清除log</button>
    </div>
  </div>
  <div id="log"></div>
  </div>
  <script>
    // 这里做了一个相应的拦截,便于调试都打在页面里了
    axios.interceptors.response.use(
      response => {
        var element = document.createElement("p");
        element.innerHTML = JSON.stringify(response.data)
        document.getElementById('log').appendChild(element)
        return response;
      }
    );
    var app = new Vue({
      el: '#app',
      data: {
        username: '',
        password: ''
      },
      methods: {
        async login() {
          await axios.post('/login', {
            username: this.username,
            password: this.password
          })
        },
        async logout() {
          await axios.post('/logout')
        },
        async getUser() {
          await axios.get('/getUser')
        },
        async register() {
          await axios.post('/register', {
            username: this.username,
            password: this.password
          })
        }
      }
    });
  </script>
</body>

以上就是demo逻辑的实现,接下来看一下实现效果: sessioncookie.gif

8.session的问题

1.服务器要记录所有的session id信息,体量大的时候是不小的开销 2.扩展性不好,涉及到服务器集群的时候,就需要共享session信息

所以就衍生了jwt token这种鉴权方式,所有数据都保存在客户端,每次请求都发回服务器。

JWT (json web token)

1、token的认证流程

image.png 

1.客户端使用用户名跟密码请求登录 2.服务端收到请求,去验证用户名与密码 3.验证成功后,服务端会签发一个 Token,再把这个 Token 发送给客户端 4.客户端收到 Token 以后可以把它存储起来,比如放在 Cookie 里或者 Local Storage 里 5.客户端每次向服务端请求资源的时候需要带着服务端签发的 Token 6.服务端收到请求,然后去验证客户端请求里面带着的 Token(request头部添加Authorization),如果验证成功,就向客户端返回请求的数据,如果不成功返回401错误码,鉴权失败。

2.JWT数据结构

image.png

JWT是一个很长的字符串,中间用点(.)分隔成三个部分。注意,JWT 内部是没有换行的,这里只是为了便于展示,将它写成了几行。 JWT 的三个部分依次如下。 Header(头部) Payload(负载) Signature(签名) Header部分是一个 JSON 对象,描述 JWT 的元数据,通常是下面的样子。 { "alg": "HS256", "typ": "JWT" } alg属性表示签名的算法(algorithm),默认是 HMAC SHA256(写成 HS256);typ属性表示这个令牌(token)的类型(type),JWT 令牌统一写为JWT。 使用base64进行加密   Payload部分也是一个 JSON 对象,用来存放实际需要传递的数据。JWT 规定了7个官方字段,供选用。

iss (issuer):签发人
exp (expiration time):过期时间
sub (subject):主题
aud (audience):受众
nbf (Not Before):生效时间
iat (Issued At):签发时间
jti (JWT ID):编号

我们还可以在这个部分定义私有字段,这里我们就定义了一个data字段 JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。 这个 JSON 对象也要使用 Base64URL 算法转成字符串。   Signature部分是对前两部分的签名,防止数据篡改。 首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。 HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用"点"(.)分隔,就可以返回给用户。

3.JWT使用方式

客户端收到服务器返回的 JWT,可以储存在 Cookie 里面,也可以储存在 localStorage。

此后,客户端每次与服务器通信,都要带上这个 JWT。较好的做法是放在 HTTP 请求的头信息 Authorization 字段里面。

4.koa中JWT的使用演示

接下来我们使用JWT实现一个简单的登陆注册案例 先来看一下代码结构,和上面的cookie的基本一致,只有服务端和客户端的部分逻辑不太一样,所以我就介绍一下差异的部分index.jsindex.html image.png

先来看下index.js:

const Koa = require('koa')
const router = require('koa-router')()
const static = require('koa-static')
const bodyParser = require('koa-bodyparser')

// 这里我们引用了jsonwebtoken和koa-jwt做jwt的处理
const jwt = require("jsonwebtoken");
const jwtAuth = require("koa-jwt");
require('./utils/mongoose');
const UserModel = require('./models/user');
const {
  checkPassword
} = require('./utils/checkLogin');

const app = new Koa();

// 这里是jwt加密时候使用的秘钥
const secret = "loginshare";
app.use(bodyParser())
app.use(static(__dirname + '/'));


// 鉴权错误处理,在jwt鉴权不通过时返回401状态码,这里我们做个token验证失败的提示
app.use((ctx, next) => {
  return next().catch((err) => {
      if(err.status === 401){
          ctx.status = 200;
          ctx.body = {
            message: 'token验证失败'
          };
      }else{
          throw err;
      }
  })
})

// 这里是jwt不用鉴权的过滤
app.use(jwtAuth({
  secret
}).unless({
  path: [/^\/login/, /^\/register/]
}));

// 登陆接口
router.post("/login", async ctx => {
  const {
    body: {
      username,
      password,
    }
  } = ctx.request

  // 检验账号密码
  if (!(await checkPassword({
      username,
      password,
    }))) {
    ctx.body = {
      message: '账号或者密码不对'
    }
    return;
  }

  ctx.body = {
    message: "登录成功",
    user: username,
    // 生成 token 返回给客户端
    token: jwt.sign({
        data: username,
        // 设置 token 过期时间
        exp: Math.floor(Date.now() / 1000) + 10
      },
      secret
    )
  };
});

router.get(
  "/getUser",
  async ctx => {
    // token验证通过,会挂一个对象,state.user存着我们自定义的信息,比如上面定义的data
    console.log(ctx.state.user);

    //获取session
    ctx.body = {
      message: "获取数据成功",
      username: ctx.state.user.data
    };
  }
)

// 注册接口没变
router.post('/register', async (ctx, next) => {
  const {
    body: {
      username,
      password,
    }
  } = ctx.request

  await UserModel.create({
    username,
    password
  })
  ctx.body = {
    message: "注册成功",
  }
})

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

上面是服务端的逻辑,接下来看一下客户端的逻辑

<body>
  <div id="app">
    <div>
      <input v-model="username">
      <input v-model="password">
    </div>
    <div>
      <button v-on:click="login">登陆</button>
      <button v-on:click="register">注册</button>
      <button v-on:click="logout">登出</button>
      <button v-on:click="getUser">获取用户信息</button>
    </div>
    <div>
      <button onclick="document.getElementById('log').innerHTML = ''">清除log</button>
    </div>
  </div>
  <div id="log"></div>
  </div>
  <script>
    // 做请求拦截,在头里加token
    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;
      },
    );

    axios.interceptors.response.use(
      response => {
        var element = document.createElement("p");
        element.innerHTML = JSON.stringify(response.data)
        document.getElementById('log').appendChild(element)
        return response;
      },
    );
    var app = new Vue({
      el: '#app',
      data: {
        username: '',
        password: ''
      },
      methods: {
        async login() {
          const res = await axios.post("/login", {
            username: this.username,
            password: this.password
          });
          localStorage.setItem("token", res.data.token);
        },
        // 登出不再请求服务端,就删了本地的token存放
        async logout() {
          localStorage.removeItem("token");
        },
        async getUser() {
          await axios.get("/getUser");
        },
        async register() {
          await axios.post('/register', {
            username: this.username,
            password: this.password
          })
        }
      }
    });
  </script>
</body>

以上是客户端的逻辑,接下来看一下代码运行效果: jwt.gif

5.JWT的问题

1.JWT 默认是不加密,但也是可以生成原始 Token 再加密一次。 2.JWT 不加密的情况下,不能将秘密数据写入 JWT。 3.JWT 的最大缺点是,由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑。 4.JWT 本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。为了减少盗用,JWT 的有效期应该设置得比较短。对于一些比较重要的权限,使用时应该再次对用户进行认证。 5.为了减少盗用,JWT 不应该使用 HTTP 协议明码传输,要使用 HTTPS 协议传输。

OAuth

1.什么是OAuth

OAuth 就是一种授权机制。数据的所有者告诉系统,同意授权第三方应用进入系统,获取这些数据。系统从而产生一个短期的进入令牌(token),用来代替密码,供第三方应用使用。

OAuth 的核心就是向第三方应用颁发令牌

2.OAuth授权码方式

授权码(authorization code)方式,指的是第三方应用先申请一个授权码,然后再用该码获取令牌。

这种方式是最常用的流程,安全性也最高,它适用于那些有后端的 Web 应用。授权码通过前端传送,令牌则是储存在后端,而且所有与资源服务器的通信都在后端完成。这样的前后端分离,可以避免令牌泄漏

3.授权码的认证流程

image.png 第一步,琢磨提供一个链接,用户点击后就会跳转到 github;

第二步,用户跳转后,github会要求用户登录,然后询问是否同意给予琢磨授权。用户表示同意,这时github就会跳回redirect_uri参数指定的网址。跳转时,会传回一个授权码

第三步,琢磨拿到授权码以后,就可以在后端,向 github请求令牌。

第四步,github收到请求以后,就会颁发令牌。具体做法是向redirect_uri指定的网址,发送一段 JSON 数据。

4.授权码认证方式演示

OAuth的demo就比较简单了,不需要数据库做一些存储,所以只用到index.jsindex.html

先来看一下服务端的逻辑index.js

const Koa = require('koa')
const router = require('koa-router')()
const static = require('koa-static')
const app = new Koa();

// 因为需要向github发请求,所以引了个axios库
const axios = require('axios')

// 引了个query解析的库
const querystring = require('querystring')

app.use(static(__dirname + '/'));

// https://github.com/settings/applications/new
// 这里是我们在github申请到的账号和秘钥,用来申请认证
const config = {
  client_id: '509844df45c6e1cf6784',
  client_secret: 'd8c896d68c6dadd2597a5f94ec70cf8e9ccd3816',
}

// 这里定义了一个github登陆的接口,在登陆的时候我们跳转到github,获取授权码
router.get('/auth/github/login', async (ctx) => {
  //重定向到认证接口,并配置参数
  var path = `https://github.com/login/oauth/authorize?${querystring.stringify({ client_id: config.client_id })}`;

  //转发到授权服务器
  ctx.redirect(path);
})

// 这里是github返回授权码的会跳地址,github为什么知道要会跳这个地址呢?是因为我们在线上申请账号密码的时候设置了哈
router.get('/auth/github/callback', async (ctx) => {
  console.log('callback..')
  
  // 这里拿到授权码
  const code = ctx.query.code;
  const params = {
    client_id: config.client_id,
    client_secret: config.client_secret,
    code: code
  }

  // 使用授权码去请求token
  let res = await axios.post('https://github.com/login/oauth/access_token', params)
  const access_token = querystring.parse(res.data).access_token

  // 这里设计的是在返回token后,把token存在本地,然后关闭掉当前申请授权的页面
  ctx.response.type = 'html';
  ctx.response.body = ` <script>window.localStorage.setItem("authSuccess","true");window.localStorage.setItem("token","${access_token}");window.close();</script>`;
})

router.get('/auth/github/userinfo', async (ctx) => {
  
  // 通过github下发的token去获取用户信息
  res = await axios.get('https://api.github.com/user?access_token=' + ctx.request.header.authorization)
  console.log('userAccess:', res.data)
  ctx.body = res.data
})

app.use(router.routes()); /*启动路由*/
app.use(router.allowedMethods());
app.listen(3000);

以上是服务端的代码,接下来看一下客户端的逻辑:

<body>
  <div id="app">
    <button @click='oauth()'>Github登陆</button>
    <div v-if="userInfo">
      Hello {{userInfo.login}}
      <img :src="userInfo.avatar_url" />
    </div>
  </div>
  <script>

  </script>
  <script>
    axios.interceptors.request.use(
      config => {
        const token = window.localStorage.getItem("token");
        if (token) {
          // 判断是否存在token,如果存在的话,则每个http header都加上token
          // Bearer,这里没有加这个前缀了,因为不是jwt不需要了
          config.headers.common["Authorization"] = token;
        }
        return config;
      },
      err => {
        return Promise.reject(err);
      }
    );
    
    var app = new Vue({
      el: "#app",
      data: {
        userInfo: null
      },
      methods: {
        async oauth() {
          // 在请求授权时,我们新开一个页面,然后当前页面不断轮训,当服务端设置好了token信息,就去请求用户数据
          window.open('/auth/github/login', '_blank')
          const intervalId = setInterval(() => {
            console.log("等待认证中..");
            if (window.localStorage.getItem("authSuccess")) {
              clearInterval(intervalId);
              window.localStorage.removeItem("authSuccess");
              this.getUser()
            }
          }, 500);
        },
        async getUser() {
          const res = await axios.get("/auth/github/userinfo");
          console.log('res:', res.data)
          this.userInfo = res.data
        }
      }
    });
  </script>
</body>

接下来我们看一下demo效果: github.gif

参考文献

  1. juejin.cn/post/684490…

  2. juejin.cn/post/684490…

  3. www.ruanyifeng.com/blog/2018/0…

  4. www.ruanyifeng.com/blog/2019/0…

以上就是本期的全部分享了,希望可以对各位伙伴有所帮助!