1 Star 6 Fork 3

吴云华 / koa2-oauth2-server-demo

加入 Gitee
与超过 1200万 开发者一起发现、参与优秀开源项目,私有仓库也完全免费 :)
免费加入
克隆/下载
贡献代码
同步代码
取消
提示: 由于 Git 不支持空文件夾,创建文件夹后会生成空的 .keep 文件
Loading...
README
MIT

本文是一篇新手教程,目的是提供一个有效的指引,让初次接触OAuth2的同学快速掌握关键信息,快速实现功能,本文不会涉及原理、源码


什么是OAuth2

关于OAuth2的解释,网络上相关文章100%会写,有详细的,有简短的,但是很多新手不明白什么是协议,协议意味着什么?

OAuth2协议强制要求你怎么做,你不能有自己的想法。请求必须是xxx,返回必须是xxx,步骤必须是xxx。因为都被强制了,张三的实现和李四的实现,接口必然完全一样。node的实现和php的实现,接口必然完全一样。

要想掌握OAuth2,必须看完这份协议RFC6749,这里有一份中文版的RFC6749

oauth2-server包

oauth2-server是OAuth2协议nodejs的实现

他是OAuth2协议的完整实现,他提供了3个接口供我们使用,同时他要求我们必须告诉他token是怎么存储的。文档里详细描述

他用来辅助你实现授权和认证的具体功能,你可以在任何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是第三方应用拿着tokenResource Server请求资源,举个例子,微信API里获取用户头像昵称,API要求token验证
  • F是Resource Server验证token通过后,返回给第三方应用程序资源,比如头像昵称

一、搭建三个http服务器,分别作为第三方应用、授权服务器、资源服务器

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

二、第三方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.jsoauth2-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.jsgetAccessToken()方法中验证身份,但我们不处理,这样不论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


三、第三方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

请求类型必须是postcontent-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获取资源


四、从API服务器获取数据(步骤E)

上一步我们拿到了通行证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上,希望大家能有所收获。

MIT License Copyright (c) 2020 wuyunhua Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

简介

oauth2-server demo with koa2 展开 收起
JavaScript
MIT
取消

发行版

暂无发行版

贡献者

全部

近期动态

加载更多
不能加载更多了
JavaScript
1
https://gitee.com/wuyunhua/koa2-oauth2-server-demo.git
git@gitee.com:wuyunhua/koa2-oauth2-server-demo.git
wuyunhua
koa2-oauth2-server-demo
koa2-oauth2-server-demo
master

搜索帮助