乐趣区

聊聊鉴权那些事

在系统级项目开发时常常会遇到一个问题就是鉴权,身为一个前端来说可能我们距离鉴权可能比较远,一般来说我们也只是去应用,并没有对权限这一部分进行深入的理解。

什么是鉴权

鉴权:是指验证用户是否拥有访问系统的权利。传统的鉴权是通过密码来验证的。这种方式的前提是,每个获得密码的用户都已经被授权。在建立用户时,就为此用户分配一个密码,用户的密码可以由管理员指定,也可以由用户自行申请。这种方式的弱点十分明显:一旦密码被偷或用户遗失密码,情况就会十分麻烦,需要管理员对用户密码进行重新修改,而修改密码之前还要人工验证用户的合法身份。— 节选自百度百科

上述简单扼要的说明了一下鉴权的概念,但是这也只是简单的鉴权,也是项目中最最常见的及安全形式了,但是对于后端鉴权又是如何去做的,我们仍是一无所知,一般来说对于后端来说,鉴权最长见的方式分为三种:

  1. Session/Cookie
  2. Token 或 Jwt
  3. OAuth

这种授权方式是浏览器遵守 http 协议实现的基本授权方式,HTTP协议进行通信的过程中,HTTP协议定义了基本认证认证允许 HTTP 服务器对客户端进行用户身份证的方法。接下来就一一介绍一下这三种鉴权方式。

Session/Cookie

Cookie是一个非常具体的东西,指的就是浏览器里面能永久存储的一种数据,仅仅是浏览器实现的一种数据存储功能。Cookie由服务器生成,发送给浏览器,浏览器把 CookieKV形式保存到某个目录下的文本文件内,下一次请求同一网站时会把该 Cookie 发送给服务器。由于 Cookie 是存在客户端上的,所以浏览器加入了一些限制确保 Cookie 不会被恶意使用,同时不会占据太多磁盘空间,所以每个域的 Cookie 数量是有限的。

Cookie.js

const Http = require("http");
const app = Http.createServer((req,res) => {if(req.url === "/favicon.ico"){return;}else{res.setHeader("Set-Cookie","cx=Segmentfault");
        res.end("hello cookie");
    };
});
app.listen(3000);

使用 node Cookie.js 运行上面代码,等程序启动后访问 http://localhost:3000/,就可以看到hello cookie 字样,这样的话就代表该服务已经启动了。若想查看到到我们所设置的 Cookie,首先观察一下在NetworkResponse Headers中,可以看到我们所写的 Set-Cookie 属性,当我们访问 http://localhost:3000/ 的时候,当浏览器接收到 Set-Cookie 这个属性的时候,浏览器会根据其内部约定,并在其浏览器内部对其 cookie 进行存储,打开浏览器控制台,在 Application 中找到 Cookies 中找到相对应的域名,就可以看到我们所设置的 cookie 值了。当在同域的情况下,当再次请求数据的时候浏览器会默认发送 cookie 在该请求中,一起发送给后端。为了证实上面的说法,刷新一下 http://localhost:3000/ 页面,在控制台 Network 找到 Request Headers 中可以看到 Cookie: cx=Segmentfault 属性,既然发送给服务端之后,相应的在后端也是可以接收到该 Cookie 的,修改一下上面的例子:

const Http = require("http");
const app = Http.createServer((req,res) => {if(req.url === "/favicon.ico"){return;}else{console.log("cookie",req.headers.cookie)
        res.setHeader("Set-Cookie","cx=Segmentfault");
        res.end("hello cookie");
    };
});
app.listen(3000);

在接收到访问的时候,就可以接收到了 cx=Segmentfault,如果说现在这份Cookie 是一份加密的数据的话,里面包含一些用户信息,在通过前后端进行交互之后,当客户端再次请求服务端的时候,服务端拿到相对应的 Cookie 并对其进行解密,对其中用户的信息进行鉴权处理就可以了。

服务端通过 Set-CookieResponse Headers设置了一段加密数据,客户端接收到了其相对应的数据之后,浏览器对其进行存储,当可客户端再次发送请求的时候,会携带已有的 CookieRequest Headers中一并发送给服务端,服务端解密数据完成鉴权,由此可以得出 Cookie 是服务端存储在客服端的状态标志,再由客户端发送给服务端,由服务端解析。Cookie在使用中必须是同域的情况下才可以,一般常用的是在 MVC 这种开发形式中很常用。

说了半天 Cookie,但是对于Session 却只字未提,接下来就介绍一下 SessionSession 从字面上讲,就是会话。这个就类似于你和一个人交谈,你怎么知道当前和你交谈的是张三而不是李四呢?对方肯定有某种特征(长相等)表明他就是张三。Session也是类似的道理,服务器要知道当前发请求给自己的是谁。为了做这种区分,服务器就要给每个客户端分配不同的 身份标识 ,然后客户端每次向服务器发请求的时候,都带上这个 身份标识 ,服务器就知道这个请求来自于谁了。至于客户端怎么保存这个 身份标识 ,可以有很多种方式,对于浏览器客户端,大家都默认采用Cookie 的方式。

const Http = require("http");
let session = {};
const app = Http.createServer((req,res) => {
    const sessionKey = "uId";
    if(req.url === "/favicon.ico"){return;}else{const uId = parseInt(Math.random() * 10e10);
        const cookie = req.headers.cookie;
        if(cookie && cookie.indexOf(sessionKey) !== -1){let _uId = cookie.split("=")[1];
            res.end(`${session[_uId].name} Come back`);
        }
        else{res.setHeader("Set-Cookie",`${sessionKey}=${uId}`);
            session[uId] = {"name":"Aaron"};
            res.end("hello cookie");
        }
    };
});
app.listen(3000);

代码中解析 cookie 只是用了和很简单的方式,只是为了完成 Dome 而已,在实际项目中获取 cookie 比这个要复杂很多。

Session/Cookie认证主要分四步:

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

利用服务器端的 session 和浏览器端的 cookie 来实现前后端的认证,由于 http 请求时是无状态的,服务器正常情况下是不知道当前请求之前有没有来过,这个时候我们如果要记录状态,就需要在服务器端创建一个会话(seesion), 将同一个客户端的请求都维护在各自得会会话中,每当请求到达服务器端的时候,先去查一下该客户端有没有在服务器端创建seesion,如果有则已经认证成功了,否则就没有认证。

redis 结合使用:

const koa = require("koa");
const session = require("koa-session");
const redisStore = require("koa-redis");
const redis = require("redis");
const wrapper = require("co-redis");
const app = new koa();
const redisClient = redis.createClient(6379,"localhost");
const client = wrapper(redisClient);
//  类似于密钥
app.keys = ["Aaron"];
const SESSION_CONFIG = {
    //  所设置的 session 的 key
    key:"sId",
    //  最大有效期
    maxAge:8640000,
    //  是否防止 js 读取
    httpOnly:true,
    //  cookie 二次签名
    signed:true,
    //  存储方式
    stroe:redisStore({client})
};
app.use(session(SESSION_CONFIG,app));
app.use((ctx) => {redisClient.keys("*",(err,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);

虽然 Session/Cookie 可以解决鉴权问题,但是会有很大的问题,对于服务端来说说是一个巨大的开销,严重的限制了服务器扩展能力,比如说我用两个机器组成了一个集群,小 F 通过机器 A 登录了系统,那 sessionId 会保存在机器 A 上,假设小 F 的下一次请求被转发到机器 B 怎么办?机器 B 可没有小 F 的 sessionId,有时候会采用一点小伎俩:session sticky,就是让小 F 的请求一直粘连在机器 A 上,但是这也不管用,要是机器 A 挂掉了,还得转到机器 B 去。那只好做session 的复制了,把 sessionId 在两个机器之间搬来搬去,再好的服务器也经不起这样的折腾。

Token 或 Jwt

在计算机身份认证中是令牌(临时)的意思,在词法分析中是标记的意思。一般作为邀请、登录系统使用。现在前后端分离火热,Token混的风生水起,很多项目开发过程中都会用到 Token,其实Token 是一串字符串,通常因为作为鉴权凭据,最常用的使用场景是 API 鉴权。

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

示例:

前端

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<title>Document</title>
</head>
<body>
<div id="app">
    <div>
        <input type="text" v-model="username">
        <input type="text" v-model="passwrold">
    </div>
    <div>
        <button @click="login"> 登陆 </button>
        <button @click="loginOut"> 退出 </button>
        <button @click="getUserInfo"> 获取用户信息 </button>
    </div>
    <div>
        <button @click="logs = []"> 清空日志 </button>
    </div>
    <ul>
        <li v-for="(item,index) of logs" :key="index">{{item}}</li>
    </ul>
</div>
<script>
axios.defaults.baseURL = "http://localhost:3000"
//  请求拦截
axios.interceptors.request.use((config) => {const token = localStorage.getItem("token");
    if(token){
        //  判断是否存在 token,如果存在的话
        //  每次发起 HTTP 请求时在 headers 中添加 token
        //  Bearer 是 JWT 的认证头部信息
        config.headers["Authorization"] = `Bearer ${token}`
    }
    return config;
},error => alert(error));
//  响应拦截
axios.interceptors.response.use((res) => {app.logs.push(JSON.stringify(res.data))
    return res;
},error => alert(error));
const app = new Vue({
    el:"#app",
    data:{
        username:"",
        passwrold:"",
        logs:[]},
    methods:{login() {let {username,passwrold} = this;
            axios.post("/users/login/token",{username,passwrold}).then((res) => {localStorage.setItem("token",res.data.token)
            })
        },
        loginOut(){axios.post("/users/logout").then((res) => {localStorage.removeItem("token")
            })
            
        },
        getUserInfo(){axios.get("/users/get/user/info").then((res) => {console.log(res)
            });
        }
    }
})
</script>
</body>
</html>

后端:

const Koa = require("koa");
const jwt = require("jsonwebtoken");
const jwtAuth = require("koa-jwt");
const Router = require('koa-router'); // koa 路由中间件
const bodyParser = require("koa-bodyparser");
const cors = require("koa2-cors");
const app = new Koa();
const router = new Router();
//  密钥
const secret = "this is a secret";
app.use(bodyParser());
app.use(cors());
router.post("/users/login/token",(ctx) => {const {body} = ctx.request;
   const {username} = body;
   ctx.body = {
       code:1,
       message:"登陆成功",
       body:{username},
       token:jwt.sign({
           data:body,
           exp:Math.floor(Date.now() / 1000) + 60 * 60,
       },secret)
   }
});
router.post("/users/logout",(ctx) => {const {body} = ctx.request;
    ctx.body = {
        code:1,
        message:"退出成功"
    }
})
router.get("/users/get/user/info",jwtAuth({secret}),(ctx) => {
    //  jwtAuth token 参数
    console.log(ctx.state.user.data)
    ctx.body = {
        code:1,
        message:"成功",
        data:ctx.state.user.data
    }
})
app.use(router.routes());
app.listen(3000);

上面代码用到了很多的依赖模块,最关键的的是 jsonwebtokenkoa-jwt,这两个模块一个是用来对 token 进行加密,一个是用来对数据进行解密的,同时在每次访问需要保护的路由的时候需要使用 jwtAuth 对其进行拦截处理,jwtAuth会根据其 secret 进行数据解密,把解密的数据存放到 ctx.state 中,供用户读取。

有关 jwt 相关请查看深入理解令牌认证机制详细的解释了其加密后数据 token 的构成。

加密后的数据主要分为三个部分 机密头部、载荷、数据 如果我们想查看其加密前内容是什么样子的,可以通过 base64 对其没一部分进行解密。

  1. 机密头部:声明加密规则,可反解
  2. 载荷:数据信息,也就是我们需要加密的信息,可反解
  3. 验证:这部分是对前两部分使用 hash 算法的摘要,是不可逆的

在使用 jsonwebtoken 时需要注意的是,由于加密信息是可以反解的所以,尽量不要在加密数据中存放敏感信息,比如用户的密码,用户私密信息等等(千万不要效仿 Dome,这是不对的 O(∩_∩)O)。同过上面所述,所传递给前端的 token 一旦发生变化,仅仅是一个字母大小写发生变化也是不行的,当服务端接收到 token 解密时,是无法正确解密的,这种 token 可以是发篡改的。如果想要篡改 token 必须要有其 secret 才可以对其进行篡改和伪造。

OAuth

OAuth(开放授权)是一个开放标准,允许用户授权第三方网站访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方网站或分享他们数据的所有内容,为了保护用户数据的安全和隐私,第三方网站访问用户数据前都需要显式的向用户征求授权。我们常见的提供 OAuth 认证服务的厂商有 支付宝,QQ, 微信

OAuth协议又有 1.02.0两个版本。相比较 1.0 版,2.0版整个授权验证流程更简单更安全,也是目前最主要的用户身份验证和授权方式。

OAuth认证主要经历了如下几步:

  1. 需要第三方应用存储资源所有者的凭据,以供将来使用,通常是明文密码。
  2. 需要服务器支持密码身份认证,尽管密码认证天生就有安全缺陷。
  3. 第三方应用获得的资源所有者的受保护资源的访问权限过于宽泛,从而导致资源所有者失去对资源使用时限或使用范围的控制。
  4. 资源所有者不能仅撤销某个第三方的访问权限而不影响其它,并且,资源所有者只有通过改变第三方的密码,才能单独撤销这第三方的访问权限。
  5. 与任何第三方应用的让步导致对终端用户的密码及该密码所保护的所有数据的让步。

简单概括,就是用于第三方在用户授权下调取平台对外开放接口获取用户相关信息。OAuth引入了一个授权环节来解决上述问题。第三方应用请求访问受保护资源时,资源服务器在获准资源用户授权后,会向第三方应用颁发一个访问令牌(AccessToken)。该访问令牌包含资源用户的授权访问范围、授权有效期等关键属性。第三方应用在后续资源访问过程中需要一直持有该令牌,直到用户主动结束该次授权或者令牌自动过期。

总结

授权方式多种多样,主要还是要取决于我们对于产品的定位。如果我们的产品只是在企业内部使用,tokensession 就可以满足我们的需求,现在前后端分离如此火热 jwt 认证方式更加适合。

感谢大家阅读本文章,文章中若有错误请大家指正,如果感觉有多帮助的话,不要忘记点赞哦。

退出移动版