# Node 鉴权

常见的鉴权方式:

  • Session/Cookie
  • Token
  • OAuth
  • SSO (单点方式)

# 一、Session/Cookie方式

# 1、cookie原理解析

// cookie.js
const http = require("http")
http.createServer((req, res) => {
    if(req.url === '/favicon.ico'){
        res.end('')
        return
    }
    // 观察cookie存在
    console.log('cookie:', req.headers.cookie)
    // 设置cookie
    res.setHeader('Set-Cookie', 'cookie1=abc;')
    res.end('hello cookie!!')
})
.listen(3000)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
  • Header Set-Cookie负责设置cookie
  • 请求传递Cookie

# 2、session的原理解析

session称为“会话控制”。Session 对象存储特定用户会话所需的属性及配置信息。说白了session就是一种可以维持服务器端的数据存储技术。session主要有以下的这些特点:

  1. session保存的位置是在服务器端
  2. session一般来说是要配合cookie使用,如果是浏览器禁用了cookie功能,也就只能够使用URL重写来实现session存储的功能
  3. 单纯的使用session来维持用户状态的话,那么当同时登录的用户数量较多的时候,或者存在较多的数量的session会导致查询慢的问题

本质上:session技术就是一种基于后端有别于数据库的临时存储数据的技术。

# 实现原理

  1. 服务器在接受客户端首次访问时在服务器端创建seesion,然后保存seesion(我们可以将seesion保存在 内存中,也可以保存在redis中,推荐使用后者),然后给这个session生成一个唯一的标识字符串,然后在 响应头中种下这个唯一标识字符串。
  2. 签名。这一步通过秘钥对sid进行签名处理,避免客户端修改sid。(非必需步骤)
  3. 浏览器中收到请求响应的时候会解析响应头,然后将sid保存在本地cookie中,浏览器在下次http请求的 请求头中会带上该域名下的cookie信息,
  4. 服务器在接受客户端请求时会去解析请求头cookie中的sid,然后根据这个sid去找服务器端保存的该客 户端的session,然后判断该请求是否合法。

image

const http = require('http')
const session = {}
http.createServer((req, res) => {
    const sessionKey = 'sid'

    if (req.url === '/favicon.ico') {
        return
    } else {
        const cookie = req.headers.cookie
        if (cookie && cookie.indexOf(sessionKey) > -1) {
            res.end('Come Back')
            console.log('cookie:', req.headers.cookie)
            // 取到sessionid
            const pattern = new RegExp(`${sessionKey}=([^;]+);?\s*`)
            const sid = pattern.exec(cookie)[1]
            console.log('session:', sid, session, session[sid])
        } else {
            // 生成sessionid
            const sid = (Math.random() * 9999999).toFixed()
            res.setHeader('Set-Cookie', `${sessionKey}=${sid}`)
            session[sid] = { name: 'laowang' }
            res.end('hello cookie')
        }
        
    }

}).listen(3000)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

# 3、koa中的session使用

const koa = require('koa')
const app = new koa()
const session = require('koa-session')

app.keys = ['some secret']

const SESS_CONFIG = {
    key: 'test', // 名
    maxAge: 8640000, // 有效期
    httpOnly: true, // 服务器有效
    signed: true // 签名
}

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 = '第' + n + '次访问'
})
app.listen(3000)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 4、使用redis存储session

安装redis参考

# 使用redis

const redis = require('redis')
const client = redis.createClient(6379,'localhost')

client.set('hello','hahaha')
client.get('hello',function(err,v){
    console.log('redis key:',v)
})
1
2
3
4
5
6
7

命令行用 redis-cli 命令访问

# 用redis存储session

const koa = require('koa')
const app = new koa()
const session = require('koa-session')
const redisStore = require('koa-redis')
const redis = require('redis')
const redisClient = redis.createClient(6379, 'localhost')
const wrapper = require('co-redis')
const client = wrapper(redisClient)

app.keys = ['some secret']

const SESS_CONFIG = {
    key: 'myredis:sess', // 名
    // maxAge: 8640000, // 有效期
    // httpOnly: true, // 服务器有效
    // signed: true // 签名
    store: redisStore({ client })
}
app.use(session(SESS_CONFIG, app))
app.use(ctx => {
    // 查看redis
    redisClient.keys('*',(err,keys) => {
        console.log('keys:',keys)
        keys.forEach(key => {
            redisClient.get(key, (err,val) => {
                console.log(val)
            })
        })
    })
    if (ctx.path === '/favicon.ico') return
    let n = ctx.session.count || 0
    ctx.session.count = ++n
    ctx.body = '第' + n + '次访问'
})
app.listen(3000)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

# 5、使用session/cookie实现鉴权

// index.js
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 koaStatic = require('koa-static')
const app = new Koa();

//配置session的中间件
app.use(cors({ credentials: true }))
app.keys = ['some secret'];

app.use(koaStatic(__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: "登录失败" }
        } else {
            next()
        }
    }
})

router.post('/login', async (ctx) => {
    const { body } = ctx.request
    console.log('body', body)
    //设置session
    ctx.session.userinfo = body.username;
    ctx.body = { message: "登录成功" }
})
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(router.routes());
app.use(router.allowedMethods());
app.listen(3000);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

# 二、Token 验证

session不足:

  • 有很多服务器的时候不方便
  • 不灵活,如果应用是APP,则没有cookie
  • 跨域

# 1、Token原理

image

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

# 2、JWT Token

JSON Web Token(缩写 JWT)是目前最流行的跨域认证解决方案。

# 为什么要有JWT

互联网服务离不开用户认证。一般流程是下面这样。

  1. 用户向服务器发送用户名和密码。
  2. 服务器验证通过后,在当前对话(session)里面保存相关数据,比如用户角色、登录时间等等。
  3. 服务器向用户返回一个 session_id,写入用户的 Cookie。
  4. 用户随后的每一次请求,都会通过 Cookie,将 session_id 传回服务器。
  5. 服务器收到 session_id,找到前期保存的数据,由此得知用户的身份。

这种模式的问题在于,扩展性(scaling)不好。单机当然没有问题,如果是服务器集群,或者是跨域的服务导向架构,就要求 session 数据共享,每台服务器都能够读取 session。

举例来说,A 网站和 B 网站是同一家公司的关联服务。现在要求,用户只要在其中一个网站登录,再访问另一个网站就会自动登录,请问怎么实现?

一种解决方案是 session 数据持久化,写入数据库或别的持久层。各种服务收到请求后,都向持久层请求数据。这种方案的优点是架构清晰,缺点是工程量比较大。另外,持久层万一挂了,就会单点失败。

另一种方案是服务器索性不保存 session 数据了,所有数据都保存在客户端,每次请求都发回服务器。JWT 就是这种方案的一个代表。

# jwt原理

JWT 的原理是,服务器认证以后,生成一个 JSON 对象,发回给用户,就像下面这样。

{
  "姓名": "张三",
  "角色": "管理员",
  "到期时间": "2018年7月1日0点0分"
}
1
2
3
4
5

以后,用户与服务端通信的时候,都要发回这个 JSON 对象。服务器完全只靠这个对象认定用户身份。为了防止用户篡改数据,服务器在生成这个对象的时候,会加上签名(详见后文)。

服务器就不保存任何 session 数据了,也就是说,服务器变成无状态了,从而比较容易实现扩展。

# jwt具体实现

Bearer Token包含三个组成部分:Header(令牌头)、payload(载荷)、Signature(签名)

默认使用base64对令牌头和载荷编码,使用hs256算法对令牌头、载荷和密钥进行加密生成签名。

例如下面这个token,该token是base64编码,可逆的。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjoidGVzdCIsImV4cCI6MTU3MzM4NDU3NiwiaWF0IjoxNTczMzgwOTc2fQ.FWfeKQY00Pxee71HsCqJBwyiWCLlVjeGhR-ctO5Y2-U
1

它是一个很长的字符串,中间用点(.)分隔成三部分:

  • eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
  • eyJkYXRhIjoidGVzdCIsImV4cCI6MTU3MzM4NDU3NiwiaWF0IjoxNTczMzgwOTc2fQ
  • pjGaxzX2srG_MEZizzmFEy7JM3t8tjkiu3yULgzFwUk

JWT 的三个部分依次如下。

Header(令牌头)
Payload(载荷)
Signature(签名)
1
2
3

写成一行,就是下面的样子。

Header.Payload.Signature
1

其中前两部分,我们可以用base64解码器进行解码,会得到:

  • {"alg":"HS256","typ":"JWT"}
  • {"data":"test","exp":1573384576,"iat":1573380976}

第一部分是jwt的头部Header,alg属性表示签名的算法(algorithm),默认是 HMAC SHA256(写成 HS256);typ属性表示这个令牌(token)的类型(type),JWT 令牌统一写为JWT。最后,将上面的 JSON 对象使用 Base64URL 算法(详见后文)转成字符串。

第二部分是 Payload ,也是一个 JSON 对象,用来存放实际需要传递的数据。JWT 规定了7个官方字段,供选用。除了官方字段,你还可以在这个部分定义私有字段。

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

注意,JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。这个 JSON 对象也要使用 Base64URL 算法转成字符串。

第三部分是对前两部分的签名Signature ,防止数据篡改。首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)
1
2
3
4

算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用"点"(.)分隔,就可以返回给用户。

验证方法:默认使用hs256算法对令牌中数据签名并将结果和令牌中的签名比对。

// jsonwebtoken.js
const jsonwebtoken = require('jsonwebtoken')
const secret = '12345678'
const opt = {
  secret: 'jwt_secret',
  key: 'user'
}
const user = {
  username: 'abc',
  password: '111111'
}

const token = jsonwebtoken.sign({
  data: user,
  // 设置 token 过期时间
  exp: Math.floor(Date.now() / 1000) + (60 * 60), 
}, secret)

console.log('生成token:' + token)
// 生成token:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7InVzZXJuYW1lIjoiYWJjIiwicGFzc3dvcmQiOiIxMTExMTEifSwiZXhwIjoxNTQ2OTQyMzk1LCJpYXQiOjE1NDY5Mzg3OTV9.VPBCQgLB7XPBq3RdHK9WQMkPp3dw65JzEKm_LZZjP9Y
console.log('解码:', jsonwebtoken.verify(token, secret, opt))
// 解码: { data: { username: 'abc', password: '111111' },
//  exp: 1546942395,
//  iat: 1546938795 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

# JWT 的使用方式

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

此后,客户端每次与服务器通信,都要带上这个 JWT。你可以把它放在 Cookie 里面自动发送,但是这样不能跨域,所以更好的做法是放在 HTTP 请求的头信息Authorization字段里面。

Authorization: Bearer <token>
1

另一种做法是,跨域的时候,JWT 就放在 POST 请求的数据体里面。

# JWT 的几个特点

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

# 3、案例

index.html

<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">
      <div>
        <input v-model="username" />
        <input v-model="password" />
      </div>
      <div>
        <button v-on:click="login">Login</button>
        <button v-on:click="logout">Logout</button>
        <button v-on:click="getUser">GetUser</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(
        response => {
          app.logs.push(JSON.stringify(response.data));
          return response;
        },
        err => {
          app.logs.push(JSON.stringify(response.data));
          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
            });
            localStorage.setItem("token", res.data.token);
          },
          async logout() {
            localStorage.removeItem("token");
          },
          async getUser() {
            await axios.get("/getUser-token");
          }
        }
      });
    </script>
  </body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79

index.js

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;
  //省略登录逻辑
  //设置session
  const userinfo = body.username;
  ctx.body = {
    message: "登录成功",
    user: userinfo,
    // 生成 token 返回给客户端
    token: jwt.sign(
      {
        data: userinfo,
        // 设置 token 过期时间,一小时后,秒为单位
        exp: Math.floor(Date.now() / 1000) + 60 * 60
      },
      secret
    )
  };
});

router.get("/getUser-token", jwtAuth({ secret }), async ctx => {
  // 验证通过,jwtAuth会把信息放在ctx.state.user里面,通过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);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

# 三、OAuth

OAuth 是一种授权机制。数据的所有者告诉系统,同意授权第三方应用进入系统,获取这些数据。系统从而产生一个短期的进入令牌(token),用来代替密码,供第三方应用使用。OAuth协议为用户资源的授权提供了一个安全的、开放而又简易的标准。与以往的授权方式不同之处是OAUTH的授权不会使第三方触及到用户的帐号信息(如用户名与密码),即第三方无需使用用户的用户名与密码就可以申请获得该用户资源的授权,因此OAUTH是安全的。参考 OAuth

# 令牌和密码的区别

令牌(token)与密码(password)的作用是一样的,都可以进入系统,但是有三点差异。

(1)令牌是短期的,到期会自动失效,用户自己无法修改。密码一般长期有效,用户不修改,就不会发生变化。

(2)令牌可以被数据所有者撤销,会立即失效。

(3)令牌有权限范围(scope),对于网络服务来说,只读令牌就比读写令牌更安全。密码一般是完整权限。

上面这些设计,保证了令牌既可以让第三方应用获得权限,同时又随时可控,不会危及系统安全。这就是 OAuth 2.0 的优点。

注意,只要知道了令牌,就能进入系统。系统一般不会再次确认身份,所以令牌必须保密,泄漏令牌与泄漏密码的后果是一样的。 这也是为什么令牌的有效期,一般都设置得很短的原因。

# 案例:github授权登陆

首先申请一个应用:https://github.com/settings/applications/1167994 ,拿到client_id 和 client_secret。

index.html

<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">
      <a href='/github/login'>login with github</a>
  </div>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11

index.js

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')

app.use(static(__dirname + '/'));
const config = {
    client_id: 'xxxxx',
    client_secret: 'xxxxx'
}

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

    //转发到授权服务器
    ctx.redirect(path);
})
router.get('/github/callback', async (ctx) => {
    // 回调里面拿到code
    const code = ctx.query.code;
    console.log('code', code)
    const params = {
        client_id: config.client_id,
        client_secret: config.client_secret,
        code: code
    }
    // 用code去申请令牌
    let res = await axios.post('https://github.com/login/oauth/access_token', params)
    const access_token = querystring.parse(res.data).access_token
    // 用令牌去拿用户信息
    res = await axios.get('https://api.github.com/user?access_token=' + access_token)
    console.log('userAccess:', res.data)
    ctx.body = `
        <h1>Hello ${res.data.login}</h1>
        <img src="${res.data.avatar_url}" alt=""/>
    `
})

app.use(router.routes()); /*启动路由*/
app.use(router.allowedMethods());
app.listen(3000);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

# 四、SSO

SSO(单点登录),是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统的保护资源,若用户在某个应用系统中进行注销登录,所有的应用系统都不能再直接访问保护资源,像一些知名的大型网站,如:淘宝与天猫、新浪微博与新浪博客等都用到了这个技术。SSO单点登陆分3种情况。

  • 同一个域名不同站点

这种情况默认不需要做任何处理。

  • 主域相同但子域不同

设置cookie和sessionId的所属域为主域。

  • 不同域

这种方式需要我们借助一个单独的SSO服务,专门做验证用。而且我们还需要对于不同的站点的用户要有一个统一的用户数据。相对于前一种方式——浏览器需要存储每个站点的cookie——来说,这种方式浏览器只需要存储SSO服务站点的cookie信息,将这个cookie信息用于其他站点从而实现单点登录。

image

SSO单点登录三种情况的实现方式详解

Last Updated: 4/12/2020, 3:36:17 PM