本文是一篇新手教程,目的是提供一个有效的指引,让初次接触OAuth2的同学快速掌握关键信息,快速实现功能,本文不会涉及原理、源码
关于OAuth2的解释,网络上相关文章100%会写,有详细的,有简短的,但是很多新手不明白什么是协议,协议意味着什么?
OAuth2协议强制要求你怎么做,你不能有自己的想法。请求必须是xxx,返回必须是xxx,步骤必须是xxx。因为都被强制了,张三的实现和李四的实现,接口必然完全一样。node的实现和php的实现,接口必然完全一样。
要想掌握OAuth2,必须看完这份协议RFC6749,这里有一份中文版的RFC6749
oauth2-server是OAuth2协议nodejs的实现
他是OAuth2协议的完整实现,他提供了3个接口供我们使用,同时他要求我们必须告诉他token是怎么存储的。文档里详细描述
他用来辅助你实现授权和认证的具体功能,你可以在任何nodejs框架中使用,也可以选择任意的后端存储
他不是用来做注册登录的,也不是用来替代jwt
的
我们完全按照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
通过后,返回给第三方应用程序资源,比如头像昵称
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"
}
// 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');
// 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');
// 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');
根据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()
,他的文档在这里
// 省略...
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
,先什么都不写
// auth/model.js oauth2-server的存储实现
module.exports = {
// 先什么都不写,看看会发生什么
}
运行以下yarn run auth
接下来我们编写app部分的代码,先编写一个按钮用于发起授权请求,再编写一个callback用于授权回调
// app/index.js
// 省略...
router.get('/login', async(ctx) => {
// uri必需这么写,client_id先随便写一个,redirect_uri写callback
ctx.body = '<a href="http://localhost:3002/authorize?response_type=code&client_id=client&state=xyz&redirect_uri=http://localhost:3001/callback">授权</a>'
});
router.get('/callback', async(ctx) => {
// 输出code
ctx.body = ctx.query.code
});
// 省略...
运行appyarn run app
,打开浏览器,输入http://localhost:3001/login
,点击授权
链接,观察页面。不出意外将会看到这样的结果:
{
error: "invalid_argument",
error_description: "Invalid argument: model does not implement `getClient()`"
}
注意:这里的错误是授权服务器发出的,并不是授权回调后展示的错误
这个错误提示很明显了,auth2-server要求的model,必需要实现getClient()
方法,我们先查看文档看看这个方法是干啥的,他的文档在这里
这个方法要求实现如何根据client_id/client_secret得到client object,具体一点就是根据clientId、clientSecret去存储里找到到client对象,参数和返回文档已经给了,我们来实现一下,这里使用内存存储实现
// 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
文档,我们实现一下:
// 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()
的实现,根据文档实现一下:
// 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是什么总是能通过身份认证
// app/index.js
// 省略...
router.get('/login', async(ctx) => {
ctx.body = '<a href="http://localhost:3002/authorize?response_type=code&client_id=client&state=xyz&redirect_uri=http://localhost:3001/callback&access_token=1">授权</a>'
})
// 省略...
为了允许uri query
携带access_token
参数,我们需要修改一下auth/index.js代码
// 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
上一步我们在/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功能
// 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
// 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()
// 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
获取资源
上一步我们拿到了通行证token,我们试一下从API服务器获取用户数据,资源都是受保护的,我们写一个中间件用来做用户身份验证,验证方法使用oauth2-server
提供的authenticate
方法,这个方法要求必须实现getAccessToken()
方法,我们在这个方法里面决定他是否通过身份验证,如果通过返回固定格式,如果没通过,抛出UnauthorizedRequestError
异常,具体实现如下:
// 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
方法
// 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/gitee上,希望大家能有所收获。