指验证用户是否拥有访问系统的权利
常见的鉴权方式
- 用户登录的时候,服务端生成一个唯一的会话标识,并以它为key存储数据
- 会话标识在客户端和服务端之间通过cookie进行传输
- 服务端通过会话标识可以获取到会话相关的信息,然后对客户端的请求进行响应;如果找不到有效的会话,那么认为用户是未登陆状态
-
技术原理
session 会话是一种服务端机制,它使用类似于hash表(或就是hash表)的结构保存信息。
服务器在接受客户端首次访问时在服务器端创建seesion,然后保存seesion(我们可以将seesion保存在内存中,也可以保存在redis中,推荐使用后者),然后给这个session生成一个唯一的标识字符串,然后在响应头中种下这个唯一标识字符串;
- 签名,这一步通过秘钥对sid进行签名处理,避免客户端修改sid。(非必需步骤)
- 浏览器中收到请求响应的时候会解析响应头,然后将sid保存在本地cookie中,浏览器在下次http请求的请求头中会带上该域名下的cookie信息;
- 服务器在接受客户端请求时会去解析请求头cookie中的sid,然后根据这个sid去找服务器端保存的该客户端的session,然后判断该请求是否合法。
最简单的 cookie 实现(nodejs)
```javascript // 实现了后台设置 cookie 到客户端 const http = require(“http”);
http .createServer((req, res) => { const sessionKey = “sid”; const sid = ‘123123’
res.setHeader('Set-Cookie', `${sessionKey}=${sid}` )
res.end(`${sessionKey}=${sid}`)
}) .listen(3000);
<a name="tknes"></a>
### 基础的 cookie 实现
```javascript
// 实现了登录态和非登录态的区分(发出指令和记录状态)
const http = require("http");
const session = {};
http
.createServer((req, res) => {
const sessionKey = "sid";
const cookie = req.headers.cookie;
// 判断是否已经存在 cookie 的 sid
if (cookie && cookie.indexOf(sessionKey) > -1) {
// 存在 sid,不是首次登陆
// 筛选出 cookie 中 sid 的部分
const pattern = new RegExp(`${sessionKey}=([^;]+);?\s*`);
const sid = pattern.exec(cookie)[1];
console.log(sessionKey, sid, session[sid]);
res.end('welcome back')
} else {
// 首次登陆
const sid = (Math.random() * 123456).toFixed();
// 设置 sessionKey 为 sid
res.setHeader("Set-Cookie", `${sessionKey}=${sid};`);
session[sid] = {
name: "qiji",
};
res.end("hello, first login");
}
})
.listen(3000);
- 跨域的时候可以使用cookie,需要设置请求头
Access-Control-Allow-Origin
。使用 koa / koa-session 中间件的方式使用 session
```javascript const koa = require(‘koa’) const app = new koa()
// 此处的 session 本身是一个中间件工厂函数 const session = require(‘koa-session’)
// 设置一个加密秘钥 app.keys = [‘some secret’]
const SEES_CONFIG = { key: ‘key:sess’, maxAge: 8640000, // 生存周期 httpOnly: true, // httpOnly 设置 signed: false, // 签名设置,本质用来防篡改 }
app.use(session(SEES_CONFIG, app))
app.use(ctx => { // 设置服务器不应答 if(ctx.path == ‘/favicon.ico’) return
// 此处想要实现利用 session 记录浏览量
// 经过 session 中间件之后,session本身值在上下文中
let n = ctx.session.count || 0
// 设置
ctx.session.count = ++n
ctx.body = `第${n}次访问`
})
app.listen(3000)
- httpOnly:该设置两个目的,设置为 true 以后 js 将无法获取到 cookie,第二个目的是仅允许http协议进行传输。
- signed:该属性是签名设置,签名设置的意义在于**防篡改**。
设置为 false:<br />![image.png](https://cdn.nlark.com/yuque/0/2021/png/388396/1629786770388-d277267a-58a9-4cea-9b10-ccee8b920317.png#height=117&id=ud99ab05a&margin=%5Bobject%20Object%5D&name=image.png&originHeight=117&originWidth=457&originalType=binary&ratio=1&size=8985&status=done&style=none&width=457)<br />设置为 true:f<br />![image.png](https://cdn.nlark.com/yuque/0/2021/png/388396/1629786862113-3791bba6-5a5b-4b82-b800-c746307b2819.png#height=129&id=u2c1bc7d5&margin=%5Bobject%20Object%5D&name=image.png&originHeight=129&originWidth=464&originalType=binary&ratio=1&size=10260&status=done&style=none&width=464)<br />此处反篡改是这样的,key:sess 该属性虽然是加密过后的值,但是也依然能够通过穷举等暴力破解的方式破解,假设就是一个随机数,万一通过大量的尝试,就会实验出真实数据。签名的本质是进行了一次hash运算。哈希Hash - SHA MD5:通常来讲,满足下面三个规则:摘要、不定长摘要变为定长结果、雪崩效应。
<a name="zmUDw"></a>
### 使用 redis 存储 session
> redis 是一个高性能的 key-value 存储库,通常认为 redis 是存在内存上的存储,但其实 redis 也有相对应的持久化存储的方案
**特点:**
- 数据持久化,内存数据保存到磁盘中
- 不仅支持 key-value 类型,还提供 list,set,zset 等数据结构
- 支持数据备份,即 master-slave 模式数据备份
是相比于前面基础 cookie 实现的pro版本
```javascript
// koa-redis
// 该文件是相比 app.js 改造的那部分代码
// 引入两个 redis 的包,第一个是 redis 的 store,将 session 指定存入的地方;第二个是 redis。
const redisStore = require('koa-redis');
const redis = require('redis')
const redisClient = redis.createClient(6379, "localhost"); // 配置端口
// 之后需要干两件事儿
// 一个是 redis 的存储,之前 app.js 已经做了,重新设置一个 store 的指向就行
// 另一个是我们希望遍历一下 redis 里面所有 key,查看状态,所以需要引入 co-redis
const wrapper = require('co-redis');
// 封装一下,本身是回调风格,封装之后变为 promise
const client = wrapper(redisClient);
app.use(session({
key: 'ttt:sess',
// 这是第一件事儿:store 的指向,此处也可以不必指定client,不设置就会存储到内存中
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()
})
结果如图:此结果表示经过多次访问之后,能看到不同的用户(session)访问次数。
一个完整的鉴权赋权demo
使用 koa / koa-router / koa-session / koa-bodyparser 等常用中间件
共分为两部分,一个html 页面,一个node 后台的 js。主要实现功能为登录、退出、查看用户信息,并且将每一次 log 显示在页面上。
<!-- 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">登录</button>
<button v-on:click="logout">退出</button>
<button v-on:click="getUser">获取用户信息</button>
</div>
<div>
<button onclick="document.getElementById('log').innerHTML = ''">清空</button>
</div>
</div>
<h6 id="log"></h6>
</div>
<script type="text/javascript">
// 创建一个 axios 的正常请求
axios.defaults.baseURL = 'http://localhost:3000'
axios.defaults.withCredentials = true
// response 拦截器
axios.interceptors.response.use(response => {
document.getElementById('log').append(JSON.stringify(response.data) + " ")
return response;
});
var app = new Vue({
el: '#app',
data: {
username: 'qiji',
password: '123456'
},
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
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();
//配置session的中间件
app.use(cors({
credentials: true
}))
app.keys = ['some secret'];
app.use(static(__dirname + '/'));
app.use(bodyParser())
app.use(session(app));
app.use((ctx, next) => {
// login 是个例外,如果是 login 的话,一定是要放行的
if (ctx.url.indexOf('login') > -1) {
next()
} else {
// 当请求没有携带 session 的时候,则不能进行其他操作: 操作失败
if (!ctx.session.userInfo) {
ctx.body = { message: '操作失败' }
} else {
next()
}
}
})
router.post('/users/login', async ctx => {
const { body } = ctx.request
// 正常需要数据库匹配 赋权 设置 session,此处省略掉数据库操作
ctx.session.userInfo = body.username
ctx.body = { message: '登录成功' }
})
router.post('/users/logout', async (ctx) => {
delete ctx.session.userInfo
ctx.body = { message: '登出成功' }
})
router.get('/users/getUser', async ctx => {
ctx.body = { message: '获取用户信息', userInfo: ctx.session.userInfo }
})
app.use(router.routes())
app.listen(3000)
结果如图:依次点击 登录 - 获取用户信息 - 退出 - 获取用户信息
如果在本机启动服务测试的请求的 URL出现了内容为 This Set-Cookie didn't specify a "SameSite" attribute, was defaulted to "SameS...
的黄色感叹号,参考此链接解决 Chrome 更新到 80 以后,本地发起的跨域请求失败了。
Token
绝大多数的网站都使用 token 方式,为什么不是用 session 方式?
- session 需要服务器有状态
- 不灵活,app?跨域?
服务器有状态的意思是,需要服务器需要存储一些登录状态等的内容,所以不太方便与扩展。cookie 是浏览器的机制,如果服务于多端,是不一定能够提供 session/cookie 的能力的
原理
- 客户端使用用户名跟密码请求登录
- 服务端收到请求,去验证用户名与密码
- 验证成功后,服务端会签发一个令牌(Token),再把这个 Token 发送给客户端
- 客户端收到 Token 以后可以把它存储起来,比如放在 Cookie 里或者 Local Storage 里
- 客户端每次向服务端请求资源的时候需要带着服务端签发的 Token
- 服务端收到请求,然后去验证客户端请求里面带着的 Token,如果验证成功,就向客户端返回请求的数据
代码实现
token 的代码实现比较简单,我们仍然使用类似的登录页作为前台。 ```javascript // html 部分大致相同,此处列举不同的部分 // 我们再 axios 拦截器中设置,每一次请求时都在 localStorage 中拿一下 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; }, err => { return Promise.reject(err); });
// 登录退出方法,主要是存取 token login: async function () { const res = await axios.post(“/users/login-token”, { username: this.username, password: this.password }); localStorage.setItem(“token”, res.data.token); } logout: async function () { localStorage.removeItem(“token”); }
// index.js const Koa = require(‘koa’) const router = require(‘koa-router’)() // 重要引入 jwt 与 jwtAuth // jwt 是 token 的一个发放形式 const jwt = require(“jsonwebtoken”) // jwtAuth 使用一个中间件来验证 token 的有效性 const jwtAuth = require(“koa-jwt”) const secret = “it’s a secret” const cors = require(‘koa2-cors’) const bodyParser = require(‘koa-bodyparser’) const static = require(‘koa-static’) const app = new Koa();
app.keys = [‘some 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: “登录成功”, user: userInfo, // 生成 token 返回给客户端 token: jwt.sign({ data: userInfo, // 设置 token 过期时间,一小时后,秒为单位 exp: Math.floor(Date.now() / 1000) + 60 * 60 }, secret) }; }); // 调用了获取信息的接口,使用 jwtAuth 实现鉴权 router.get(“/users/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);
结果如图,操作顺序为:登录 - 获取用户信息 - 登出 - 获取用户信息 - 登录。<br />其中登出后 token 消失,此时请求用户信息,则报错 **response is not define **因为后台没有写无 token 时的处理逻辑,仅报错。<br />![image.png](https://cdn.nlark.com/yuque/0/2021/png/388396/1629939201498-e07187b2-f122-400a-99a2-e531eacd8e81.png#clientId=ua1cec1b8-0363-4&from=paste&height=127&id=uefce30ac&margin=%5Bobject%20Object%5D&name=image.png&originHeight=254&originWidth=1160&originalType=binary&ratio=1&size=61381&status=done&style=none&taskId=u870d887f-a3e6-4b11-8a46-c75870f9dd4&width=580)
<a name="au2UP"></a>
### token 串解析
**token **串为:`eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjoidGVzdCIsImV4cCI6MTYyOTk0MzAwMCwiaWF0IjoxNjI5OTM5NDAwfQ.sv0hAYgq_h9sLShjklFmZiibkUgRpI1yBoQlRSdFlss`<br />这里分为三部分
1. `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9`:第一部分是一个base64 的编码串,叫做**请求头**,经过解码后是:**{"alg":"HS256","typ":"JWT"}**。加密方式是 HS256,token 的形式(类型)是 JWT;
1. `eyJkYXRhIjoidGVzdCIsImV4cCI6MTYyOTk0MzAwMCwiaWF0IjoxNjI5OTM5NDAwfQ`:第二部分也是 base64 的编码串,叫做**载荷 payload**,经过解码后是:**{"data":"qiji","exp":1629948271,"iat":1629944671}**。data 值是 qiji,exp 过期时间,iat 签名时间;
1. `sv0hAYgq_h9sLShjklFmZiibkUgRpI1yBoQlRSdFlss`:第三部分无法通过 base64解开,其实是一个 **哈希hash** 值,是对令牌头+载荷+密钥设置的签名(默认使用hs256算法对令牌头、payload和密钥进行签名生成哈希),通过第三部分内容保证了无法复制,提高了安全性。
<a name="HMDOA"></a>
### session 与 token 对比
1. session 需要服务端根据传回的 id 检索信息,而 token 不需要。在大规模的应用当中,对每一个请求进行检索是一个非常复杂耗时的工作,另外服务端通过token 来解析用户身份也是需要向对应的协议;
1. session 通过 cookie 来实现,但是 token 可以放在 cookie 中,也可以放在请求头中,还可以放在请求内容中;
1. token 生成方式多样化,支持第三方工具;
1. token 被盗用过风险小,因为存储在客户端中。
推荐一个文章:[谁说 session 只能存在服务器端](https://juejin.cn/post/6866982764256690189):内容是 koa 的一个变种写法,低成本的将 session 做了类 token 化,有优点,但是也优缺点。
<a name="W1KaA"></a>
## OAuth
<a name="Y8PqZ"></a>
### 原理
> 简单概括,就是用于第三方在用户授权下调取平台对外开放接口获取用户相关信息。
OAuth引入了一个授权环节来解决上述问题。第三方应用请求访问受保护资源时,资源服务器在获准资源用户授权后,会向第三方应用颁发一个访问令牌(AccessToken)。该访问令牌包含资源用户的授权访问范围、授权有效期等关键属性。第三方应用在后续资源访问过程中需要一直持有该令牌,直到用户主动结束该次授权或者令牌自动过期。
OAuth2.0 包括了授权码模式、PWD模式等多种模式。<br />更多查看 OAuth 文档就好啦。[w3cschool OAuth 中文文档](https://www.w3cschool.cn/oauth2/)
<a name="yEJZQ"></a>
### 代码实现
使用了github OAuth 应用进行了第三方登录的代码编写,具体操作流程可以查看响应的github文档和百度。
```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')
app.use(static(__dirname + '/'));
// client_id 和 client_secret 在 github OAuthApp 中获取/设置
const config = {
client_id: '6f1cd7926641f53771b4',
client_secret: 'a4a84b6511166cf2e4800de18db07b851da4cce4'
}
router.get('/github/login', async (ctx) => {
var dataStr = (new Date()).valueOf();
//重定向到认证接口,并配置参数
var path = "https://github.com/login/oauth/authorize";
path += '?client_id=' + config.client_id;
//转发到授权服务器
ctx.redirect(path);
})
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)
// console.log(res)
const access_token = querystring.parse(res.data).access_token
console.log('access_token: ', access_token)
res = await axios.get('https://api.github.com/user?access_token=' + access_token)
console.log('userAccess:', res.data.login)
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(7001);
SSO 单点登录
以后有比较简单的例子和代码再更新……