# koa2-oauth2-server-demo
**Repository Path**: wuyunhua/koa2-oauth2-server-demo
## Basic Information
- **Project Name**: koa2-oauth2-server-demo
- **Description**: oauth2-server demo with koa2
- **Primary Language**: JavaScript
- **License**: MIT
- **Default Branch**: master
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 6
- **Forks**: 3
- **Created**: 2020-09-06
- **Last Updated**: 2024-03-17
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
本文是一篇新手教程,目的是提供一个有效的指引,让初次接触OAuth2的同学快速掌握关键信息,快速实现功能,本文不会涉及原理、源码
---
## 什么是OAuth2
关于OAuth2的解释,网络上相关文章100%会写,有详细的,有简短的,但是很多新手不明白什么是协议,协议意味着什么?
OAuth2协议强制要求你怎么做,你不能有自己的想法。请求必须是xxx,返回必须是xxx,步骤必须是xxx。因为都被强制了,张三的实现和李四的实现,接口必然完全一样。node的实现和php的实现,接口必然完全一样。
要想掌握OAuth2,必须看完这份协议[RFC6749](https://tools.ietf.org/html/rfc6749),这里有一份中文版的[RFC6749](https://github.com/jeansfish/RFC6749.zh-cn)
## oauth2-server包
[oauth2-server](https://github.com/oauthjs/node-oauth2-server)是OAuth2协议nodejs的实现
他是OAuth2协议的完整实现,他提供了3个接口供我们使用,同时他要求我们必须告诉他token是怎么存储的。[文档](https://oauth2-server.readthedocs.io/en/latest/index.html)里详细描述
他用来辅助你实现授权和认证的具体功能,你可以在任何nodejs框架中使用,也可以选择任意的后端存储
他不是用来做注册登录的,也不是用来替代`jwt`的
## 使用Koa和oauth2-server实现授权码流程
我们完全按照RFC6749规定的流程来做。
+--------+ +---------------+
| |--(A)- Authorization Request ->| Resource |
| | | Owner |
| |<-(B)-- Authorization Grant ---| |
| | +---------------+
| |
| | +---------------+
| |--(C)-- Authorization Grant -->| Authorization |
| Client | | Server |
| |<-(D)----- Access Token -------| |
| | +---------------+
| |
| | +---------------+
| |--(E)----- Access Token ------>| Resource |
| | | Server |
| |<-(F)--- Protected Resource ---| |
+--------+ +---------------+
Figure 1: Abstract Protocol Flow
- A是第三方应用询问`Resource Owner`是否授权,举个例子,第三方app选择微信登录的时候,跳转到微信,询问是否授权登录
- B是`Resource Owner`同意授权,回调第三方应用,同时附上`code`
- C是第三方应用收到回调后,带着`code`,去找`Authorization Server`,换取`token`
- D是`Authorization Server`验证`code`通过后,返回第三方应用一个`token`
- E是第三方应用拿着`token`去`Resource Server`请求资源,举个例子,微信API里获取用户头像昵称,API要求token验证
- F是`Resource Server`验证`token`通过后,返回给第三方应用程序资源,比如头像昵称
### 一、搭建三个http服务器,分别作为第三方应用、授权服务器、资源服务器
```bash
mkdir koa2-oauth2-server & cd koa2-oauth2-server
yarn init
yarn add koa koa-router koa-bodyparser oauth2-server jsonwebtoken
```
编辑package.json,加上入口scripts
```
// ...
"scripts": {
"auth": "node ./auth",
"app": "node ./app",
"api": "node ./api"
}
```
```javascript
// app/index.js the third app http server 3001
const Koa = require('koa')
const bodyParser = require('koa-bodyparser');
const Router = require('koa-router')
const app = new Koa();
app.use(bodyParser());
var router = new Router();
router.get('/hello', async(ctx) => {
ctx.body = 'hello app'
});
app.use(router.routes()).use(router.allowedMethods());
app.listen('3001');
```
```javascript
// auth/index.js the authorize http server 3002
const Koa = require('koa')
const OAuth2 = require('oauth2-server')
const bodyParser = require('koa-bodyparser');
const Router = require('koa-router')
const app = new Koa();
app.use(bodyParser());
var router = new Router();
router.get('/hello', async(ctx) => {
ctx.body = 'hello auth'
});
app.use(router.routes()).use(router.allowedMethods());
app.listen('3002');
```
```javascript
// api/index.js the resource http server 3003
const Koa = require('koa')
const bodyParser = require('koa-bodyparser');
const Router = require('koa-router')
const app = new Koa();
app.use(bodyParser());
var router = new Router();
router.get('/hello', async(ctx) => {
ctx.body = 'hello api'
});
app.use(router.routes()).use(router.allowedMethods());
app.listen('3003');
```
---
### 二、第三方app应用请求授权(步骤A)
根据RFC6749协议中的规定,第三方请求授权必须满足以下条件
请求授权服务器授权uri的规定,需要提供以下参数
- response_type 必需 我们这里是`code`
- client_id 必需 客户端标识
- redirect_uri 可选的 成功后重定向`uri`
- scope 可选的 申请范围
- state 必需 客户端用于维护请求和回调之间状态的值
如果`resource owner`允许授权,要求返回302重定向,重定向到`redirect_uri`,并带上以下数据:
- code 必需 授权服务器生成的授权码
- state 必需 请求中携带的状态值
如果`resource owner`不允许访问,或者出现其他错误,要求返回302重定向,并重定向到`redirect_uri`,并带上以下数据:
- error 必需
- invalid_request 请求缺少必需的参数、包含无效的参数值、包含一个参数超过一次或其他不良格式
- unauthorized_client 客户端未被授权使用此方法请求授权码
- access_denied 资源所有者或授权服务器拒绝该请求
- unsupported_response_type 授权服务器不支持使用此方法获得授权码
- invalid_scope 请求的范围无效,未知的或格式不正确
- server_error 授权服务器遇到意外情况导致其无法执行该请求
- temporarily_unavailable 授权服务器由于暂时超载或服务器维护目前无法处理请求
- error_description 可选 提供额外信息的人类可读的信息
- error_uri 可选 指向带有有关错误的信息的人类可读网页的URI
- state 必需 请求中携带的状态值
一个请求授权例子:
```
GET http://localhost:3002/authorize?response_type=code&client_id=client&state=xyz&redirect_uri=http://localhost:3001/callback HTTP/1.1
Content-Type: application/x-www-form-urlencoded
```
接下来我们来写代码实现协议,修改`auth/index.js`。`oauth2-server`包提供了3个方法给我们使用,这里会用到`oauth.authorize()`,他的文档在[这里](https://oauth2-server.readthedocs.io/en/latest/api/oauth2-server.html#authenticate-request-response-options-callback)
```javascript
// 省略...
const { Request, Response, UnauthorizedRequestError } = require('oauth2-server')
// 省略...
const oauth = new OAuth2({
model: require('./model')
})
app.context.oauth = oauth;
router.get('/authorize', async(ctx, next) => {
// 构造oauth2-server的request、response
const request = new Request(ctx.request);
const response = new Response(ctx.response);
try {
// 调用oauth2-server的authorize生成code
ctx.state.oauth = {
code: await ctx.oauth.authorize(request, response)
};
// 使用oauth2-server的response
ctx.body = response.body;
ctx.status = response.status;
ctx.set(response.headers);
} catch (e) {
if (e instanceof UnauthorizedRequestError) {
ctx.status = e.code;
} else {
ctx.body = { error: e.name, error_description: e.message };
ctx.status = e.code;
}
return ctx.app.emit('error', e, ctx);
}
})
// 省略...
```
`oauth2-server`要求我们提供存储的具体实现,我们写一个`model.js`,先什么都不写
```javascript
// auth/model.js oauth2-server的存储实现
module.exports = {
// 先什么都不写,看看会发生什么
}
```
运行以下`yarn run auth`
接下来我们编写app部分的代码,先编写一个按钮用于发起授权请求,再编写一个callback用于授权回调
```javascript
// app/index.js
// 省略...
router.get('/login', async(ctx) => {
// uri必需这么写,client_id先随便写一个,redirect_uri写callback
ctx.body = '授权'
});
router.get('/callback', async(ctx) => {
// 输出code
ctx.body = ctx.query.code
});
// 省略...
```
运行app`yarn run app`,打开浏览器,输入`http://localhost:3001/login`,点击`授权`链接,观察页面。不出意外将会看到这样的结果:
```
{
error: "invalid_argument",
error_description: "Invalid argument: model does not implement `getClient()`"
}
```
> 注意:这里的错误是授权服务器发出的,并不是授权回调后展示的错误
这个错误提示很明显了,auth2-server要求的model,必需要实现`getClient()`方法,我们先查看文档看看这个方法是干啥的,他的文档在[这里](https://oauth2-server.readthedocs.io/en/latest/model/spec.html#model-getclient)
这个方法要求实现如何根据client_id/client_secret得到client object,具体一点就是根据clientId、clientSecret去存储里找到到client对象,参数和返回文档已经给了,我们来实现一下,这里使用内存存储实现
```javascript
// auth/model.js 实现getClient
module.exports = {
async getClient(clientId, clientSecret) {
return {
id: 'client',
redirectUris: ['http://localhost:3001/callback'],
grants: ['authorization_code']
};
}
}
```
> 为了演示,直接写死了,真实业务中,是会先注册clientId/clientSecret到数据库里,这里检索出来即可
重新运行auth服务`yarn run auth`,再操作一次点击`授权`,观察页面。不出意外会看到这样的结果:
```
{
error: "invalid_argument",
error_description: "Invalid argument: model does not implement `saveAuthorizationCode()`"
}
```
有了之前的经验,很明显这里需要我们实现`saveAuthorizationCode()`,阅读`oauth2-server`文档,我们实现一下:
```javascript
// auth/model.js 实现saveAuthorizationCode()
async saveAuthorizationCode(code, client, user) {
return {
authorizationCode: code.authorizationCode,
expiresAt: code.expiresAt,
redirectUri: code.redirectUri,
scope: code.scope,
client: { id: client.id },
user: { id: user.id }
};
}
```
> 为了演示,直接写死了,真实业务中,需要把这些数据保存到数据库中,否则后续无法判断是否已使用,无法判断是否已失效
重新运行`yarn run auth`,重复上一步操作,观察页面,提示缺少`getAccessToken()`的实现,根据文档实现一下:
```javascript
// auth/model.js 实现getAccessToken()
async getAccessToken(accessToken) {
return {
accessToken: 'dddd',
accessTokenExpiresAt: new Date(2020, 09, 01, 10, 10, 10),
scope: '1',
client: { id: 'client' },
user: { id: 1 }
};
}
```
> 为了演示,直接写死了,真实业务中,需要根据accessToken去数据库里查寻token对象
重新运行`yarn run auth`,重复上一步操作,观察页面,这次不再提示缺少xxx方法的实现了,而是提示`Unauthorized`,并且返回的http状态码是401,说明未登录,怎么回事呢?
因为没有登录授权服务器,授权服务器并不知道`resource owner`是谁,就无法询问。真实业务中,这里发现401,就需要弹出登录界面,先让用户完成登录。之后再询问用户是否授权,选择授权范围,再带上`access_token`重新请求授权
我们先绕过这一步,后面再完善,在请求URL后面加上`access_token=1`,使得请求合规,后续的验证身份环节,需要在`auth/model.js`的`getAccessToken()`方法中验证身份,但我们不处理,这样不论access_token是什么总是能通过身份认证
```javascript
// app/index.js
// 省略...
router.get('/login', async(ctx) => {
ctx.body = '授权'
})
// 省略...
```
为了允许`uri query`携带`access_token`参数,我们需要修改一下auth/index.js代码
```javascript
// auth/index.js allow token in query string
// 省略...
const oauth = new OAuth2({
model: require('./model'),
allowBearerTokensInQueryString: true,
accessTokenLifetime: 4 * 60 * 60
})
// 省略...
```
重新运行`yarn run auth` & `yarn run app`,重复上一步操作,观察页面。不出意外会看到重定向,`http://localhost:3001/callback?code=ae4354425c3a3bb75ef5e969a19ebd304a0736ef&state=xyz`
步骤B成功了,拿到了`code`,下一步,我们用`code`换取通行证`token`
---
### 三、第三方app应用换取token(步骤C)
上一步我们在`/callback`里拿到了`code`,接下来要用`code`去授权服务器换取`token`,根据RFC6749协议中的规定,第三方请求获取token必须满足以下条件
请求授权服务器获取token uri的规定,需要提供以下参数
- grant_type 必需 这里必须设置为`authorization_code`
- code 必需 从授权服务器拿到的`code`
- redirect_uri 必需 上一步中`redirect_uri`,必须完全一样
- client_id 必需 客户端id
- client_secret 必需 客户端secret
请求类型必须是`post`,`content-type`必须是`application/x-www-form-urlencoded`,例如
```
POST http://localhost:3002/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Authorization: Bearer 1
grant_type=authorization_code&code=2d6d7da2ed7c405ade522ced89a875bc2b65c8e1&client_id=client&client_secret=secret&redirect_uri=http://localhost:3002/callback
```
> 注意:一般情况下,这一步请求需要在服务器里完成,避免在客户端完成,因为涉及client_secret,避免泄露
请求成功的返回必须是这样子的
```
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache
{
"access_token":"2YotnFZFEjr1zCsicMWpAA",
"token_type":"example",
"expires_in":3600,
"refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA",
"example_parameter":"example_value"
}
```
我们编写一个`/token`的uri来实现换取token功能
```javascript
// auth/index.js
// 省略...
router.post('/token', async(ctx) => {
const request = new Request(ctx.request);
const response = new Response(ctx.response);
try {
// 调用oauth2-server的token()生成token
ctx.state.oauth = {
token: await ctx.oauth.token(request, response)
};
// 使用oauth2-server的response
ctx.body = response.body;
ctx.status = response.status;
ctx.set(response.headers);
} catch (e) {
if (e instanceof UnauthorizedRequestError) {
ctx.status = e.code;
} else {
ctx.body = { error: e.name, error_description: e.message };
ctx.status = e.code;
}
return ctx.app.emit('error', e, ctx);
}
})
// 省略...
```
接下来我们在app里请求授权服务器换取token
```javascript
// app/index.js
const axios = require('axios');
// 省略...
router.get('/callback', async(ctx) => {
const code = ctx.query.code;
const res = await axios({
url: 'http://localhost:3002/token',
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
data: `grant_type=authorization_code&code=${code}&client_id=client&client_secret=secret&redirect_uri=http://localhost:3002/callback`
});
console.log(res.data);
ctx.body = "ok"
});
// 省略...
```
> `axios`是node中httpclient库,记得`yarn add axios`
现在重启一下,`yarn run auth` & `yarn run app`,重复上一步中的授权,页面显示Internal Server Error,看一下终端给出的异常信息:invalid_argument: Invalid argument: model does not implement getAuthorizationCode()
很明显,需要继续实现model.js里的方法,参考文档,补充方法,重复上述步骤,依次实现`revokeAuthorizationCode()`、`saveToken()`
```javascript
// auth/model.js
// 省略...
async getAuthorizationCode(code) {
return {
code: code,
expiresAt: new Date(2020, 9, 1, 0, 0, 0),
redirectUri: 'http://localhost:3002/callback',
scope: '1',
client: { id: 'client' },
user: { id: 1 }
};
},
async revokeAuthorizationCode(code) {
return true
},
async saveToken(token, client, user) {
return {
accessToken: token.accessToken,
accessTokenExpiresAt: token.accessTokenExpiresAt,
refreshToken: token.accessToken,
refreshTokenExpiresAt: token.refreshTokenExpiresAt,
scope: token.scope,
client: client,
user: user
};
},
// 省略...
```
重新运行`yarn run auth`,不出意外会看到200 ok,观察终端,会发现授权服务器返回了token给我们:
```
{
access_token: "e1062fc1a93d9b86231090a7ca2b221dd0eb7d8a",
token_type: "Bearer",
expires_in: 14399,
refresh_token: "e1062fc1a93d9b86231090a7ca2b221dd0eb7d8a",
scope: "1"
}
```
步骤D成功了,拿到了`token`,下一步,我们用`token`获取资源
---
### 四、从API服务器获取数据(步骤E)
上一步我们拿到了通行证token,我们试一下从API服务器获取用户数据,资源都是受保护的,我们写一个中间件用来做用户身份验证,验证方法使用`oauth2-server`提供的`authenticate`方法,这个方法要求必须实现`getAccessToken()`方法,我们在这个方法里面决定他是否通过身份验证,如果通过返回固定格式,如果没通过,抛出`UnauthorizedRequestError`异常,具体实现如下:
```javascript
// api/index.js
const OAuth2 = require('oauth2-server')
const { Request, Response, UnauthorizedRequestError } = require('oauth2-server')
const oauth = new OAuth2({
model: {
async getAccessToken(accessToken) {
// 省略验证过程,如果没通过,取消下面这行的注释
//throw new UnauthorizedRequestError('token invaild')
return {
accessToken: 'dddd',
accessTokenExpiresAt: new Date(2020, 9, 1, 0, 0, 0),
scope: '1',
client: { id: 'client' },
user: { id: 1 }
};
}
},
allowBearerTokensInQueryString: true,
accessTokenLifetime: 4 * 60 * 60
})
app.context.oauth = oauth;
app.use(async(ctx, next) => {
const request = new Request(ctx.request);
const response = new Response(ctx.response);
try {
ctx.state.oauth = {
token: await ctx.oauth.authenticate(request, response)
};
await next();
} catch (e) {
if (e instanceof UnauthorizedRequestError) {
ctx.status = e.code;
} else {
ctx.body = { error: e.name, error_description: e.message };
ctx.status = e.code;
}
}
});
// 获取资源,如果如果通过了中间件的身份验证,这里就能拿到userid
router.get('/user', async(ctx, next) => {
const { user, client } = ctx.state.oauth.token
ctx.body = { user, client }
});
```
> 为了演示,没有写具体如何验证的,真实业务中可以使用数据库验证,也可以使用jwt验证
加下来就可以在app中请求API了,改一下`callback`方法
```javascript
// app/index.js
router.get('/callback', async(ctx) => {
const code = ctx.query.code;
const res = await axios({
url: 'http://localhost:3002/token',
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
data: `grant_type=authorization_code&code=${code}&client_id=client&client_secret=secret&redirect_uri=http://localhost:3002/callback`
});
// 带上token去请求资源
const access_token = res.data.access_token
try {
const user = await axios.get(`http://localhost:3003/user?access_token=${access_token}`)
ctx.body = user.data
} catch (e) {
ctx.body = { error_description: e.message };
ctx.status = e.response.status;
}
});
```
重启app和api服务器,`yarn run app` & `yarn run api`,重复第一步的授权,不出意外我们将会看到如下json:
```
{
user: {
id: 1
},
client: {
id: "client"
}
}
```
步骤F成功了,拿到了受保护的资源,并且API服务器知道是来自哪个`client`,哪个`user`
---
## 总结
本文首先阐述实现`OAuth2`的关键点是RFC6749,很多文章上来就讲什么是`OAuth2`却不提RFC6749,这会误导新手,即使知道了原理,还是不知道请求的参数应该填什么。
其次,我们使用`node`的包`oauth2-server`来实现了一遍授权码验证过程,这个过程要始终围绕RFC6749,否则流程很难走通,自然无法实现`model`。
最后,写这篇文章的缘由是我发现网络上关于`oauth2-server`的koa实现非常非常少,即使有,也没有提供代码,所以就有了这篇文章,源码在[github](https://github.com/wuyunhua1987/koa2-oauth2-server-demo)/[gitee](https://gitee.com/wuyunhua/koa2-oauth2-server-demo)上,希望大家能有所收获。