This commit is contained in:
Jay 2021-09-01 20:46:41 +08:00
parent b91ce62aa4
commit 2e05f90851
19 changed files with 3061 additions and 11174 deletions

View File

@ -1,38 +1,18 @@
const { env } = process; const { env } = process
module.exports = { module.exports = {
server: { server: {
<<<<<<< HEAD
url: env.SERVER_URL || "http://localhost:10230",
=======
url: env.SERVER_URL || 'http://localhost:10230', url: env.SERVER_URL || 'http://localhost:10230',
>>>>>>> c96cdf0ebd17f805235c6fa9eecf2ea79ecca19b
port: parseInt(env.SERVER_PORT, 10) || 10230, port: parseInt(env.SERVER_PORT, 10) || 10230,
jwt_secret: env.SERVER_JWT_SECRET || "testsecret", jwt_secret: env.SERVER_JWT_SECRET || 'testsecret',
jwt_expire: parseInt(env.SERVER_JWT_EXPIRE, 10) || 60 * 60 * 24 * 30, // 30 day jwt_expire: parseInt(env.SERVER_JWT_EXPIRE, 10) || 60 * 60 * 24 * 30 // 30 day
<<<<<<< HEAD
},
sso: {
authorized_endpoint: env.SSO_AUTHORIZED_ENDPOINT || "",
token_endpoint: env.SSO_TOKEN_ENDPOINT || "",
logout_endpoint: env.SSO_LOGOUT_ENDPOINT || "",
client_id: env.SSO_CLIENT_ID || "",
client_secret: env.SSO_CLIENT_SECRET || "",
=======
},
redis: {
host: env.REDIS_HOST || 'localhost',
port: parseInt(env.REDIS_PORT, 10) || 6379,
password: env.REDIS_PASSWORD || '',
prefix: env.REDIS_PREFIX || '',
db: parseInt(env.REDIS_DB, 10) || 0,
}, },
sso: { sso: {
authorized_endpoint: env.SSO_AUTHORIZED_ENDPOINT || '', authorized_endpoint: env.SSO_AUTHORIZED_ENDPOINT || '',
token_endpoint: env.SSO_TOKEN_ENDPOINT || '', token_endpoint: env.SSO_TOKEN_ENDPOINT || '',
userinfo_endpoint: env.SSO_USERINFO_ENDPOINT || '',
logout_endpoint: env.SSO_LOGOUT_ENDPOINT || '', logout_endpoint: env.SSO_LOGOUT_ENDPOINT || '',
client_id: env.SSO_CLIENT_ID || '', client_id: env.SSO_CLIENT_ID || '',
client_secret: env.SSO_CLIENT_SECRET || '', client_secret: env.SSO_CLIENT_SECRET || ''
>>>>>>> c96cdf0ebd17f805235c6fa9eecf2ea79ecca19b }
}, }
};

View File

@ -2,13 +2,7 @@
const constants = { const constants = {
PAGE_SIZE: 20, PAGE_SIZE: 20,
OPENID_EXPIRE: 300, // 5min OPENID_EXPIRE: 300, // 5min
<<<<<<< HEAD
ALLOW_GROUP_ROLE: ["Ironman3"], ALLOW_GROUP_ROLE: ["Ironman3"],
=======
INTERNAL_REGULATION_CACHE_TTL: 1800, // 30min
REPORT_CACHE_TTL: 600, // 10 min
ALLOW_GROUP_ROLE: ['Ironman3'] // 允許的 Group 身份
>>>>>>> c96cdf0ebd17f805235c6fa9eecf2ea79ecca19b
}; };
module.exports = constants; module.exports = constants;

View File

@ -1,41 +1,40 @@
const { resp } = require("src/utils/response/index.js"); const { resp } = require('src/utils/response/index.js')
const { get: getCacheInstance } = require("src/utils/cache.js"); const { get: getCacheInstance } = require('src/utils/cache.js')
const sso = require("src/utils/sso/index.js"); const sso = require('src/utils/sso/index.js')
const { OPENID_EXPIRE } = require("src/constants/index.js"); const uuid = require('uuid')
const uuid = require("uuid"); const url = require('url')
const url = require("url");
const controller = {}; const controller = {}
module.exports = controller; module.exports = controller
controller.loginSSO = () => async (ctx) => { controller.loginSSO = () => async (ctx) => {
const { back_url: backURL } = ctx.query; const { back_url: backURL } = ctx.query
const state = uuid.v4(); const state = uuid.v4()
const authURL = sso.getAuthURL(state); const authURL = sso.getAuthURL(state)
// store back url to cache // store back url to cache
const cacheKey = `login-${state}`; const cacheKey = `login-${state}`
const cache = getCacheInstance(); const cache = getCacheInstance()
cache.set(cacheKey, JSON.stringify({ back_url: backURL }), true); cache.set(cacheKey, JSON.stringify({ back_url: backURL }), true)
const u = new url.URL(authURL); const u = new url.URL(authURL)
ctx.resp(resp.Success, { url: u.toString() }); ctx.resp(resp.Success, { url: u.toString() })
}; }
controller.logout = () => async (ctx) => { controller.logout = () => async (ctx) => {
let link = ""; let link = ''
if (ctx.token.sso) { if (ctx.token.sso) {
link = sso.getLogoutURL(); link = sso.getLogoutURL()
} }
ctx.resp(resp.Success, { url: link }); ctx.resp(resp.Success, { url: link })
}; }
controller.getInfo = () => async (ctx) => { controller.getInfo = () => async (ctx) => {
ctx.resp(resp.Success, {}); ctx.resp(resp.Success, {})
}; }

View File

@ -1,33 +1,33 @@
/* eslint-disable no-bitwise */ /* eslint-disable no-bitwise */
const debug = require('debug')('ctrl:common'); const debug = require('debug')('ctrl:common')
const util = require('util'); const util = require('util')
const joi = require('joi'); const joi = require('joi')
const response = require('src/utils/response/index.js'); const response = require('src/utils/response/index.js')
const config = require('src/config/index.js'); const config = require('src/config/index.js')
const { jwt } = require('src/utils/pkgs.js'); const { jwt } = require('src/utils/pkgs.js')
const { copyObject, toNumber } = require('src/utils/index.js'); const { copyObject, toNumber } = require('src/utils/index.js')
const { Success, InternalError, DataFormat, Forbidden, Unauthorized } = response.resp; const { Success, InternalError, DataFormat, Forbidden, Unauthorized } = response.resp
const controller = {}; const controller = {}
module.exports = controller; module.exports = controller
/** /**
* api reponse function * api reponse function
* @param {import('src/utils/response/index.js').respObject} resp * @param {import('src/utils/response/index.js').respObject} resp
* @param {string|Object} body * @param {string|Object} body
*/ */
function responseFunc(resp, body) { function responseFunc (resp, body) {
/** @type {import('src/utils/response/index.js').respObject} */ /** @type {import('src/utils/response/index.js').respObject} */
const copy = copyObject(response.checkStruct(resp) ? resp : Success); const copy = copyObject(response.checkStruct(resp) ? resp : Success)
if (typeof body === 'string') copy.object.message = body; if (typeof body === 'string') copy.object.message = body
else if (typeof body === 'object') copy.object = body; else if (typeof body === 'object') copy.object = body
// @ts-ignore // @ts-ignore
if (!('obj' in this)) this.obj = {}; if (!('obj' in this)) this.obj = {}
this.obj.status = copy.status; this.obj.status = copy.status
this.obj.object = copy.object; this.obj.object = copy.object
} }
/** /**
@ -35,134 +35,134 @@ function responseFunc(resp, body) {
* @param {import('src/utils/response/index.js').respObject} resp * @param {import('src/utils/response/index.js').respObject} resp
* @param {import('src/utils/response/index.js').codeMessage=} code * @param {import('src/utils/response/index.js').codeMessage=} code
*/ */
function responseError(resp, code) { function responseError (resp, code) {
/** @type {import('src/utils/response/index.js').respObject} */ /** @type {import('src/utils/response/index.js').respObject} */
const copy = copyObject(response.checkStruct(resp) ? resp : InternalError); const copy = copyObject(response.checkStruct(resp) ? resp : InternalError)
if (code && typeof code === 'object' && 'message' in code && 'code' in code) { if (code && typeof code === 'object' && 'message' in code && 'code' in code) {
copy.object = code; copy.object = code
} }
const err = new response.APIError(copy.object.message, copy); const err = new response.APIError(copy.object.message, copy)
throw err; throw err
} }
controller.apiHandler = () => async (ctx, next) => { controller.apiHandler = () => async (ctx, next) => {
ctx.obj = {}; ctx.obj = {}
ctx.token = {}; ctx.token = {}
ctx.resp = responseFunc.bind(ctx); ctx.resp = responseFunc.bind(ctx)
ctx.err = responseError; ctx.err = responseError
ctx.getBody = key => (ctx.request.body || {})[key]; ctx.getBody = key => (ctx.request.body || {})[key]
ctx.getFile = key => (ctx.request.files || {})[key]; ctx.getFile = key => (ctx.request.files || {})[key]
// run next // run next
try { try {
await next(); await next()
} catch (err) { } catch (err) {
debug(`Get API Throw Error: ${util.inspect(err, false, null)}`); debug(`Get API Throw Error: ${util.inspect(err, false, null)}`)
// debug(err.stack); // debug(err.stack);
if (!(err instanceof response.APIError)) { if (!(err instanceof response.APIError)) {
ctx.resp(InternalError); ctx.resp(InternalError)
} else { } else {
ctx.obj = err.object; ctx.obj = err.object
} }
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
ctx.obj.object.errorStack = err.stack; ctx.obj.object.errorStack = err.stack
ctx.obj.object.errorMessage = err.message; ctx.obj.object.errorMessage = err.message
} }
} }
if (Object.keys(ctx.obj).length > 0) { if (Object.keys(ctx.obj).length > 0) {
ctx.status = ctx.obj.status; ctx.status = ctx.obj.status
ctx.body = ctx.obj.object; ctx.body = ctx.obj.object
} }
}; }
/** /**
* data validate middleware * data validate middleware
* @param {{query?: any, header?: any, body?: any}} schema body,query and header is joi.Schema * @param {{query?: any, header?: any, body?: any}} schema body,query and header is joi.Schema
*/ */
controller.validate = schema => { controller.validate = schema => {
if (typeof schema !== 'object') responseError(InternalError); if (typeof schema !== 'object') responseError(InternalError)
const v = {}; const v = {}
if ('body' in schema) v.body = joi.isSchema(schema.body) ? schema.body : joi.object(schema.body).unknown(); if ('body' in schema) v.body = joi.isSchema(schema.body) ? schema.body : joi.object(schema.body).unknown()
if ('header' in schema) v.header = joi.isSchema(schema.header) ? schema.header : joi.object(schema.header).unknown(); if ('header' in schema) v.header = joi.isSchema(schema.header) ? schema.header : joi.object(schema.header).unknown()
if ('query' in schema) v.query = joi.isSchema(schema.query) ? schema.query : joi.object(schema.query).unknown(); if ('query' in schema) v.query = joi.isSchema(schema.query) ? schema.query : joi.object(schema.query).unknown()
return async (ctx, next) => { return async (ctx, next) => {
try { try {
await joi.object(v).unknown().validateAsync({ query: ctx.query, header: ctx.headers, body: ctx.request.body }); await joi.object(v).unknown().validateAsync({ query: ctx.query, header: ctx.headers, body: ctx.request.body })
} catch (err) { } catch (err) {
debug(`data validate error: ${util.inspect(err, false, null)}`); debug(`data validate error: ${util.inspect(err, false, null)}`)
responseError(DataFormat); responseError(DataFormat)
} }
return next(); return next()
}; }
}; }
/** /**
* @param {boolean=} allowExpired * @param {boolean=} allowExpired
* @return {import('koa').Middleware} * @return {import('koa').Middleware}
*/ */
controller.authorization = allowExpired => { controller.authorization = allowExpired => {
return async (ctx, next) => { return async (ctx, next) => {
ctx.token = {}; ctx.token = {}
/** @type {string} */ /** @type {string} */
const token = ctx.get('authorization'); const token = ctx.get('authorization')
if (!token) ctx.err(Unauthorized); if (!token) ctx.err(Unauthorized)
try { try {
const strs = token.split(/\s/); const strs = token.split(/\s/)
debug(`Get Header: ${token}`); debug(`Get Header: ${token}`)
if (strs.length !== 2 || !/^bearer$/i.test(strs[0])) ctx.err(Unauthorized, response.codeMessage.CodeTokenInvalid); if (strs.length !== 2 || !/^bearer$/i.test(strs[0])) ctx.err(Unauthorized, response.codeMessage.CodeTokenInvalid);
[, ctx.token.origin] = strs; [, ctx.token.origin] = strs
let decoded = {}; let decoded = {}
let expired = false; let expired = false
try { try {
decoded = jwt.verify(strs[1], config.server.jwt_secret); decoded = jwt.verify(strs[1], config.server.jwt_secret)
await joi await joi
.object({ .object({
user_id: joi.string().required(), user_id: joi.string().required()
}) })
.unknown() .unknown()
.validateAsync(decoded); .validateAsync(decoded)
} catch (err) { } catch (err) {
debug(`jwt token verify fail: ${util.inspect(err, false, null)}`); debug(`jwt token verify fail: ${util.inspect(err, false, null)}`)
if (err instanceof jwt.TokenExpiredError) { if (err instanceof jwt.TokenExpiredError) {
decoded = jwt.decode(ctx.token.origin); decoded = jwt.decode(ctx.token.origin)
expired = true; expired = true
} else { } else {
throw err; throw err
} }
} }
ctx.token.user_id = decoded.user_id; ctx.token.user_id = decoded.user_id
ctx.token.sso = !!decoded.sso; ctx.token.sso = !!decoded.sso
if (expired) ctx.err(Forbidden, response.codeMessage.CodeTokenExpired); if (expired) ctx.err(Forbidden, response.codeMessage.CodeTokenExpired)
ctx.verified = true; ctx.verified = true
} catch (err) { } catch (err) {
debug(`Token valid fail: ${util.inspect(err, false, null)}`); debug(`Token valid fail: ${util.inspect(err, false, null)}`)
if (err instanceof response.APIError) { if (err instanceof response.APIError) {
// 如果是過期的錯誤,判斷是否允許過期存取 // 如果是過期的錯誤,判斷是否允許過期存取
// @ts-ignore // @ts-ignore
// eslint-disable-next-line // eslint-disable-next-line
if (err._object?.object?.code === response.codeMessage.CodeTokenExpired.code) { if (err._object?.object?.code === response.codeMessage.CodeTokenExpired.code) {
if (!!allowExpired) return next(); if (allowExpired) return next()
} }
} }
throw err; throw err
} }
return next(); return next()
}; }
}; }

View File

@ -1,7 +1,7 @@
const controller = {}; const controller = {}
module.exports = controller; module.exports = controller
controller.healthCheck = async ctx => { controller.healthCheck = async ctx => {
ctx.body = 'ok'; ctx.body = 'ok'
ctx.status = 200; ctx.status = 200
}; }

View File

@ -1,80 +1,70 @@
const debug = require("debug")("ctrl:common"); const debug = require('debug')('ctrl:common')
const util = require("util"); const util = require('util')
const url = require("url"); const url = require('url')
const sso = require("src/utils/sso/index.js"); const sso = require('src/utils/sso/index.js')
const { get: getCacheInstance } = require("src/utils/cache.js"); const { get: getCacheInstance } = require('src/utils/cache.js')
const { codeMessage, APIError } = require("src/utils/response/index.js"); const { codeMessage, APIError } = require('src/utils/response/index.js')
const config = require("src/config/index.js"); const config = require('src/config/index.js')
const { jwt } = require("src/utils/pkgs.js"); const { jwt } = require('src/utils/pkgs.js')
const controller = {}; const controller = {}
module.exports = controller; module.exports = controller
controller.verifyCode = () => async (ctx) => { controller.verifyCode = () => async (ctx) => {
const { code, session_state: sessionState, state } = ctx.query; const { code, session_state: sessionState, state } = ctx.query
// logout flow redirect tot frontend // logout flow redirect tot frontend
if (state === "logout") { if (state === 'logout') {
ctx.redirect(config.server.frontend_url); ctx.redirect(config.server.frontend_url)
return; return
} }
// get back url from redis // get back url from redis
const cacheKey = `login-${state}`; const cacheKey = `login-${state}`
const cache = getCacheInstance(); const cache = getCacheInstance()
const data = cache.get(cacheKey); const data = cache.get(cacheKey)
if (!data) ctx.throw("get login cache fail"); if (!data) ctx.throw('get login cache fail')
const stateObj = JSON.parse(data); const stateObj = JSON.parse(data)
const { back_url: backURL } = stateObj; const { back_url: backURL } = stateObj
if (!backURL) ctx.throw("cache data missing"); if (!backURL) ctx.throw('cache data missing')
const u = new url.URL(backURL); const u = new url.URL(backURL)
try { try {
const token = await sso.getToken(code, sessionState); const token = await sso.getToken(code, sessionState)
// generate jwt token // set accessToken/refreshToken cache
const jwtToken = jwt.sign( cache.set(token.access_token, token.refresh_token, false)
{
user_id: token.user_id,
sso: true,
},
config.server.jwt_secret,
{
expiresIn: config.server.jwt_expire,
issuer: "lawsnote",
}
);
u.searchParams.append( u.searchParams.append(
"success", 'success',
Buffer.from(JSON.stringify({ token: jwtToken })).toString("base64") Buffer.from(JSON.stringify({ token: token.access_token })).toString('base64')
); )
try { try {
cache.del(cacheKey); cache.del(cacheKey)
} catch (err) { } catch (err) {
debug(`delete cache fail: ${util.inspect(err, false, null)}`); debug(`delete cache fail: ${util.inspect(err, false, null)}`)
} }
} catch (err) { } catch (err) {
debug(`openid verify fail: ${util.inspect(err, false, null)}`); debug(`openid verify fail: ${util.inspect(err, false, null)}`)
/** @type {object} */ /** @type {object} */
const errObj = { ...codeMessage.CodeInternalError }; const errObj = { ...codeMessage.CodeInternalError }
if (err instanceof APIError) { if (err instanceof APIError) {
// @ts-ignore // @ts-ignore
Object.assign(errObj, err.object.object); Object.assign(errObj, err.object.object)
} }
errObj.errorStack = err.stack; errObj.errorStack = err.stack
errObj.errorMessage = err.message; errObj.errorMessage = err.message
u.searchParams.append( u.searchParams.append(
"error", 'error',
Buffer.from(JSON.stringify(errObj)).toString("base64") Buffer.from(JSON.stringify(errObj)).toString('base64')
); )
} }
ctx.redirect(u.toString()); ctx.redirect(u.toString())
}; }

View File

@ -1,15 +1,15 @@
require("dotenv").config(); require('dotenv').config()
const config = require("src/config/index.js"); const config = require('src/config/index.js')
const { new: newCacheInstance } = require("src/utils/cache.js"); const { new: newCacheInstance } = require('src/utils/cache.js')
const app = require("./server.js"); const app = require('./server.js')
async function runServer() { async function runServer () {
newCacheInstance(); newCacheInstance()
const server = app.listen(config.server.port, () => { const server = app.listen(config.server.port, () => {
// @ts-ignore // @ts-ignore
console.info(`server start on port ${server.address().port}`); console.info(`server start on port ${server.address().port}`)
}); })
} }
runServer(); runServer()

13346
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,10 +5,6 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"start": "node index.js", "start": "node index.js",
<<<<<<< HEAD
=======
"test": "mocha --timeout 5000 --exit test/ && jest --passWithNoTests --runInBand --coverage .",
>>>>>>> c96cdf0ebd17f805235c6fa9eecf2ea79ecca19b
"postinstall": "node -e \"var s='../',d='node_modules/src',fs=require('fs');fs.exists(d,function(e){e||fs.symlinkSync(s,d,'dir')});\"" "postinstall": "node -e \"var s='../',d='node_modules/src',fs=require('fs');fs.exists(d,function(e){e||fs.symlinkSync(s,d,'dir')});\""
}, },
"keywords": [], "keywords": [],
@ -34,13 +30,9 @@
"devDependencies": { "devDependencies": {
"chai": "4.2.0", "chai": "4.2.0",
"eslint": "^7.2.0", "eslint": "^7.2.0",
"eslint-config-airbnb-base": "^14.2.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-import": "^2.21.2",
"eslint-plugin-prettier": "^3.1.3",
"jest": "^26.6.0", "jest": "^26.6.0",
"mocha": "^8.2.0", "mocha": "^8.2.0",
"prettier": "^2.0.5", "standard": "^16.0.3",
"supertest": "^5.0.0" "supertest": "^5.0.0"
}, },
"nodemonConfig": { "nodemonConfig": {

View File

@ -1,13 +1,13 @@
const Router = require('@koa/router'); const Router = require('@koa/router')
const joi = require('joi'); const joi = require('joi')
const commonCtrl = require('src/controllers/common/index.js'); const commonCtrl = require('src/controllers/common/index.js')
const accCtrl = require('src/controllers/account/index.js'); const accCtrl = require('src/controllers/account/index.js')
const r = new Router({ prefix: '/api' }); const r = new Router({ prefix: '/api' })
module.exports = r; module.exports = r
// set api handler middleware // set api handler middleware
r.use(commonCtrl.apiHandler()); r.use(commonCtrl.apiHandler())
/** /**
* get account info * get account info
@ -17,15 +17,15 @@ r.use(commonCtrl.apiHandler());
* @param {string} back_url.query.required - back to url * @param {string} back_url.query.required - back to url
* @returns {RespDefault.model} default - * @returns {RespDefault.model} default -
*/ */
r.get( r.get(
'/login', '/login',
commonCtrl.validate({ commonCtrl.validate({
query: { query: {
back_url: joi.string().required(), back_url: joi.string().required()
}, }
}), }),
accCtrl.loginSSO() accCtrl.loginSSO()
); )
/** /**
* account refresh token * account refresh token
@ -35,7 +35,7 @@ r.use(commonCtrl.apiHandler());
* @security JWT * @security JWT
* @returns {RespDefault.model} default - * @returns {RespDefault.model} default -
*/ */
r.post('/refresh', commonCtrl.authorization(true), accCtrl.logout()); r.post('/refresh', commonCtrl.authorization(true), accCtrl.logout())
/** /**
* account logout * account logout
@ -45,7 +45,7 @@ r.post('/refresh', commonCtrl.authorization(true), accCtrl.logout());
* @security JWT * @security JWT
* @returns {RespDefault.model} default - * @returns {RespDefault.model} default -
*/ */
r.post('/logout', commonCtrl.authorization(false), accCtrl.logout()); r.post('/logout', commonCtrl.authorization(false), accCtrl.logout())
/** /**
* account get info * account get info
@ -55,4 +55,4 @@ r.post('/logout', commonCtrl.authorization(false), accCtrl.logout());
* @security JWT * @security JWT
* @returns {RespDefault.model} default - * @returns {RespDefault.model} default -
*/ */
r.get('/userinfo', commonCtrl.authorization(false), accCtrl.getInfo()); r.get('/userinfo', commonCtrl.authorization(false), accCtrl.getInfo())

View File

@ -1,13 +1,13 @@
const Router = require('@koa/router'); const Router = require('@koa/router')
const controller = require('src/controllers/index.js'); const controller = require('src/controllers/index.js')
const apiRouter = require('./api/index.js'); const apiRouter = require('./api/index.js')
const oauthRouter = require('./oauth/index.js'); const oauthRouter = require('./oauth/index.js')
const r = new Router(); const r = new Router()
module.exports = r; module.exports = r
r.get('/', controller.healthCheck); r.get('/', controller.healthCheck)
r.use(apiRouter.routes()); r.use(apiRouter.routes())
r.use(oauthRouter.routes()); r.use(oauthRouter.routes())

View File

@ -1,8 +1,8 @@
const Router = require('@koa/router'); const Router = require('@koa/router')
const oauthCtrl = require('src/controllers/oauth/index.js'); const oauthCtrl = require('src/controllers/oauth/index.js')
const r = new Router({ prefix: '/oauth' }); const r = new Router({ prefix: '/oauth' })
r.get('/redirect', oauthCtrl.verifyCode()); r.get('/redirect', oauthCtrl.verifyCode())
module.exports = r; module.exports = r

View File

@ -1,5 +1,5 @@
// @ts-nocheck // @ts-nocheck
module.exports = {}; module.exports = {}
/** /**
* @typedef RespDefault * @typedef RespDefault
@ -9,4 +9,3 @@ module.exports = {};
* @property {string} errorStack api error stack (除了prod以外的環境會有) * @property {string} errorStack api error stack (除了prod以外的環境會有)
* @property {string} errorMessage api error message (除了prod以外的環境會有) * @property {string} errorMessage api error message (除了prod以外的環境會有)
*/ */

View File

@ -1,28 +1,28 @@
const Koa = require('koa'); const Koa = require('koa')
const path = require('path'); const path = require('path')
const url = require('url'); const url = require('url')
const swaggerGenerator = require('@mtfos/swagger-generator'); const swaggerGenerator = require('@mtfos/swagger-generator')
const { copyObject } = require('src/utils/index.js'); const { copyObject } = require('src/utils/index.js')
const app = new Koa(); const app = new Koa()
const config = require('src/config/index.js'); const config = require('src/config/index.js')
// @ts-ignore // @ts-ignore
app.proxy = true; app.proxy = true
// const server = app.listen(config.server.port, () => { // const server = app.listen(config.server.port, () => {
// console.log(`server start on port ${server.address().port}`); // eslint-disable-line // console.log(`server start on port ${server.address().port}`); // eslint-disable-line
// }); // });
// load middleware module // load middleware module
const koaLogger = require('koa-logger'); const koaLogger = require('koa-logger')
const koaCors = require('@koa/cors'); const koaCors = require('@koa/cors')
const koaBody = require('koa-body'); const koaBody = require('koa-body')
const koaMount = require('koa-mount'); const koaMount = require('koa-mount')
const koaStatic = require('koa-static'); const koaStatic = require('koa-static')
const rootRouter = require('src/routes/index.js'); const rootRouter = require('src/routes/index.js')
const packageJSON = require('./package.json'); const packageJSON = require('./package.json')
let swaggerDoc = null; let swaggerDoc = null;
// generate swagger document // generate swagger document
@ -32,7 +32,7 @@ let swaggerDoc = null;
info: { info: {
description: 'KeyCloak OAuth Demo Server', description: 'KeyCloak OAuth Demo Server',
title: 'KeyCloak', title: 'KeyCloak',
version: packageJSON.version, version: packageJSON.version
}, },
host: new url.URL(config.server.url).host, host: new url.URL(config.server.url).host,
basePath: '', basePath: '',
@ -43,57 +43,57 @@ let swaggerDoc = null;
type: 'apiKey', type: 'apiKey',
in: 'header', in: 'header',
name: 'Authorization', name: 'Authorization',
description: 'Bearer token', description: 'Bearer token'
}, }
}, }
}, },
basedir: __dirname, basedir: __dirname,
files: ['./routes/**/*.js'], files: ['./routes/**/*.js']
}; }
swaggerDoc = await swaggerGenerator.generateSpec(swaggerSpec); swaggerDoc = await swaggerGenerator.generateSpec(swaggerSpec)
})().catch(err => { })().catch(err => {
console.error('[Error] Generate swagger doc failed, ', err); console.error('[Error] Generate swagger doc failed, ', err)
process.exit(1); process.exit(1)
}); })
// production 不掛上swagger ui // production 不掛上swagger ui
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
app.use(koaMount('/api-docs/', koaStatic(path.resolve(__dirname, 'public', 'swagger-ui'), { index: 'index.html' }))); app.use(koaMount('/api-docs/', koaStatic(path.resolve(__dirname, 'public', 'swagger-ui'), { index: 'index.html' })))
rootRouter.get('/api-docs', async c => { rootRouter.get('/api-docs', async c => {
if (!/\/$/.test(c.url)) c.redirect(`${c.url}/`); if (!/\/$/.test(c.url)) c.redirect(`${c.url}/`)
}); })
} }
// set swagger doc route // set swagger doc route
rootRouter.get('/api-docs.json', async c => { rootRouter.get('/api-docs.json', async c => {
c.type = 'application/json'; c.type = 'application/json'
const spec = copyObject(swaggerDoc || {}); const spec = copyObject(swaggerDoc || {})
if (c.protocol === 'https') { if (c.protocol === 'https') {
spec.schemes = ['https']; spec.schemes = ['https']
} }
c.body = spec; c.body = spec
}); })
app.use(koaLogger()); app.use(koaLogger())
app.use( app.use(
koaCors({ koaCors({
credentials: true, credentials: true,
// allow all origin // allow all origin
origin: ctx => ctx.get('origin'), origin: ctx => ctx.get('origin')
}) })
); )
app.use( app.use(
koaBody({ koaBody({
multipart: true, multipart: true,
formidable: { formidable: {
maxFileSize: 100 * 1024 * 1024, // 100 mb maxFileSize: 100 * 1024 * 1024 // 100 mb
}, }
}) })
); )
app.use(koaMount('/', koaStatic(path.resolve(__dirname, 'public', 'html')))); app.use(koaMount('/', koaStatic(path.resolve(__dirname, 'public', 'html'))))
app.use(rootRouter.allowedMethods()); app.use(rootRouter.allowedMethods())
app.use(rootRouter.routes()); app.use(rootRouter.routes())
module.exports = app; module.exports = app

View File

@ -1,6 +1,6 @@
class Cache { class Cache {
constructor() { constructor () {
this.kv = {}; this.kv = {}
} }
/** /**
@ -8,41 +8,44 @@ class Cache {
* @param {string} value * @param {string} value
* @param {boolean?} noOverride * @param {boolean?} noOverride
*/ */
set(key, value, noOverride) { set (key, value, noOverride) {
if (noOverride && key in this.kv) { if (noOverride && key in this.kv) {
throw new Error("key exists"); throw new Error('key exists')
} }
this.kv[key] = value; this.kv[key] = value
} }
/** /**
* @param {string} key * @param {string} key
* @return {string?} * @return {string?}
*/ */
get(key) { get (key) {
return this.kv[key] || null; return this.kv[key] || null
} }
/** /**
* @param {string[]} keys * @param {string[]} keys
*/ */
del(...keys) { del (...keys) {
for (const key of keys) { for (const key of keys) {
delete this.kv[key]; delete this.kv[key]
} }
} }
} }
let cache = null; let cache = null
exports.new = function () { exports.new = function () {
if (cache) throw new Error("cache already initiate"); if (cache) throw new Error('cache already initiate')
cache = new Cache(); cache = new Cache()
return cache; return cache
}; }
/**
* @return {Cache}
*/
exports.get = function () { exports.get = function () {
if (!cache) throw new Error("cache not initiate"); if (!cache) throw new Error('cache not initiate')
return cache; return cache
}; }

View File

@ -1,13 +1,13 @@
/* eslint-disable no-restricted-globals,no-param-reassign,guard-for-in,no-restricted-syntax,no-continue */ /* eslint-disable no-restricted-globals,no-param-reassign,guard-for-in,no-restricted-syntax,no-continue */
const mod = {}; const mod = {}
module.exports = mod; module.exports = mod
/** /**
* check value is number * check value is number
* @param {any} v input value * @param {any} v input value
* @return {boolean} * @return {boolean}
*/ */
mod.isNumber = v => !(!isFinite(v) || v === true || v === false || v === null || v === ''); mod.isNumber = v => !(!isFinite(v) || v === true || v === false || v === null || v === '')
/** /**
* value to number * value to number
@ -18,20 +18,20 @@ mod.isNumber = v => !(!isFinite(v) || v === true || v === false || v === null ||
* @return {number} * @return {number}
*/ */
mod.toNumber = (v, defVal, min, max) => { mod.toNumber = (v, defVal, min, max) => {
let defaultVal = defVal; let defaultVal = defVal
let inVal = v; let inVal = v
if (!mod.isNumber(defVal)) defVal = 0; if (!mod.isNumber(defVal)) defVal = 0
if (typeof defVal === 'string') defaultVal = parseFloat(defVal); if (typeof defVal === 'string') defaultVal = parseFloat(defVal)
const minVal = !mod.isNumber(min) ? null : typeof min === 'string' ? parseFloat(min) : min; // eslint-disable-line const minVal = !mod.isNumber(min) ? null : typeof min === 'string' ? parseFloat(min) : min; // eslint-disable-line
const maxVal = !mod.isNumber(max) ? null : typeof max === 'string' ? parseFloat(max) : max; // eslint-disable-line const maxVal = !mod.isNumber(max) ? null : typeof max === 'string' ? parseFloat(max) : max; // eslint-disable-line
if (!mod.isNumber(v)) return defaultVal; if (!mod.isNumber(v)) return defaultVal
if (typeof v === 'string') inVal = parseFloat(v); if (typeof v === 'string') inVal = parseFloat(v)
if (minVal !== null && inVal < minVal) inVal = min; if (minVal !== null && inVal < minVal) inVal = min
if (maxVal !== null && inVal > maxVal) inVal = max; if (maxVal !== null && inVal > maxVal) inVal = max
return inVal; return inVal
}; }
/** /**
* @exports * @exports
@ -49,24 +49,24 @@ mod.toNumber = (v, defVal, min, max) => {
* @return {pageObject} * @return {pageObject}
*/ */
mod.calcPage = (argCount, argPage, argMaxItem = 10) => { mod.calcPage = (argCount, argPage, argMaxItem = 10) => {
const count = mod.toNumber(argCount, 0, 0); const count = mod.toNumber(argCount, 0, 0)
let page = mod.toNumber(argPage, 1, 1); let page = mod.toNumber(argPage, 1, 1)
const maxItem = mod.toNumber(argMaxItem, 10, 1); const maxItem = mod.toNumber(argMaxItem, 10, 1)
let total = Math.ceil(count / maxItem); let total = Math.ceil(count / maxItem)
if (total < 1) total = 1; if (total < 1) total = 1
if (page > total) page = total; if (page > total) page = total
let offset = (page - 1) * maxItem; let offset = (page - 1) * maxItem
if (offset > count) offset = count; if (offset > count) offset = count
const limit = maxItem; const limit = maxItem
const pageObject = { total, page, count, offset, limit }; const pageObject = { total, page, count, offset, limit }
for (const key in pageObject) { for (const key in pageObject) {
pageObject[key] = mod.toNumber(pageObject[key]); pageObject[key] = mod.toNumber(pageObject[key])
} }
return pageObject; return pageObject
}; }
/** /**
* deep copy object * deep copy object
@ -74,18 +74,18 @@ mod.calcPage = (argCount, argPage, argMaxItem = 10) => {
* @return {any} * @return {any}
*/ */
mod.copyObject = src => { mod.copyObject = src => {
if (typeof src !== 'object') return src; if (typeof src !== 'object') return src
const isArray = Array.isArray(src); const isArray = Array.isArray(src)
const copy = isArray ? [] : {}; const copy = isArray ? [] : {}
for (let it in src) { for (let it in src) {
// @ts-ignore // @ts-ignore
if (isArray) it = parseInt(it, 10); if (isArray) it = parseInt(it, 10)
if (typeof src[it] !== 'object') copy[it] = src[it]; if (typeof src[it] !== 'object') copy[it] = src[it]
else copy[it] = mod.copyObject(src[it]); else copy[it] = mod.copyObject(src[it])
} }
return copy; return copy
}; }
/** /**
* @typedef {(string|number)} SelectObjectAlias * @typedef {(string|number)} SelectObjectAlias
@ -102,23 +102,23 @@ mod.copyObject = src => {
* @param {SelectObjectParam} param * @param {SelectObjectParam} param
*/ */
mod.selectObject = (obj, param) => { mod.selectObject = (obj, param) => {
if (typeof obj !== 'object' || typeof param !== 'object') throw new Error('input arg wrong'); if (typeof obj !== 'object' || typeof param !== 'object') throw new Error('input arg wrong')
let newObj = {}; let newObj = {}
for (const key in param) { for (const key in param) {
const strs = key.split('.'); const strs = key.split('.')
const alias = param[key]; const alias = param[key]
if (strs.length > 1) { if (strs.length > 1) {
if (!(strs[0] in obj)) continue; if (!(strs[0] in obj)) continue
newObj = { ...newObj, ...mod.selectObject(obj[strs[0]], { [strs.slice(1).join('.')]: alias }) }; newObj = { ...newObj, ...mod.selectObject(obj[strs[0]], { [strs.slice(1).join('.')]: alias }) }
} }
const toAlias = param[key] && typeof param[key] === 'string'; const toAlias = param[key] && typeof param[key] === 'string'
if (!(key in obj)) continue; if (!(key in obj)) continue
newObj[toAlias ? alias : key] = obj[key]; newObj[toAlias ? alias : key] = obj[key]
} }
return newObj; return newObj
}; }

View File

@ -1,9 +1,9 @@
const path = require('path'); const path = require('path')
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken')
const pkg = {}; const pkg = {}
pkg.path = path; pkg.path = path
pkg.jwt = jwt; pkg.jwt = jwt
module.exports = pkg; module.exports = pkg

View File

@ -18,36 +18,36 @@
* @param {codeMessage} codeMsg * @param {codeMessage} codeMsg
*/ */
const mod = {}; const mod = {}
module.exports = mod; module.exports = mod
mod.respDefault = (status = 200, codeMsg) => ({ status, object: codeMsg }); mod.respDefault = (status = 200, codeMsg) => ({ status, object: codeMsg })
mod.APIError = class extends Error { mod.APIError = class extends Error {
/** /**
* @param {string} message * @param {string} message
* @param {respObject} resp * @param {respObject} resp
*/ */
constructor(message = '', resp) { constructor (message = '', resp) {
super(message); super(message)
this._object = resp || {}; this._object = resp || {}
} }
get object() { get object () {
return this._object; return this._object
} }
}; }
/** /**
* check response object struct * check response object struct
* @param {respObject} v * @param {respObject} v
*/ */
mod.checkStruct = v => { mod.checkStruct = v => {
if (typeof v !== 'object' || v === null || v === undefined) return false; if (typeof v !== 'object' || v === null || v === undefined) return false
if (!('status' in v) || !('object' in v)) return false; if (!('status' in v) || !('object' in v)) return false
if (typeof v.object !== 'object' || !('code' in v.object) || !('message' in v.object)) return false; if (typeof v.object !== 'object' || !('code' in v.object) || !('message' in v.object)) return false
return true; return true
}; }
mod.codeMessage = { mod.codeMessage = {
CodeSuccess: { code: 1000, message: 'success' }, CodeSuccess: { code: 1000, message: 'success' },
@ -57,8 +57,8 @@ mod.codeMessage = {
CodeUnauthorized: { code: 1004, message: 'unauthorized' }, CodeUnauthorized: { code: 1004, message: 'unauthorized' },
CodeForbidden: { code: 1005, message: 'forbidden' }, CodeForbidden: { code: 1005, message: 'forbidden' },
CodeNotFound: { code: 1006, message: 'not found' }, CodeNotFound: { code: 1006, message: 'not found' },
CodeInternalError: { code: 1007, message: 'internal error' }, CodeInternalError: { code: 1007, message: 'internal error' }
}; }
mod.resp = { mod.resp = {
Success: mod.respDefault(200, mod.codeMessage.CodeSuccess), Success: mod.respDefault(200, mod.codeMessage.CodeSuccess),
@ -68,5 +68,5 @@ mod.resp = {
Unauthorized: mod.respDefault(401, mod.codeMessage.CodeUnauthorized), Unauthorized: mod.respDefault(401, mod.codeMessage.CodeUnauthorized),
Forbidden: mod.respDefault(403, mod.codeMessage.CodeForbidden), Forbidden: mod.respDefault(403, mod.codeMessage.CodeForbidden),
NotFound: mod.respDefault(404, mod.codeMessage.CodeNotFound), NotFound: mod.respDefault(404, mod.codeMessage.CodeNotFound),
InternalError: mod.respDefault(500, mod.codeMessage.CodeInternalError), InternalError: mod.respDefault(500, mod.codeMessage.CodeInternalError)
}; }

View File

@ -1,12 +1,11 @@
const joi = require("joi"); const joi = require('joi')
const url = require("url"); const url = require('url')
const querystring = require("querystring"); const querystring = require('querystring')
const got = require("got"); const got = require('got')
const config = require("src/config/index.js"); const config = require('src/config/index.js')
const { jwt } = require("src/utils/pkgs.js");
const mod = {}; const mod = {}
module.exports = mod; module.exports = mod
/** /**
* @return {string} * @return {string}
@ -18,29 +17,29 @@ mod.getAuthURL = (state) => {
token_endpoint: joi.string().required(), token_endpoint: joi.string().required(),
client_id: joi.string().required(), client_id: joi.string().required(),
client_secret: joi.string().required(), client_secret: joi.string().required(),
state: joi.string().allow("", null).default(""), state: joi.string().allow('', null).default('')
}) })
.unknown() .unknown()
.validate({ ...config.sso, state }); .validate({ ...config.sso, state })
if (input.error) throw new Error(input.error.message); if (input.error) throw new Error(input.error.message)
/** /**
* @type {{value: { authorized_endpoint: string, token_endpoint: string, client_id: string, client_secret: string, state: string }}} * @type {{value: { authorized_endpoint: string, token_endpoint: string, client_id: string, client_secret: string, state: string }}}
*/ */
const { value } = input; const { value } = input
const redirectUri = new url.URL("/oauth/redirect", config.server.url); const redirectUri = new url.URL('/oauth/redirect', config.server.url)
const qs = { const qs = {
client_id: value.client_id, client_id: value.client_id,
scope: "offline_access", scope: 'offline_access',
response_type: "code", response_type: 'code',
redirect_uri: redirectUri.toString(), redirect_uri: redirectUri.toString()
}; }
if (value.state) qs.state = state; if (value.state) qs.state = state
return `${value.authorized_endpoint}?${querystring.stringify(qs)}`; return `${value.authorized_endpoint}?${querystring.stringify(qs)}`
}; }
/** /**
* @return {string} * @return {string}
@ -48,33 +47,116 @@ mod.getAuthURL = (state) => {
mod.getLogoutURL = () => { mod.getLogoutURL = () => {
const input = joi const input = joi
.object({ .object({
logout_endpoint: joi.string().required(), logout_endpoint: joi.string().required()
}) })
.unknown() .unknown()
.validate({ ...config.sso }); .validate({ ...config.sso })
if (input.error) throw new Error(input.error.message); if (input.error) throw new Error(input.error.message)
const redirectUri = new url.URL("/oauth/redirect", config.server.url); const redirectUri = new url.URL('/oauth/redirect', config.server.url)
const qs = { state: "logout", redirect_uri: redirectUri.toString() }; const qs = { state: 'logout', redirect_uri: redirectUri.toString() }
return `${input.value.logout_endpoint}?${querystring.stringify(qs)}`; return `${input.value.logout_endpoint}?${querystring.stringify(qs)}`
}; }
/**
* @param {string} token
* @return {Promise<{username: string, display_name: string, email: string, groups: string[]}>}
*/
mod.getUserInfo = async (token) => { mod.getUserInfo = async (token) => {
const input = joi /**
.object() * @type {{
* client_id: string,
* userinfo_endpoint: string,
* token: string,
* }}
*/
const input = await joi
.object({
client_id: joi.string().required(),
client_secret: joi.string().required(),
userinfo_endpoint: joi.string().required(),
token: joi.string().required()
})
.unknown() .unknown()
.validateAsync({ ...config.sso, token }); .validateAsync({ ...config.sso, token })
};
try {
const resp = await got.default.get(input.userinfo_endpoint, {
responseType: 'json',
headers: { Authorization: `Bearer ${input.token}` }
})
const body = await joi.object({
name: joi.string(),
email: joi.string().email().required(),
groups: joi.array().items(joi.string()).default([]),
family_name: joi.string(),
given_name: joi.string(),
preferred_username: joi.string()
}).unknown().validateAsync(resp.body)
const displayName = body.name || body.preferred_username || body.given_name || ''
return { display_name: displayName, email: body.email, groups: body.groups, username: body.preferred_username || '' }
} catch (err) {
console.log(err)
if (err instanceof got.HTTPError) {
if (err.code === 401) {
// try refresh token
return null
}
}
throw err
}
}
mod.refreshToken = async (token) => {
/**
* @type {{
* token_endpoint: string,
* client_id: string,
* client_secret: string,
* token: string,
* }}
*/
const input = await joi.object({
token_endpoint: joi.string().required(),
client_id: joi.string().required(),
client_secret: joi.string().required(),
token: joi.string().required()
}).unknown().validateAsync({ ...config.sso, token })
const qs = {
client_id: input.client_id,
client_secret: input.client_secret,
grant_type: 'refresh_token',
refresh_token: input.token
}
const resp = await got.default.post(input.token_endpoint, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: querystring.stringify(qs),
responseType: 'json'
})
const body = await joi.object({
access_token: joi.string().required(),
refresh_token: joi.string().required()
}).unknown().validateAsync(resp.body)
return { access_token: body.access_token, refresh_token: body.refresh_token }
}
/** /**
* @typedef SSOAccount * @typedef SSOAccount
* @property {string} access_token * @property {string} access_token
* @property {string} refresh_token * @property {string} refresh_token
* @property {string} user_id
* @property {string} username * @property {string} username
* @property {string} display_name * @property {string} display_name
* @property {string} email * @property {string} email
* @property {string[]} groups
*/ */
mod.getToken = async (code, state) => { mod.getToken = async (code, state) => {
@ -85,19 +167,19 @@ mod.getToken = async (code, state) => {
client_id: joi.string().required(), client_id: joi.string().required(),
client_secret: joi.string().required(), client_secret: joi.string().required(),
state: joi.string().required(), state: joi.string().required(),
code: joi.string().required(), code: joi.string().required()
}) })
.unknown() .unknown()
.validate({ ...config.sso, state, code }); .validate({ ...config.sso, state, code })
if (input.error) throw new Error(input.error.message); if (input.error) throw new Error(input.error.message)
/** /**
* @type {{value: { authorized_endpoint: string, token_endpoint: string, client_id: string, client_secret: string, state: string, code: string }}} * @type {{value: { authorized_endpoint: string, token_endpoint: string, client_id: string, client_secret: string, state: string, code: string }}}
*/ */
const { value } = input; const { value } = input
const redirectUri = new url.URL("/oauth/redirect", config.server.url); const redirectUri = new url.URL('/oauth/redirect', config.server.url)
const qs = { const qs = {
client_id: value.client_id, client_id: value.client_id,
@ -105,57 +187,39 @@ mod.getToken = async (code, state) => {
redirect_uri: redirectUri.toString(), redirect_uri: redirectUri.toString(),
code: value.code, code: value.code,
client_session_state: value.state, client_session_state: value.state,
grant_type: "authorization_code", grant_type: 'authorization_code'
}; }
const resp = await got.default.post(value.token_endpoint, { const resp = await got.default.post(value.token_endpoint, {
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", 'Content-Type': 'application/x-www-form-urlencoded'
}, },
body: querystring.stringify(qs), body: querystring.stringify(qs),
responseType: "json", responseType: 'json'
}); })
const { body } = resp; const { body } = resp
if (!body) throw new Error("resopnse body empty"); if (!body) throw new Error('resopnse body empty')
const { const {
id_token: idToken,
access_token: accessToken, access_token: accessToken,
refresh_token: refreshToken, refresh_token: refreshToken
} = body; } = body
// if (!idToken) throw new Error("get id token fail");
// const decoded = jwt.decode(idToken); const userInfo = await mod.getUserInfo(accessToken)
// if (!decoded || typeof decoded !== "object") if (!userInfo) throw new Error('user info get fail')
// throw new Error("jwt decode fail");
// console.log("decoded ::: ", decoded);
const decoded = jwt.decode(accessToken);
// decode access token // decode access token
console.log("token ::: ", jwt.decode(accessToken)); console.log('user info ::: ', userInfo)
<<<<<<< HEAD console.log('body ::: ', body)
console.log("body ::: ", body);
=======
const decoded = jwt.decode(idToken);
if (!decoded || typeof decoded !== 'object') throw new Error('jwt decode fail');
>>>>>>> c96cdf0ebd17f805235c6fa9eecf2ea79ecca19b
// @ts-ignore
const { preferred_username: preferredUsername } = decoded;
if (!preferredUsername) throw new Error("id token field missing");
const displayName = `${decoded.family_name ?? ""}${decoded.given_name ?? ""}`;
/** @type {SSOAccount} */ /** @type {SSOAccount} */
const ssoAccount = { const ssoAccount = {
access_token: accessToken, access_token: accessToken,
refresh_token: refreshToken, refresh_token: refreshToken,
user_id: decoded.sub, ...userInfo
username: preferredUsername.toLowerCase(), }
display_name: displayName ?? preferredUsername,
email: decoded.email ?? "",
};
return ssoAccount; return ssoAccount
}; }