鉴权实战 - Koa

2023-05-17 16:41:04 浏览数 (4)

# Session/Cookie

# cookie 是如何工作的

代码语言:javascript复制
// cookie.js
const http = require('http');

http
  .createServer((req, res) => {
    if (req.url === '/favicon.ico') {
      res.end('favicon.ico');
      return;
    }
    // 获取 Cookie
    console.log(`cookies: ${req.headers.cookie}`);

    // 设置 Cookie
    res.setHeader('Set-Cookie', 'cookie1=a');
    res.end('Hello Cookie');
  })
  .listen(3000);

cookie 缺点

  • 空间太小
  • 不安全

# 简易 session 实现

session 会话机制是一种服务器端机制,使用类似于哈希表的结构来保存信息 实现原理:

  1. 服务器在接受客户端首次访问时在服务器端创建 session,然后保存 session (保存在内存或 redis 中),然后给这个 session 生成一个唯一的标识字符串(uuid),然后在响应头中设置该 uuid
  2. 签名,通过密钥对 sid 进行签名处理,避免客户端修改 sid (非必需步骤)
  3. 浏览器中收到请求响应的时候解析响应头,然后将 sid 保存在本地 cookie 中,浏览器下次发起 http 请求时会带上该域名下的 cookie 信息
  4. 服务器在接受客户端请求时会解析请求头 cookie 中的 sid,然后根据这个 sid 去找服务器端保存的该客户端的 session,然后判断请求是否合法
代码语言:javascript复制
// cookie.js
const http = require('http');

const session = {};

http
  .createServer((req, res) => {
    if (req.url === '/favicon.ico') {
      res.end('favicon.ico');
      return;
    }
    // 获取 Cookie
    console.log(`cookies: ${req.headers.cookie}`);

    const sessionKey = 'sid';
    const cookie = req.headers.cookie;

    if (cookie && cookie.indexOf(sessionKey) > -1) {
      res.end('Second Request');
      // 获取 Cookie 中的信息
      const pattern = new RegExp(`${sessionKey}=([^;] );?s*`);
      const sid = pattern.exec(cookie)[1];
      console.log(`session: ${sid}, ${JSON.stringify(session[sid])}`);
    } else {
      const sid = (Math.random() * 999999).toFixed();
      // 设置 Cookie
      res.setHeader('Set-Cookie', `${sessionKey}=${sid}`);
      session[sid] = { name: 'cell' };
      res.end('Hello Session');
    }
  })
  .listen(3000);

# 在 koa 中使用 session

代码语言:javascript复制
// app.js
const koa = require('koa');
const session = require('koa-session');

const app = new koa();

// 签名 key
app.keys = ['my secret'];

// 配置项
const SESS_CONFIG = {
  key: 'cilab:sess', // cookie 键名
  maxAge: 24 * 60 * 60 * 1000, // 1 day
  httpOnly: true, // 仅限服务器修改 
  signed: true // 对 cookie 进行签名
};

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

app.use(ctx => {
  if (ctx.path === '/favicon.ico') return;
  // 获取
  let n = ctx.session.count || 0;

  // 设置
  ctx.session.count =   n;
  ctx.body = `request ${n} times`;
});

app.listen(3000);

使用 redis 进行持久化处理

代码语言:javascript复制
// app.js
const koa = require('koa');
const session = require('koa-session');
const redis = require('redis');
const redisStore = require('koa-redis');
const wrapper = require('co-redis');

const app = new koa();

const redisClient = redis.createClient(6379, 'localhost');
const client = wrapper(redisClient);

// 签名 key
app.keys = ['my secret'];

app.use(session({
  key: 'cilab:sess',
  store: redisStore({ client })
}, app));

app.use(async (ctx, next) => {
  const keys = await client.keys('*');
  keys.forEach(async key => {
    console.log(await client.get(key));
  });
  await next();
});

app.use(ctx => {
  if (ctx.path === '/favicon.ico') return;
  // 获取
  let n = ctx.session.count || 0;

  // 设置
  ctx.session.count =   n;
  ctx.body = `request ${n} times`;
});

app.listen(3000);

# session-cookie 方案在 Koa 中实践

前端页面 index.html

代码语言:javascript复制
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
  <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
<body>
  <div id="app">
    <div>
      <input v-model="username">
      <input v-model="password">
    </div>
    <div>
      <button @click="login">Login</button>
      <button @click="logout">Logout</button>
      <button @click="getUser">Get User</button>
    </div>
    <div>
      <button onclick="document.getElementById('log').innerHTML = ''">Clear Log</button>
    </div>
    <h6 id="log"></h6>
  </div>
  <script>
    axios.defaults.widthCredentials = true;
    axios.interceptors.response.use(res => {
      document.getElementById('log').append(JSON.stringify(res.data));
      return res;
    });
    const app = new Vue({
      el: '#app',
      data: {
        username: 'cell',
        password: 'pwd',
      },
      methods: {
        async login() {
          await axios.post('/users/login', {
            username: this.username,
            password: this.password
          });
        },
        async logout() {
          await axios.post('/users/logout');
        },
        async getUser() {
          await axios.get('/users/getUser');
        }
      }
    });
  </script>
</body>
</html>

服务端 index.js

代码语言:javascript复制
const Koa = require('koa');
const router = require('koa-router')();
const session = require('koa-session');
const cors = require('koa2-cors');
const bodyParser = require('koa-bodyparser');
const static = require('koa-static');

const app = new Koa();

app.use(cors({
  credentials: true,
}));
app.keys = ['my secret'];

app.use(static(__dirname   '/'));
app.use(bodyParser());
app.use(session(app));

app.use((ctx, next) => {
  if (ctx.url.indexOf('login') > -1) {
    next();
  } else {
    console.log('session', ctx.session.userinfo);
    if (!ctx.session.userinfo) {
      ctx.body = {
        message: 'Login Failed',
      };
    } else {
      next();
    }
  }
});

router.post('/users/login', async ctx => {
  const { body } = ctx.request;
  console.log('body', body);
  // 设置 session
  ctx.session.userinfo = body.username;
  ctx.body = {
    message: 'Login Successful'
  };
});

router.post('/users/logout', async ctx => {
  // 设置 session
  delete ctx.session.userinfo;
  ctx.body = {
    message: 'Logout Successful'
  };
});

router.get('/users/getUser', async ctx => {
  ctx.body = {
    message: 'Get Data Successful',
    userinfo: ctx.session.userinfo,
  };
});

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

过程描述:

  1. 用户登录时,服务端生成一个唯一的会话标识,并以该标识作为 key 存储相关数据
  2. 会话标识在客户端和服务端之间通过 cookie 进行传输
  3. 服务端通过会话标识可以获取到会话相关信息,然后对客户端的请求进行响应;如果找不到有效的会话标识,就判定用户是未登录状态
  4. 会话有过期时间,也可以通过一些操作(如退出登录)主动删除

# Token 验证

session 不足:

  • 服务端有状态需要维护
  • 依赖 cookie ,APP 或 跨域处理复杂

# JWT (JSON Web Token)

Bearer token 组成:Header、payload(载荷)、Signature(签名) Header.Payload.Signature

Header:JSON 对象,描述 JWT 的元数据,用 Base64URL 算法转成字符串

代码语言:javascript复制
{
  "alg": "HS256", // 签名算法,默认是 HMAC SHA256(写成 HS256)
  "typ": "JWT" // Token 类型
}

Payload: JSON 对象,用来存放实际需要传递的数据。JWT 规定了7个官方字段,供选用,也可以定义私有字段。JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。使用 Base64URL 算法转成字符串

字段名称

说明

iss (issuer)

签发人

exp (expiration time)

过期时间

sub (subject)

主题

aud (audience)

受众

nbf (Not Before)

生效时间

iat (Issued At)

签发时间

jti (JWT ID)

编号

Signature:对前两部分的签名,防止数据篡改。签名密钥只有服务器知道

JWT 特点

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

JWT 生效过程梳理

代码语言:javascript复制
// jwt.js
const jsonwebtoken = require('jsonwebtoken');

// 签名密钥
const secret = 'hello';

// 数据
const user = {
  username: 'cell',
  password: 'pwd',
};

// 计算token
const token = jsonwebtoken.sign({
  data: user,
  exp: Math.floor(Date.now() / 1000)   (60 * 60),
}, secret);

console.log(token);
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7InVzZXJuYW1lIjoiY2VsbCIsInBhc3N3b3JkIjoicHdkIn0sImV4cCI6MTYxNTAzNDk3OCwiaWF0IjoxNjE1MDMxMzc4fQ.iZAHDN6a8eTBcB6a6reeNLgDD-tI9g7CBvfBh9b5Ivs

// 解算 验证
console.log('解码', jsonwebtoken.verify(token, secret));
// 解码 {
//   data: { username: 'cell', password: 'pwd' },
//   exp: 1615035042,
//   iat: 1615031442
// }

# 简单 token 使用流程

前端页面 index.html

代码语言:javascript复制
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js">
  </script>
  <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
<body>
  <div id="app">
    <div>
      <input v-model="username">
      <input v-model="password">
    </div>
    <div>
      <button @click="login">Login</button>
      <button @click="logout">Logout</button>
      <button @click="getUser">Get User</button>
    </div>
    <div>
      <button @click="logs=[]">Clear Log</button>
    </div>
    <ul>
      <li v-for="(log, idx) in logs" :key="idx">{{log}}</li>
    </ul>
  </div>
  <script>
    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(res => {
      app.logs.push(JSON.stringify(res.data));
      return res;
    }, err => {
      app.logs.push(JSON.stringify(err.data));
      return Promise.reject(err);
    });
    const app = new Vue({
      el: '#app',
      data: {
        username: 'cell',
        password: 'pwd',
        logs: [],
      },
      methods: {
        async login() {
          const res = await axios.post('/users/login-token', {
            username: this.username,
            password: this.password
          });
          localStorage.setItem('token', res.data.token);
        },
        async logout() {
          localStorage.removeItem('token');
        },
        async getUser() {
          await axios.get('/users/getUser-token');
        }
      }
    });
  </script>
</body>
</html>

服务端 index.js

代码语言:javascript复制
const Koa = require('koa');
const router = require('koa-router')();

const jwt = require('jsonwebtoken');
const jwtAuth = require('koa-jwt');
const bodyParser = require('koa-bodyparser');
const static = require('koa-static');

const secret = 'my-secret';
const app = new Koa();
app.keys = ['my secret'];

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

router.post('/users/login-token', async ctx => {
  const { body } = ctx.request;
  const userinfo = body.username;
  ctx.body = {
    message: 'Login Successful',
    user: userinfo,
    // 生成 token 返回给客户端
    token: jwt.sign({
      data: userinfo,
      exp: Math.floor(Date.now() / 1000)   60 * 60, // 一小时后过期
    }, secret)
  };
});

router.get('/users/getUser-token',
  jwtAuth({
    secret
  }),
  async ctx => {
    // 验证通过
    console.log(ctx.state.user);
    // 获取 信息
    ctx.body = {
      message: 'Get Data Successful',
      userinfo: ctx.state.user.data
    };
});

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

# OAuth

使用 github OAuth 实现用户登录 前端页面 index.html

代码语言:javascript复制
<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>

</head>

<body>
  <div id="app">
    <button @click='oauth()'>Login with Github</button>
    <div v-if="userInfo">
      Hello {{userInfo.name}}
      <img style="width: 50px;height: 50px;" :src="userInfo.avatar_url" />
    </div>
    <ul>
      <li v-for="(log, idx) in logs" :key="idx">{{log}}</li>
    </ul>
  </div>
  <script>

  </script>
  <script>
    axios.interceptors.request.use(
      config => {
        const token = window.localStorage.getItem("token");
        if (token) {
          // 判断是否存在 token,如果存在的话,则每个 http header 都加上 token
          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(JSON.stringify(err));
        return Promise.reject(err);
      }
    );
    const app = new Vue({
      el: "#app",
      data: {
        logs: [],
        userInfo: null
      },
      methods: {
        async oauth() {
          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>

</html>

服务端 index.js

代码语言:javascript复制
const Koa = require('koa')
const router = require('koa-router')()
const static = require('koa-static')
const app = new Koa();
const axios = require('axios')
const querystring = require('querystring')
const jwt = require("jsonwebtoken");
const jwtAuth = require("koa-jwt");

const accessTokens = {}

const secret = "my secret";
app.use(static(__dirname   '/'));

// 从 github 注册 Oauth application 后获取 client_id 和 client_secret
const config = {
  client_id: '73a4f730f2e8cf7d5fcf',
  client_secret: '74bde1aec977bd93ac4eb8f7ab63352dbe03ce48',
}

router.get('/auth/github/login', async (ctx) => {
    // 重定向到认证接口,并配置参数
    const path = `https://github.com/login/oauth/authorize?${querystring.stringify({ client_id: config.client_id })}`;

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

// Authorization callback URL
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
    }
    let res = await axios.post('https://github.com/login/oauth/access_token', params)
    const access_token = querystring.parse(res.data).access_token
    const uid = Math.random() * 99999
    accessTokens[uid] = access_token

    const token = jwt.sign(
        {
            data: uid,
            // 设置 token 过期时间,一小时后,秒为单位
            exp: Math.floor(Date.now() / 1000)   60 * 60
        },
        secret
    )
    ctx.response.type = 'html';
    console.log('token:', token)
    ctx.response.body = ` <script>window.localStorage.setItem("authSuccess","true");window.localStorage.setItem("token","${token}");window.close();</script>`;
})

router.get('/auth/github/userinfo', jwtAuth({
    secret
}), async (ctx) => {
    // 验证通过,state.user
    console.log('jwt playload:', ctx.state.user)
    const access_token = accessTokens[ctx.state.user.data]
    res = await axios.get('https://api.github.com/user?access_token='   access_token)
    console.log('userAccess:', res.data)
    ctx.body = res.data
})

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

0 人点赞