diff --git a/constants/index.js b/constants/index.js index 2a0c7c0..23285fb 100644 --- a/constants/index.js +++ b/constants/index.js @@ -4,6 +4,7 @@ const constants = { OPENID_EXPIRE: 300, // 5min INTERNAL_REGULATION_CACHE_TTL: 1800, // 30min REPORT_CACHE_TTL: 600, // 10 min + ALLOW_GROUP_ROLE: ['Ironman3'] }; module.exports = constants; diff --git a/controllers/account/v1/index.js b/controllers/account/index.js similarity index 75% rename from controllers/account/v1/index.js rename to controllers/account/index.js index d67ce42..14060eb 100644 --- a/controllers/account/v1/index.js +++ b/controllers/account/index.js @@ -24,3 +24,17 @@ controller.loginSSO = () => async ctx => { ctx.resp(resp.Success, { url: u.toString() }); }; + +controller.logout = () => async ctx => { + let link = ''; + + if (ctx.token.sso) { + link = sso.getLogoutURL(); + } + + ctx.resp(resp.Success, { url: link }); +}; + +controller.getInfo = () => async ctx => { + ctx.resp(resp.Success, {}); +}; \ No newline at end of file diff --git a/controllers/common/index.js b/controllers/common/index.js index d6b2538..88e7561 100644 --- a/controllers/common/index.js +++ b/controllers/common/index.js @@ -3,9 +3,11 @@ const debug = require('debug')('ctrl:common'); const util = require('util'); const joi = require('joi'); const response = require('src/utils/response/index.js'); +const config = require('src/config/index.js'); +const { jwt } = require('src/utils/pkgs.js'); const { copyObject, toNumber } = require('src/utils/index.js'); -const { Success, InternalError, DataFormat } = response.resp; +const { Success, InternalError, DataFormat, Forbidden, Unauthorized } = response.resp; const controller = {}; module.exports = controller; @@ -101,13 +103,66 @@ controller.validate = schema => { }; }; -controller.getAppVersion = () => async (ctx, next) => { - // appVersion Format x.y.z (major.minor.patch) - const appVersion = ctx.get('x-app-version'); - const appBuildNumber = toNumber(ctx.get('x-app-buildnumber'), 0); - const appPlatform = ctx.get('x-app-platform'); +/** + * @param {boolean=} allowExpired + * @return {import('koa').Middleware} + */ + controller.authorization = allowExpired => { + return async (ctx, next) => { + ctx.token = {}; + /** @type {string} */ + const token = ctx.get('authorization'); - Object.assign(ctx.state, { appVersion, appBuildNumber, appPlatform }); + if (!token) ctx.err(Unauthorized); - return next(); + try { + const strs = token.split(/\s/); + debug(`Get Header: ${token}`); + if (strs.length !== 2 || !/^bearer$/i.test(strs[0])) ctx.err(Unauthorized, response.codeMessage.CodeTokenInvalid); + + [, ctx.token.origin] = strs; + + let decoded = {}; + let expired = false; + + try { + decoded = jwt.verify(strs[1], config.server.jwt_secret); + + await joi + .object({ + user_id: joi.string().required(), + }) + .unknown() + .validateAsync(decoded); + } catch (err) { + debug(`jwt token verify fail: ${util.inspect(err, false, null)}`); + if (err instanceof jwt.TokenExpiredError) { + decoded = jwt.decode(ctx.token.origin); + expired = true; + } else { + throw err; + } + } + + ctx.token.user_id = decoded.user_id; + ctx.token.sso = !!decoded.sso; + + if (expired) ctx.err(Forbidden, response.codeMessage.CodeTokenExpired); + + ctx.verified = true; + } catch (err) { + debug(`Token valid fail: ${util.inspect(err, false, null)}`); + if (err instanceof response.APIError) { + // 如果是過期的錯誤,判斷是否允許過期存取 + // @ts-ignore + // eslint-disable-next-line + if (err._object?.object?.code === response.codeMessage.CodeTokenExpired.code) { + if (!!allowExpired) return next(); + } + } + throw err; + } + + return next(); + }; }; diff --git a/controllers/index.js b/controllers/index.js index b6026ba..5dd2b1c 100644 --- a/controllers/index.js +++ b/controllers/index.js @@ -5,24 +5,3 @@ controller.healthCheck = async ctx => { ctx.body = 'ok'; ctx.status = 200; }; - -controller.appleAppSiteAssociation = async ctx => { - ctx.status = 200; - ctx.body = { - applinks: { - details: [ - { - appID: 'CL3K9D5FDN.com.lawsnote.college.staging', - paths: ['*'], - }, - { - appID: 'CL3K9D5FDN.com.lawsnote.college', - paths: ['*'], - }, - ], - }, - webcredentials: { - apps: ['CL3K9D5FDN.com.lawsnote.college.staging', 'CL3K9D5FDN.com.lawsnote.college'], - }, - }; -}; diff --git a/controllers/oauth/index.js b/controllers/oauth/index.js index 5581e92..1ec6505 100644 --- a/controllers/oauth/index.js +++ b/controllers/oauth/index.js @@ -36,7 +36,7 @@ controller.verifyCode = () => async ctx => { // generate jwt token const jwtToken = jwt.sign( { - user_id: `${token}-id`, + user_id: token.user_id, sso: true, }, config.server.jwt_secret, diff --git a/routes/api/index.js b/routes/api/index.js index 6837685..3cbac88 100644 --- a/routes/api/index.js +++ b/routes/api/index.js @@ -1,12 +1,58 @@ const Router = require('@koa/router'); - +const joi = require('joi'); const commonCtrl = require('src/controllers/common/index.js'); -const v1Router = require('./v1/index.js'); +const accCtrl = require('src/controllers/account/index.js'); const r = new Router({ prefix: '/api' }); module.exports = r; // set api handler middleware -r.use(commonCtrl.apiHandler(), commonCtrl.getAppVersion()); +r.use(commonCtrl.apiHandler()); -r.use(v1Router.routes()); +/** + * get account info + * @swagger + * @route GET /api/login + * @group account - account apis + * @param {string} back_url.query.required - back to url + * @returns {RespDefault.model} default - + */ + r.get( + '/login', + commonCtrl.validate({ + query: { + back_url: joi.string().required(), + }, + }), + accCtrl.loginSSO() +); + +/** + * account refresh token + * @swagger + * @route POST /api/refresh + * @group account - account apis + * @security JWT + * @returns {RespDefault.model} default - + */ +r.post('/refresh', commonCtrl.authorization(true), accCtrl.logout()); + +/** + * account logout + * @swagger + * @route POST /api/logout + * @group account - account apis + * @security JWT + * @returns {RespDefault.model} default - + */ +r.post('/logout', commonCtrl.authorization(false), accCtrl.logout()); + +/** + * account get info + * @swagger + * @route GET /api/userinfo + * @group account - account apis + * @security JWT + * @returns {RespDefault.model} default - + */ +r.get('/userinfo', commonCtrl.authorization(false), accCtrl.getInfo()); diff --git a/routes/api/v1/account/index.js b/routes/api/v1/account/index.js deleted file mode 100644 index e78cbe0..0000000 --- a/routes/api/v1/account/index.js +++ /dev/null @@ -1,25 +0,0 @@ -const Router = require('@koa/router'); -const joi = require('joi'); -const commonCtrl = require('src/controllers/common/index.js'); -const accCtrl = require('src/controllers/account/v1/index.js'); - -const r = new Router({ prefix: '/account' }); -module.exports = r; - -/** - * get account info - * @swagger - * @route GET /api/v1/account/login/sso - * @group account - account apis - * @param {string} back_url.query.required - back to url - * @returns {RespDefault.model} default - - */ -r.get( - '/login/sso', - commonCtrl.validate({ - query: { - back_url: joi.string().required(), - }, - }), - accCtrl.loginSSO() -); diff --git a/routes/api/v1/index.js b/routes/api/v1/index.js deleted file mode 100644 index 3e1c918..0000000 --- a/routes/api/v1/index.js +++ /dev/null @@ -1,8 +0,0 @@ -const Router = require('@koa/router'); - -const accountRouter = require('./account/index.js'); - -const r = new Router({ prefix: '/v1' }); -module.exports = r; - -r.use(accountRouter.routes()); diff --git a/routes/index.js b/routes/index.js index c9ddfa3..0a2a7cb 100644 --- a/routes/index.js +++ b/routes/index.js @@ -8,7 +8,6 @@ const r = new Router(); module.exports = r; r.get('/', controller.healthCheck); -r.get(['/apple-app-site-association', '/.well-known/apple-app-site-association'], controller.appleAppSiteAssociation); r.use(apiRouter.routes()); r.use(oauthRouter.routes()); diff --git a/utils/redis.js b/utils/redis.js index 33f9864..e090760 100644 --- a/utils/redis.js +++ b/utils/redis.js @@ -1,11 +1,11 @@ -const IORedis = require('ioredis'); -const config = require('src/config/index.js'); +const IORedis = require("ioredis"); +const config = require("src/config/index.js"); class Redis extends IORedis { constructor() { let { prefix } = config.redis; const { host, port, password, db } = config.redis; - if (prefix && !/:$/.test(prefix)) prefix += ':'; + if (prefix && !/:$/.test(prefix)) prefix += ":"; super({ host, port, @@ -23,7 +23,13 @@ class Redis extends IORedis { * @param {string} s state * @return {string} */ - ssoLoginCache: s => self.getKeyWithPrefix(`sso-login:${s}`), + ssoLoginCache: (s) => self.getKeyWithPrefix(`sso-login:${s}`), + /** + * 儲存 Token + * @param {string} s state + * @return {string} + */ + userToken: (s) => self.getKeyWithPrefix(`token:${s}`), }; } @@ -33,7 +39,7 @@ class Redis extends IORedis { * @return {string} */ getKeyWithPrefix(s) { - if (typeof s !== 'string') throw new Error('input key not a string'); + if (typeof s !== "string") throw new Error("input key not a string"); return `${this.prefix}${s}`; } diff --git a/utils/sso/index.js b/utils/sso/index.js index 47f0f76..f3e2379 100644 --- a/utils/sso/index.js +++ b/utils/sso/index.js @@ -42,6 +42,9 @@ mod.getAuthURL = state => { return `${value.authorized_endpoint}?${querystring.stringify(qs)}`; }; +/** + * @return {string} + */ mod.getLogoutURL = () => { const input = joi .object({ @@ -59,6 +62,9 @@ mod.getLogoutURL = () => { /** * @typedef SSOAccount + * @property {string} access_token + * @property {string} refresh_token + * @property {string} user_id * @property {string} username * @property {string} display_name * @property {string} email @@ -106,13 +112,13 @@ mod.getToken = async (code, state) => { const { body } = resp; if (!body) throw new Error('resopnse body empty'); - const { id_token: idToken, access_token: accessToken } = body; + const { id_token: idToken, access_token: accessToken, refresh_token: refreshToken } = body; if (!idToken) throw new Error('get id token fail'); const decoded = jwt.decode(idToken); if (!decoded || typeof decoded !== 'object') throw new Error('jwt decode fail'); - console.log(decoded) - console.log(body) + console.log('decoded ::: ', decoded) + console.log('body ::: ', body) // @ts-ignore const { preferred_username: preferredUsername } = decoded; if (!preferredUsername) throw new Error('id token field missing'); @@ -121,6 +127,9 @@ mod.getToken = async (code, state) => { /** @type {SSOAccount} */ const ssoAccount = { + access_token: accessToken, + refresh_token: refreshToken, + user_id: decoded.sub, username: preferredUsername.toLowerCase(), display_name: displayName ?? preferredUsername, email: decoded.email ?? '',