From c4e2f4a61fc9be58fff1212abb1ea7b04267f8f8 Mon Sep 17 00:00:00 2001 From: "gao.zhiyuan" Date: Thu, 11 Aug 2022 13:40:32 +0800 Subject: [PATCH] OAuth2.0 --- code/api/.vscode/launch.json | 5 +- code/api/app/lib/controllers/auth/app.js | 135 ++++++++++++++++++ code/api/app/lib/index.js | 4 + code/api/app/lib/middlewares/authenticator.js | 1 + code/api/app/lib/routes/auth/app.js | 14 ++ code/api/app/lib/service/paasRequest.js | 68 +++++++++ code/api/app/lib/service/redis.js | 4 +- code/api/app/lib/utils/index.js | 16 +++ code/api/app/lib/utils/oauth2.js | 62 ++++++++ code/api/config.js | 21 ++- code/api/utils/forward-api.js | 15 -- code/web/package.json | 2 +- 12 files changed, 325 insertions(+), 22 deletions(-) create mode 100644 code/api/app/lib/controllers/auth/app.js create mode 100644 code/api/app/lib/routes/auth/app.js create mode 100644 code/api/app/lib/service/paasRequest.js create mode 100644 code/api/app/lib/utils/index.js create mode 100644 code/api/app/lib/utils/oauth2.js delete mode 100644 code/api/utils/forward-api.js diff --git a/code/api/.vscode/launch.json b/code/api/.vscode/launch.json index 4a2a2ef..a5ba887 100644 --- a/code/api/.vscode/launch.json +++ b/code/api/.vscode/launch.json @@ -16,8 +16,9 @@ "-p 4200", "-f http://localhost:4200", "-g postgres://postgres:123@10.8.30.32:5432/iot_auth", - "--redisHost 127.0.0.1", - "--redisPort 6379" + "--redisHost 10.8.30.112", + "--redisPort 6379", + "--iotVcmpApi http://127.0.0.1:4000", ] }, { diff --git a/code/api/app/lib/controllers/auth/app.js b/code/api/app/lib/controllers/auth/app.js new file mode 100644 index 0000000..ea50581 --- /dev/null +++ b/code/api/app/lib/controllers/auth/app.js @@ -0,0 +1,135 @@ +'use strict'; +// ! OAUTH2.0 模式 + +const uuid = require('uuid'); +const moment = require('moment'); + +// 暂时定制予 vcmp 视频平台 app + +async function apply (ctx) { + try { + const { models } = ctx.fs.dc; + const { body } = ctx.request; + const { authorization } = ctx.headers; + const { utils: { oauthParseAuthHeader, oauthParseBody } } = ctx.app.fs + await oauthParseBody(body, 'apply'); + const keySplit = await oauthParseAuthHeader(authorization); + + // TODO 删除此应用下的其他 token + + // 记录token + const userInfo = { + appKey: keySplit[0], + appSecret: keySplit[1] + } + const token = uuid.v4(); + const expired = moment().add(1, 'year'); + await models.UserToken.create({ + token: token, + userInfo: userInfo, + expired: expired.format('YYYY-MM-DD HH:mm:ss') + }); + + ctx.status = 200; + ctx.body = { + token: token, + expires: expired.toISOString() + }; + } catch (e) { + ctx.status = 400; + ctx.body = { + name: 'RequestError', + message: e.message + }; + } +} + +async function refresh (ctx) { + const transaction = await ctx.fs.dc.orm.transaction(); + try { + const { authorization } = ctx.headers; + const { body } = ctx.request; + const { models } = ctx.fs.dc; + const { utils: { oauthParseAuthHeader, oauthParseBody } } = ctx.app.fs + const keySplit = await oauthParseAuthHeader(authorization); + const $token = await oauthParseBody(body, 'refresh'); + + const oldToken = await models.UserToken.findOne({ + where: { + token: $token, + expired: { $gte: moment().format('YYYY-MM-DD HH:mm:ss') } + } + }); + + if ( + !oldToken + || oldToken.userInfo.appKey != keySplit[0] + || oldToken.userInfo.appSecret != keySplit[1] + ) { + throw new Error('参数无效:正文token无效或已过期,请重新申请'); + } + + const token = uuid.v4(); + const expired = moment().add(1, 'year'); + + // 记录token + const tokenMsg = { + token: token, + userInfo: oldToken.userInfo, + expired: expired.format('YYYY-MM-DD HH:mm:ss') + } + await models.UserToken.create(tokenMsg, { transaction }); + await ctx.redis.hmset(token, tokenMsg); + + // 移除旧的token + await models.UserToken.destroy({ where: { token: $token }, transaction }); + await ctx.redisTools.hdelall($token); + + await transaction.commit(); + + ctx.status = 200; + ctx.body = { + token: token, + expires: expired.toISOString() + }; + } catch (e) { + await transaction.rollback(); + ctx.status = 400; + ctx.body = { + name: 'RequestError', + message: e.message + }; + } +} + +async function invalidate (ctx) { + const transaction = await ctx.fs.dc.orm.transaction(); + try { + const { models } = ctx.fs.dc; + const { body } = ctx.request; + const { authorization } = ctx.headers; + const { utils: { oauthParseAuthHeader, oauthParseBody } } = ctx.app.fs + const keySplit = await oauthParseAuthHeader(authorization); + const $token = await oauthParseBody(body, 'invalidate'); + + // 删除token + await models.UserToken.destroy({ where: { token: $token } }); + await ctx.redisTools.hdelall($token); + + await transaction.commit(); + ctx.status = 204; + } catch (e) { + await transaction.rollback(); + ctx.status = 400; + ctx.body = { + name: 'RequestError', + message: e.message + }; + } +} + +module.exports = { + apply, + refresh, + invalidate +} \ No newline at end of file diff --git a/code/api/app/lib/index.js b/code/api/app/lib/index.js index 14d8b1a..34d4f6e 100644 --- a/code/api/app/lib/index.js +++ b/code/api/app/lib/index.js @@ -5,6 +5,8 @@ const authenticator = require('./middlewares/authenticator'); // const apiLog = require('./middlewares/api-log'); const redisConnect = require('./service/redis') const schedule = require('./schedule') +const utils = require('./utils') +const paasRequest = require('./service/paasRequest'); module.exports.entry = function (app, router, opts) { app.fs.logger.log('info', '[FS-AUTH]', 'Inject auth and api mv into router.'); @@ -15,6 +17,8 @@ module.exports.entry = function (app, router, opts) { redisConnect(app, opts) schedule(app, opts) + utils(app, opts) + paasRequest(app, opts) router.use(authenticator(app, opts)); // router.use(apiLog(app, opts)); diff --git a/code/api/app/lib/middlewares/authenticator.js b/code/api/app/lib/middlewares/authenticator.js index 69a6cef..149588e 100644 --- a/code/api/app/lib/middlewares/authenticator.js +++ b/code/api/app/lib/middlewares/authenticator.js @@ -65,6 +65,7 @@ let isPathExcluded = function (opts, path, method) { return excluded; }; +// TODO OAuth2 authorization let authorizeToken = async function (ctx, token) { let rslt = null; const tokenFormatRegexp = /^(\{{0,1}([0-9a-fA-F]){8}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}-([0-9a-fA-F]){12}\}{0,1})$/g; diff --git a/code/api/app/lib/routes/auth/app.js b/code/api/app/lib/routes/auth/app.js new file mode 100644 index 0000000..a5a832a --- /dev/null +++ b/code/api/app/lib/routes/auth/app.js @@ -0,0 +1,14 @@ +'use strict'; + +const OAuth = require('../../controllers/auth/app'); + +module.exports = function (app, router, opts) { + app.fs.api.logAttr['POST/oauth2/token'] = { content: '获取访问令牌', visible: true }; + router.post('/oauth2/token', OAuth.apply); + + app.fs.api.logAttr['POST/oauth2/token/refresh'] = { content: '刷新访问令牌', visible: false }; + router.post('/oauth2/token/refresh', OAuth.refresh); + + app.fs.api.logAttr['POST/oauth2/token/invalidate'] = { content: '作废访问令牌', visible: false }; + router.post('/oauth2/token/invalidate', OAuth.invalidate); +}; diff --git a/code/api/app/lib/service/paasRequest.js b/code/api/app/lib/service/paasRequest.js new file mode 100644 index 0000000..7c2b096 --- /dev/null +++ b/code/api/app/lib/service/paasRequest.js @@ -0,0 +1,68 @@ +'use strict'; +const request = require('superagent') + +class paasRequest { + constructor(root, { query = {} } = {}, option) { + this.root = root; + this.query = query + this.option = option + } + + #buildUrl = (url) => { + return `${this.root}/${url}`; + } + + #resultHandler = (resolve, reject) => { + return (err, res) => { + if (err) { + reject(err); + } else { + resolve(res[this.option.dataWord]); + } + }; + } + + get = (url, { query = {}, header = {} } = {}) => { + return new Promise((resolve, reject) => { + request.get(this.#buildUrl(url)).set(header).query(Object.assign(query, this.query)).end(this.#resultHandler(resolve, reject)); + }) + } + + post = (url, { data = {}, query = {}, header = {} } = {}) => { + return new Promise((resolve, reject) => { + request.post(this.#buildUrl(url)).set(header).query(Object.assign(query, this.query)).send(data).end(this.#resultHandler(resolve, reject)); + }) + } + + put = (url, { data = {}, header = {}, query = {}, } = {}) => { + return new Promise((resolve, reject) => { + request.put(this.#buildUrl(url)).set(header).query(Object.assign(query, this.query)).send(data).end(this.#resultHandler(resolve, reject)); + }) + } + + delete = (url, { header = {}, query = {} } = {}) => { + return new Promise((resolve, reject) => { + request.delete(this.#buildUrl(url)).set(header).query(Object.assign(query, this.query)).end(this.#resultHandler(resolve, reject)); + }) + } +} + +function factory (app, opts) { + if (opts.pssaRequest) { + try { + for (let r of opts.pssaRequest) { + if (r.name && r.root) { + console.log(`${r.name} request factory created.`); + app.fs[r.name] = new paasRequest(r.root, { ...(r.params || {}) }, { dataWord: r.dataWord || 'body' }) + } else { + throw 'opts.pssaRequest 参数错误!' + } + } + } catch (error) { + console.error(error) + process.exit(-1); + } + } +} + +module.exports = factory; diff --git a/code/api/app/lib/service/redis.js b/code/api/app/lib/service/redis.js index 67af56a..e8fdf1c 100644 --- a/code/api/app/lib/service/redis.js +++ b/code/api/app/lib/service/redis.js @@ -47,7 +47,9 @@ module.exports = async function factory (app, opts) { async function hdelall (key) { const obj = await client.hgetall(key); const hkeys = Object.keys(obj) - await client.hdel(key, hkeys) + if (hkeys.length > 0) { + await client.hdel(key, hkeys) + } } app.redis = client diff --git a/code/api/app/lib/utils/index.js b/code/api/app/lib/utils/index.js new file mode 100644 index 0000000..4d3272e --- /dev/null +++ b/code/api/app/lib/utils/index.js @@ -0,0 +1,16 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); + +module.exports = async function (app, opts) { + fs.readdirSync(__dirname).forEach((filename) => { + if (!['index.js'].some(f => filename == f)) { + const utils = require(`./${filename}`)(app, opts) + app.fs.utils = { + ...app.fs.utils, + ...utils, + } + } + }); +}; diff --git a/code/api/app/lib/utils/oauth2.js b/code/api/app/lib/utils/oauth2.js new file mode 100644 index 0000000..7d00d06 --- /dev/null +++ b/code/api/app/lib/utils/oauth2.js @@ -0,0 +1,62 @@ +module.exports = function (app, opts) { + async function oauthParseAuthHeader (auth) { + + if ('isVcmp') { + // 去 vcmp 检查 appkey 和 appsecret 是否正确 + try { + const existRes = await app.fs.vcmpRequest.get(`application/check`, { header: { authorization: auth } }) + } catch (error) { + throw new Error('应用已禁用或不存在!'); + } + } + + if (!auth) { + throw new Error('参数无效: 未包含Authorization头'); + } + + const authSplit = auth.split('Basic'); + if (authSplit.length != 2) { + throw new Error('参数无效: Authorization头格式无效,请检查是否包含了"Basic "'); + } + + const authCode = authSplit[1]; + const apikey = Buffer.from(authCode, 'base64').toString(); + + const keySplit = apikey.split(':'); + if (keySplit.length != 2) { + throw new Error('参数无效:请检查Authorization头内容是否经过正确Base64编码'); + } + + return keySplit; + } + + async function oauthParseBody (body, type) { + let checked = true, token = ''; + if (type == 'apply' && body['grant_type'] != 'client_credentials') { + checked = false; + } else if (type == 'refresh') { + if (body['grant_type'] != 'refresh_token' || body['token'] == null) { + checked = false; + } else { + token = body['token']; + } + } else if (type == 'invalidate') { + if (body['token'] == null) { + checked = false; + } else { + token = body['token']; + } + } + + if (!checked) { + throw new Error('参数无效:请求正文中未包含正确的信息'); + } + + return token; + } + + return { + oauthParseAuthHeader, + oauthParseBody + } +} \ No newline at end of file diff --git a/code/api/config.js b/code/api/config.js index 39922bb..7949382 100644 --- a/code/api/config.js +++ b/code/api/config.js @@ -14,17 +14,23 @@ args.option(['f', 'fileHost'], '文件中心本地化存储: WebApi 服务器地 args.option('redisHost', 'redisHost'); args.option('redisPort', 'redisPort'); args.option('redisPswd', 'redisPassword'); +args.option('iotVcmpApi', 'IOT视频接入平台api'); const flags = args.parse(process.argv); const IOT_AUTH_DB = process.env.IOT_AUTH_DB || flags.pg; const IOT_AUTH_LOCAL_SVR_ORIGIN = process.env.IOT_AUTH_LOCAL_SVR_ORIGIN || flags.fileHost; +// 视频平台api +const IOT_VCMP_API = process.env.IOT_VCMP_API || flags.iotVcmpApi; + +// redis const IOTA_REDIS_SERVER_HOST = process.env.IOTA_REDIS_SERVER_HOST || flags.redisHost || "localhost";//redis IP const IOTA_REDIS_SERVER_PORT = process.env.IOTA_REDIS_SERVER_PORT || flags.redisPort || "6379";//redis 端口 const IOTA_REDIS_SERVER_PWD = process.env.IOTA_REDIS_SERVER_PWD || flags.redisPswd || "";//redis 密码 -if (!IOT_AUTH_DB || !IOTA_REDIS_SERVER_HOST || !IOTA_REDIS_SERVER_PORT) { +if (!IOT_AUTH_DB || !IOTA_REDIS_SERVER_HOST || !IOTA_REDIS_SERVER_PORT || + !IOT_VCMP_API) { console.log('缺少启动参数,异常退出'); args.showHelp(); process.exit(-1); @@ -49,14 +55,23 @@ const product = { opts: { exclude: [// 不做认证的路由,也可以使用 exclude: ["*"] 跳过所有路由 { p: '/cross_token/check', o: 'POST' }, - { p: '/user/:userId/message', o: 'GET' } + { p: '/user/:userId/message', o: 'GET' }, // 自有系统怎么鉴权捏 + { p: '/oauth2/token', o: 'POST' }, + { p: '/oauth2/token/refresh', o: 'POST' }, + { p: '/oauth2/token/invalidate', o: 'POST' }, ], redis: { host: IOTA_REDIS_SERVER_HOST, port: IOTA_REDIS_SERVER_PORT, pwd: IOTA_REDIS_SERVER_PWD }, - } + pssaRequest: [ + {// name 会作为一个 request 出现在 ctx.app.fs + name: 'vcmpRequest', + root: IOT_VCMP_API + }, + ] + }, } ], dc: { diff --git a/code/api/utils/forward-api.js b/code/api/utils/forward-api.js deleted file mode 100644 index 6b48e3e..0000000 --- a/code/api/utils/forward-api.js +++ /dev/null @@ -1,15 +0,0 @@ -'use strict'; -const proxy = require('koa-proxy'); -const convert = require('koa-convert'); - -module.exports = { - entry: function (app, router, opts) { - app.use(convert(proxy({ - host: opts.host, - match: opts.match, - map: function (path) { - return path.replace(opts.match, ''); - } - }))); - } -}; \ No newline at end of file diff --git a/code/web/package.json b/code/web/package.json index 7c43a7f..68929dc 100644 --- a/code/web/package.json +++ b/code/web/package.json @@ -7,7 +7,7 @@ "test": "mocha", "start-vite": "cross-env NODE_ENV=developmentVite npm run start-params", "start": "cross-env NODE_ENV=development npm run start-params", - "start-params": "node server -p 5200 -u http://10.8.30.34:4200", + "start-params": "node server -p 5200 -u http://10.8.30.112:4200", "deploy": "export NODE_ENV=production&& npm run build && node server", "build-dev": "cross-env NODE_ENV=development&&webpack --config webpack.config.js", "build": "export NODE_ENV=production&&webpack --config webpack.config.prod.js"