[feat] Init code
This commit is contained in:
commit
b1e9c5e62a
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
yarn-error.log
|
||||
.env
|
||||
|
||||
deployment/
|
||||
|
||||
# vim swap file
|
||||
*.swp
|
||||
credential.json
|
||||
coverage/
|
||||
fcm_credential.json
|
4
.eslintignore
Normal file
4
.eslintignore
Normal file
@ -0,0 +1,4 @@
|
||||
public/
|
||||
node_modules/
|
||||
|
||||
*.d.ts
|
9
.eslintrc
Normal file
9
.eslintrc
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2020
|
||||
},
|
||||
"rules": {
|
||||
"no-console": [1, { "allow": ["info", "warn", "error", "table"] }],
|
||||
"no-underscore-dangle": ["error", { "allow": ["_id", "_type", "_index", "_source", "super_", "_score", "_modelName"] }]
|
||||
}
|
||||
}
|
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
yarn-error.log
|
||||
.env
|
||||
|
||||
# vim swap file
|
||||
*.swp
|
||||
credential.json
|
||||
coverage/
|
||||
fcm_credential.json
|
92
.gitlab-ci.yml
Normal file
92
.gitlab-ci.yml
Normal file
@ -0,0 +1,92 @@
|
||||
include:
|
||||
- template: Security/SAST.gitlab-ci.yml
|
||||
|
||||
sast:
|
||||
stage: build
|
||||
|
||||
nodejs-scan-sast:
|
||||
rules:
|
||||
- if: $SAST_DISABLED
|
||||
when: never
|
||||
- if: $CI_COMMIT_BRANCH != "develop"
|
||||
when: never
|
||||
- if: $CI_COMMIT_BRANCH &&
|
||||
$SAST_DEFAULT_ANALYZERS =~ /nodejs-scan/
|
||||
exists:
|
||||
- 'package.json'
|
||||
|
||||
eslint-sast:
|
||||
rules:
|
||||
- if: $SAST_DISABLED
|
||||
when: never
|
||||
- if: $CI_COMMIT_BRANCH != "develop"
|
||||
when: never
|
||||
- if: $CI_COMMIT_BRANCH &&
|
||||
$SAST_DEFAULT_ANALYZERS =~ /eslint/
|
||||
exists:
|
||||
- '**/*.html'
|
||||
- '**/*.js'
|
||||
- '**/*.jsx'
|
||||
- '**/*.ts'
|
||||
- '**/*.tsx'
|
||||
|
||||
|
||||
stages:
|
||||
- build
|
||||
- deploy
|
||||
|
||||
dev-build-job:
|
||||
stage: build
|
||||
only:
|
||||
- develop
|
||||
- merge_requests
|
||||
image: docker:stable
|
||||
variables:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: demo_server
|
||||
services:
|
||||
- name: registry.lawsnote.com/postgres:13-pgroonga
|
||||
alias: postgres
|
||||
- name: redis:5-alpine
|
||||
alias: redis
|
||||
script:
|
||||
# 用 DATE-CI_COMMIT_SHA 當做 docker image 的 tag
|
||||
- DOCKER_IMAGE_TAG=$(date +%Y%m%d%H%M%S)-${CI_COMMIT_SHA:0:8}
|
||||
- DOCKER_BUILDKIT=1 docker build
|
||||
--ssh default="$SSH_PRIVATE_KEY"
|
||||
--build-arg CI_COMMIT_SHA="$CI_COMMIT_SHA"
|
||||
-t registry.lawsnote.com/professorx-dev:$DOCKER_IMAGE_TAG .
|
||||
# 使用 build 好的 image 測試
|
||||
- docker run
|
||||
--rm
|
||||
-e DB_HOST=$POSTGRES_PORT_5432_TCP_ADDR
|
||||
-e DB_USER=postgres
|
||||
-e DB_PASSWORD=password
|
||||
-e DB_NAME=demo_server
|
||||
-e REDIS_HOST=$REDIS_PORT_6379_TCP_ADDR
|
||||
-e SMS_VENDER=empty
|
||||
-e NODE_ENV=test
|
||||
registry.lawsnote.com/professorx-dev:$DOCKER_IMAGE_TAG yarn test-with-db
|
||||
# push docker image
|
||||
- docker push registry.lawsnote.com/professorx-dev:$DOCKER_IMAGE_TAG
|
||||
# tag latest
|
||||
- docker tag registry.lawsnote.com/professorx-dev:$DOCKER_IMAGE_TAG registry.lawsnote.com/professorx-dev:latest
|
||||
- docker push registry.lawsnote.com/professorx-dev:latest
|
||||
# delete local image
|
||||
- docker rmi registry.lawsnote.com/professorx-dev:latest
|
||||
# 執行 Galactus 來刪除不必要的 image,只保留 10 個舊版
|
||||
- docker run --rm --env TARGET_IMAGE=professorx-dev --env KEEP_COUNT=10 --env FORCE=1 registry.lawsnote.com/galactus:latest
|
||||
tags:
|
||||
- docker
|
||||
|
||||
dev-deploy-job:
|
||||
stage: deploy
|
||||
only:
|
||||
- develop
|
||||
script:
|
||||
- docker pull registry.lawsnote.com/professorx-dev:latest
|
||||
- if [ "$(docker inspect -f '{{.State.Running}}' professorx-dev 2> /dev/null)" == "true" ]; then docker rm -f -v professorx-dev; fi
|
||||
- docker run --detach --restart always --log-driver=json-file --log-opt max-size=16m --log-opt max-file=2 --publish 30041:10230 --name professorx-dev --env-file "$DEV_SERVICE_ENV" registry.lawsnote.com/professorx-dev:latest
|
||||
tags:
|
||||
- office
|
22
Dockerfile
Normal file
22
Dockerfile
Normal file
@ -0,0 +1,22 @@
|
||||
# syntax=docker/dockerfile:experimental
|
||||
FROM node:14.14.0 as deps
|
||||
USER root
|
||||
WORKDIR /data
|
||||
COPY package.json yarn.lock ./
|
||||
#RUN apk add --no-cache git openssh-client
|
||||
RUN mkdir -p /root/.ssh
|
||||
RUN echo "Host git.lawsnote.com\n\tStrictHostKeyChecking no\n\tUser git\n\tHostname git.lawsnote.com\n\tPort 2222\n\tUserKnownHostsFile /dev/null" > /root/.ssh/config
|
||||
# SSH key is set in docker build argument, ex: docker build --ssh default="$SSH_PRIVATE_KEY"
|
||||
RUN --mount=type=ssh yarn install --frozen-lockfile
|
||||
|
||||
FROM node:14.14.0-slim
|
||||
WORKDIR /data
|
||||
ARG CI_COMMIT_SHA
|
||||
COPY . .
|
||||
COPY --from=deps /data/node_modules ./node_modules/
|
||||
#RUN apk add --no-cache ffmpeg \
|
||||
# && chown -R node:node .
|
||||
RUN chown -R node:node . \
|
||||
&& sed -i "s/__CI_COMMIT_SHA__/$CI_COMMIT_SHA/" ./public/html/version.html
|
||||
USER node
|
||||
CMD ["yarn", "start"]
|
53
README.md
Normal file
53
README.md
Normal file
@ -0,0 +1,53 @@
|
||||
# Demo Server
|
||||
|
||||
KeyCloak 測試 Server
|
||||
|
||||
## 設定環境變數
|
||||
|
||||
```
|
||||
# 伺服器完整網址
|
||||
SERVER_URL
|
||||
|
||||
# 伺服器監聽埠
|
||||
SERVER_PORT
|
||||
|
||||
# JWT 簽名 Secret
|
||||
SERVER_JWT_SECRET
|
||||
|
||||
# Redis 位址
|
||||
REDIS_HOST
|
||||
|
||||
# Redis Port
|
||||
REDIS_PORT
|
||||
|
||||
# Redis 密碼,沒有則留空
|
||||
REDIS_PASSWORD
|
||||
|
||||
# Redis 鍵值前綴
|
||||
REDIS_PREFIX
|
||||
|
||||
# Redis 連線的資料庫號碼
|
||||
REDIS_DB
|
||||
|
||||
# PostgreSQL 資料庫位址
|
||||
DB_HOST
|
||||
|
||||
# PostgreSQL 資料庫連接埠
|
||||
DB_PORT
|
||||
|
||||
# PostgreSQL 資料庫使用者
|
||||
DB_USER
|
||||
|
||||
# PostgreSQL 資料庫密碼
|
||||
DB_PASSWORD
|
||||
|
||||
# PostgreSQL 資料庫名稱
|
||||
DB_NAME
|
||||
|
||||
# PostgreSQL 資料庫連接池最大連線數
|
||||
DB_POOL_MAX
|
||||
|
||||
# PostgreSQL 資料庫連接池閒置連線數
|
||||
DB_POOL_MIN
|
||||
|
||||
```
|
114
bin/db-migrate.js
Normal file
114
bin/db-migrate.js
Normal file
@ -0,0 +1,114 @@
|
||||
const pg = require('pg');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const config = require('src/config/index.js');
|
||||
|
||||
// schema file name format ######_name.sql
|
||||
const schemaDir = path.resolve(__dirname, '..', 'schemas');
|
||||
|
||||
const db = new pg.Client({
|
||||
host: config.database.host,
|
||||
port: config.database.port,
|
||||
user: config.database.user,
|
||||
password: config.database.password,
|
||||
database: config.database.dbname,
|
||||
});
|
||||
|
||||
(async () => {
|
||||
await db.connect();
|
||||
|
||||
await db.query(`select now();`);
|
||||
|
||||
let version = -1;
|
||||
|
||||
// check migrate record table exists
|
||||
const checkTable = await db.query(
|
||||
`
|
||||
select exists(
|
||||
select 1
|
||||
from "information_schema"."tables"
|
||||
where
|
||||
"table_schema" = $1
|
||||
and "table_name" = $2
|
||||
) as exists
|
||||
`,
|
||||
['public', 'migrate_log']
|
||||
);
|
||||
|
||||
if (checkTable.rowCount > 0 && checkTable.rows[0].exists === true) {
|
||||
// version table exists
|
||||
const maxVersion = await db.query(`select max("version")::integer as version from "public"."migrate_log"`);
|
||||
if (maxVersion.rowCount > 0 && maxVersion.rows[0] && maxVersion.rows[0].version !== null) version = maxVersion.rows[0].version; // eslint-disable-line
|
||||
} else {
|
||||
// create version table
|
||||
await db.query(`create table "public"."migrate_log" (
|
||||
"version" integer not null primary key,
|
||||
"created_time" timestamptz not null default now()
|
||||
);`);
|
||||
}
|
||||
|
||||
console.info(`Database Now Version: ${version}`);
|
||||
|
||||
// read all schema files
|
||||
const schemaList = await fs.promises.readdir(schemaDir);
|
||||
|
||||
/**
|
||||
* @type {{[x: number]: boolean}}
|
||||
*/
|
||||
const checkDuplicate = {};
|
||||
|
||||
/**
|
||||
* @type {{version: number, filename: string}[]}
|
||||
*/
|
||||
const versionList = schemaList
|
||||
.map(file => {
|
||||
const strs = file.split('_');
|
||||
const v = parseInt(strs[0], 10);
|
||||
if (isNaN(version)) throw new Error(`schema filename format error (######_name.sql)`); // eslint-disable-line
|
||||
|
||||
if (v in checkDuplicate) throw new Error(`schema file version (${v}) is duplicate`);
|
||||
|
||||
checkDuplicate[v] = true;
|
||||
|
||||
return { version: v, filename: file };
|
||||
})
|
||||
.filter(t => t && t.version > version)
|
||||
.sort((a, b) => a.version - b.version);
|
||||
|
||||
// 沒有需要更新的檔案
|
||||
if (versionList.length === 0) return;
|
||||
|
||||
await db.query('begin');
|
||||
|
||||
try {
|
||||
const vers = [];
|
||||
// write all schema file
|
||||
for (const it of versionList) {
|
||||
vers.push(`(${it.version})`);
|
||||
|
||||
console.info(`Write Version: ${it.version}`);
|
||||
|
||||
const fileContent = await fs.promises.readFile(path.resolve(schemaDir, it.filename), 'utf-8');
|
||||
|
||||
await db.query(fileContent);
|
||||
}
|
||||
|
||||
await db.query(`insert into "public"."migrate_log" ("version") values ${vers.join(',')}`);
|
||||
|
||||
await db.query('commit');
|
||||
} catch (err) {
|
||||
await db.query('rollback');
|
||||
throw err;
|
||||
}
|
||||
})()
|
||||
.then(() => {
|
||||
console.info('Database Migrate Finish');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Database Migrate Failed, ', err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => {
|
||||
db.end();
|
||||
});
|
58
bin/migrate-tool.js
Normal file
58
bin/migrate-tool.js
Normal file
@ -0,0 +1,58 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const readline = require('readline');
|
||||
const { padLeft } = require('src/utils/index.js');
|
||||
|
||||
const schemaDir = path.resolve(__dirname, '..', 'schemas');
|
||||
|
||||
(async () => {
|
||||
const args = process.argv.slice(2);
|
||||
let filename = args[0] || '';
|
||||
if (args.length === 0) {
|
||||
// use readline
|
||||
filename = await new Promise(resolve => {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
rl.prompt();
|
||||
rl.question('schema filename: ', ans => {
|
||||
resolve(ans.replace(' ', '_'));
|
||||
|
||||
rl.close();
|
||||
});
|
||||
|
||||
rl.once('close', resolve);
|
||||
});
|
||||
}
|
||||
|
||||
if (filename === '') throw new Error('no schema filename');
|
||||
|
||||
const schemaFiles = await fs.promises.readdir(schemaDir);
|
||||
let version = 0;
|
||||
|
||||
schemaFiles.forEach(name => {
|
||||
if (!name.endsWith('.sql')) return;
|
||||
|
||||
const strInt = name.split(/_/g)[0];
|
||||
const v = parseInt(strInt, 10);
|
||||
if (isNaN(v)) return; // eslint-disable-line
|
||||
|
||||
if (v > version) version = v;
|
||||
});
|
||||
|
||||
// 版本要比最後一筆加一
|
||||
version += 1;
|
||||
|
||||
const schemaName = `${padLeft(`${version}`, 6, '0')}_${filename}.sql`;
|
||||
|
||||
const schemaText = `-- Created Time ${new Date().toISOString()}`;
|
||||
|
||||
await fs.promises.writeFile(path.resolve(schemaDir, schemaName), schemaText, 'utf-8');
|
||||
|
||||
console.info(`File: ${path.resolve(schemaDir, schemaName)} Created!`);
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
38
config/index.js
Normal file
38
config/index.js
Normal file
@ -0,0 +1,38 @@
|
||||
const { env } = process;
|
||||
|
||||
module.exports = {
|
||||
server: {
|
||||
url: env.SERVER_URL || 'http://localhost:10230',
|
||||
cms_api_url: env.SERVER_CMS_API_URL || 'http://localhost:10230',
|
||||
port: parseInt(env.SERVER_PORT, 10) || 10230,
|
||||
jwt_secret: env.SERVER_JWT_SECRET || 'testsecret',
|
||||
jwt_expire: parseInt(env.SERVER_JWT_EXPIRE, 10) || 60 * 60 * 24 * 30, // 30 day
|
||||
tos_url: env.SERVER_TOS_URL || 'http://localhost:10230',
|
||||
course_contract_url: env.SERVER_COURSE_CONTRACT_URL || 'http://localhost:10230',
|
||||
cms_limit_enabled: env.SERVER_CMS_LIMIT_ENABLED !== '0', // 啟用CMS routing 限制
|
||||
cms_limit_token: env.SERVER_CMS_LIMIT_TOKEN || '',
|
||||
},
|
||||
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: {
|
||||
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 || '',
|
||||
},
|
||||
database: {
|
||||
host: env.DB_HOST || 'localhost',
|
||||
port: parseInt(env.DB_PORT, 10) || 5432,
|
||||
user: env.DB_USER || 'postgres',
|
||||
password: env.DB_PASSWORD || '',
|
||||
dbname: env.DB_NAME || 'professor_x',
|
||||
pool_max: parseInt(env.DB_POOL_MAX, 10) || 5,
|
||||
pool_min: parseInt(env.DB_POOL_MIN, 10) || 2,
|
||||
},
|
||||
};
|
9
constants/index.js
Normal file
9
constants/index.js
Normal file
@ -0,0 +1,9 @@
|
||||
/* eslint-disable no-bitwise */
|
||||
const constants = {
|
||||
PAGE_SIZE: 20,
|
||||
OPENID_EXPIRE: 300, // 5min
|
||||
INTERNAL_REGULATION_CACHE_TTL: 1800, // 30min
|
||||
REPORT_CACHE_TTL: 600, // 10 min
|
||||
};
|
||||
|
||||
module.exports = constants;
|
26
controllers/account/v1/index.js
Normal file
26
controllers/account/v1/index.js
Normal file
@ -0,0 +1,26 @@
|
||||
const { resp } = require('src/utils/response/index.js');
|
||||
const redis = require('src/utils/redis.js');
|
||||
const sso = require('src/utils/sso/index.js');
|
||||
const { OPENID_EXPIRE } = require('src/constants/index.js');
|
||||
const uuid = require('uuid');
|
||||
const url = require('url');
|
||||
|
||||
const controller = {};
|
||||
module.exports = controller;
|
||||
|
||||
controller.loginSSO = () => async ctx => {
|
||||
const { back_url: backURL } = ctx.query;
|
||||
|
||||
const state = uuid.v4();
|
||||
|
||||
const authURL = sso.getAuthURL(state);
|
||||
|
||||
// store back url to cache
|
||||
const cacheKey = redis.Key.ssoLoginCache(state);
|
||||
|
||||
await redis.set(cacheKey, JSON.stringify({ back_url: backURL }), 'EX', OPENID_EXPIRE);
|
||||
|
||||
const u = new url.URL(authURL);
|
||||
|
||||
ctx.resp(resp.Success, { url: u.toString() });
|
||||
};
|
113
controllers/common/index.js
Normal file
113
controllers/common/index.js
Normal file
@ -0,0 +1,113 @@
|
||||
/* eslint-disable no-bitwise */
|
||||
const debug = require('debug')('ctrl:common');
|
||||
const util = require('util');
|
||||
const joi = require('joi');
|
||||
const response = require('src/utils/response/index.js');
|
||||
const { copyObject, toNumber } = require('src/utils/index.js');
|
||||
|
||||
const { Success, InternalError, DataFormat } = response.resp;
|
||||
|
||||
const controller = {};
|
||||
module.exports = controller;
|
||||
|
||||
/**
|
||||
* api reponse function
|
||||
* @param {import('src/utils/response/index.js').respObject} resp
|
||||
* @param {string|Object} body
|
||||
*/
|
||||
function responseFunc(resp, body) {
|
||||
/** @type {import('src/utils/response/index.js').respObject} */
|
||||
const copy = copyObject(response.checkStruct(resp) ? resp : Success);
|
||||
|
||||
if (typeof body === 'string') copy.object.message = body;
|
||||
else if (typeof body === 'object') copy.object = body;
|
||||
|
||||
// @ts-ignore
|
||||
if (!('obj' in this)) this.obj = {};
|
||||
this.obj.status = copy.status;
|
||||
this.obj.object = copy.object;
|
||||
}
|
||||
|
||||
/**
|
||||
* api error response function
|
||||
* @param {import('src/utils/response/index.js').respObject} resp
|
||||
* @param {import('src/utils/response/index.js').codeMessage=} code
|
||||
*/
|
||||
function responseError(resp, code) {
|
||||
/** @type {import('src/utils/response/index.js').respObject} */
|
||||
const copy = copyObject(response.checkStruct(resp) ? resp : InternalError);
|
||||
|
||||
if (code && typeof code === 'object' && 'message' in code && 'code' in code) {
|
||||
copy.object = code;
|
||||
}
|
||||
|
||||
const err = new response.APIError(copy.object.message, copy);
|
||||
throw err;
|
||||
}
|
||||
|
||||
controller.apiHandler = () => async (ctx, next) => {
|
||||
ctx.obj = {};
|
||||
ctx.token = {};
|
||||
|
||||
ctx.resp = responseFunc.bind(ctx);
|
||||
ctx.err = responseError;
|
||||
|
||||
ctx.getBody = key => (ctx.request.body || {})[key];
|
||||
ctx.getFile = key => (ctx.request.files || {})[key];
|
||||
|
||||
// run next
|
||||
try {
|
||||
await next();
|
||||
} catch (err) {
|
||||
debug(`Get API Throw Error: ${util.inspect(err, false, null)}`);
|
||||
// debug(err.stack);
|
||||
if (!(err instanceof response.APIError)) {
|
||||
ctx.resp(InternalError);
|
||||
} else {
|
||||
ctx.obj = err.object;
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
ctx.obj.object.errorStack = err.stack;
|
||||
ctx.obj.object.errorMessage = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(ctx.obj).length > 0) {
|
||||
ctx.status = ctx.obj.status;
|
||||
ctx.body = ctx.obj.object;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* data validate middleware
|
||||
* @param {{query?: any, header?: any, body?: any}} schema body,query and header is joi.Schema
|
||||
*/
|
||||
controller.validate = schema => {
|
||||
if (typeof schema !== 'object') responseError(InternalError);
|
||||
const v = {};
|
||||
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 ('query' in schema) v.query = joi.isSchema(schema.query) ? schema.query : joi.object(schema.query).unknown();
|
||||
|
||||
return async (ctx, next) => {
|
||||
try {
|
||||
await joi.object(v).unknown().validateAsync({ query: ctx.query, header: ctx.headers, body: ctx.request.body });
|
||||
} catch (err) {
|
||||
debug(`data validate error: ${util.inspect(err, false, null)}`);
|
||||
responseError(DataFormat);
|
||||
}
|
||||
return next();
|
||||
};
|
||||
};
|
||||
|
||||
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');
|
||||
|
||||
Object.assign(ctx.state, { appVersion, appBuildNumber, appPlatform });
|
||||
|
||||
return next();
|
||||
};
|
28
controllers/index.js
Normal file
28
controllers/index.js
Normal file
@ -0,0 +1,28 @@
|
||||
const controller = {};
|
||||
module.exports = controller;
|
||||
|
||||
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'],
|
||||
},
|
||||
};
|
||||
};
|
73
controllers/oauth/index.js
Normal file
73
controllers/oauth/index.js
Normal file
@ -0,0 +1,73 @@
|
||||
const debug = require('debug')('ctrl:common');
|
||||
const util = require('util');
|
||||
const url = require('url');
|
||||
const sso = require('src/utils/sso/index.js');
|
||||
const redis = require('src/utils/redis.js');
|
||||
const { codeMessage, APIError } = require('src/utils/response/index.js');
|
||||
const config = require('src/config/index.js');
|
||||
const { jwt } = require('src/utils/pkgs.js');
|
||||
|
||||
const controller = {};
|
||||
module.exports = controller;
|
||||
|
||||
controller.verifyCode = () => async ctx => {
|
||||
const { code, session_state: sessionState, state } = ctx.query;
|
||||
|
||||
// logout flow redirect tot frontend
|
||||
if (state === 'logout') {
|
||||
ctx.redirect(config.server.frontend_url);
|
||||
return;
|
||||
}
|
||||
|
||||
// get back url from redis
|
||||
const cacheKey = redis.Key.ssoLoginCache(state);
|
||||
|
||||
const data = await redis.get(cacheKey);
|
||||
if (!data) ctx.throw('get login cache fail');
|
||||
const stateObj = JSON.parse(data);
|
||||
const { back_url: backURL } = stateObj;
|
||||
if (!backURL) ctx.throw('cache data missing');
|
||||
|
||||
const u = new url.URL(backURL);
|
||||
|
||||
try {
|
||||
const token = await sso.getToken(code, sessionState);
|
||||
|
||||
// generate jwt token
|
||||
const jwtToken = jwt.sign(
|
||||
{
|
||||
user_id: `${token}-id`,
|
||||
sso: true,
|
||||
},
|
||||
config.server.jwt_secret,
|
||||
{
|
||||
expiresIn: config.server.jwt_expire,
|
||||
issuer: 'lawsnote',
|
||||
}
|
||||
);
|
||||
|
||||
u.searchParams.append('success', Buffer.from(JSON.stringify({ token: jwtToken })).toString('base64'));
|
||||
|
||||
try {
|
||||
await redis.del(cacheKey);
|
||||
} catch (err) {
|
||||
debug(`delete cache fail: ${util.inspect(err, false, null)}`);
|
||||
}
|
||||
} catch (err) {
|
||||
debug(`openid verify fail: ${util.inspect(err, false, null)}`);
|
||||
|
||||
/** @type {object} */
|
||||
const errObj = { ...codeMessage.CodeInternalError };
|
||||
|
||||
if (err instanceof APIError) {
|
||||
// @ts-ignore
|
||||
Object.assign(errObj, err.object.object);
|
||||
}
|
||||
|
||||
errObj.errorStack = err.stack;
|
||||
errObj.errorMessage = err.message;
|
||||
u.searchParams.append('error', Buffer.from(JSON.stringify(errObj)).toString('base64'));
|
||||
}
|
||||
|
||||
ctx.redirect(u.toString());
|
||||
};
|
3
doc/testScript.txt
Normal file
3
doc/testScript.txt
Normal file
@ -0,0 +1,3 @@
|
||||
curl -X GET -H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-H "Authorization: Bearer " \
|
||||
https://auth.mtfos.xyz/auth/realms/Vision/protocol/openid-connect/userinfo
|
10
example.env
Normal file
10
example.env
Normal file
@ -0,0 +1,10 @@
|
||||
DB_PASSWORD=password
|
||||
DB_NAME=professorx
|
||||
DEBUG=ctrl*,knex*
|
||||
DB_MIGRATE=1
|
||||
SERVER_CMS_LIMIT_ENABLED=0
|
||||
SSO_AUTHORIZED_ENDPOINT=https://auth.mtfos.xyz/auth/realms/Vision/protocol/openid-connect/auth
|
||||
SSO_TOKEN_ENDPOINT=https://auth.mtfos.xyz/auth/realms/Vision/protocol/openid-connect/token
|
||||
SSO_LOGOUT_ENDPOINT=https://auth.mtfos.xyz/auth/realms/Vision/protocol/openid-connect/logout
|
||||
SSO_CLIENT_ID=vision
|
||||
SSO_CLIENT_SECRET=8f6c1fa0-41a0-4218-a02d-3fb28850eb73
|
13
index.js
Normal file
13
index.js
Normal file
@ -0,0 +1,13 @@
|
||||
require('dotenv').config();
|
||||
|
||||
const config = require('src/config/index.js');
|
||||
const app = require('./server.js');
|
||||
|
||||
async function runServer() {
|
||||
const server = app.listen(config.server.port, () => {
|
||||
// @ts-ignore
|
||||
console.info(`server start on port ${server.address().port}`);
|
||||
});
|
||||
}
|
||||
|
||||
runServer();
|
10
jsconfig.json
Normal file
10
jsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2019",
|
||||
"lib": ["es2020.string"],
|
||||
"checkJs": true,
|
||||
"module": "commonjs",
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"exclude": ["node_modules"]
|
||||
}
|
25
model/base.js
Normal file
25
model/base.js
Normal file
@ -0,0 +1,25 @@
|
||||
const db = require('src/utils/database.js');
|
||||
|
||||
class Base {
|
||||
constructor() {
|
||||
this.cols = [];
|
||||
this.schema = 'public';
|
||||
this.table = '';
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
async transaction(trxFunc) {
|
||||
if (typeof trxFunc !== 'function') throw new Error('transaction function type error');
|
||||
return this.db.transaction(trxFunc);
|
||||
}
|
||||
|
||||
async checkSchema() {
|
||||
await this.db
|
||||
.withSchema(this.schema)
|
||||
.from(this.table)
|
||||
.select(...this.cols)
|
||||
.limit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Base;
|
14
model/common.js
Normal file
14
model/common.js
Normal file
@ -0,0 +1,14 @@
|
||||
/* eslint-disable func-names */
|
||||
const Base = require('./base.js');
|
||||
|
||||
class Common extends Base {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
async test() {
|
||||
// nothing
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Common;
|
25
model/public/account.js
Normal file
25
model/public/account.js
Normal file
@ -0,0 +1,25 @@
|
||||
// const debug = require('debug')('models:account');
|
||||
const Base = require('src/model/base.js');
|
||||
|
||||
/**
|
||||
* @typedef AccountModel
|
||||
* @property {string} id
|
||||
* @property {string} phone
|
||||
* @property {string} password with bcrypt
|
||||
* @property {string} display_name
|
||||
* @property {string} secret
|
||||
* @property {string} created_time
|
||||
* @property {string} updated_time
|
||||
*/
|
||||
|
||||
class Acconut extends Base {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
async test() {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Acconut;
|
16103
package-lock.json
generated
Normal file
16103
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
57
package.json
Normal file
57
package.json
Normal file
@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "demo-server",
|
||||
"version": "1.0.0",
|
||||
"description": "lawsnote oauth demo server",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"migrate": "node bin/db-migrate.js",
|
||||
"test": "mocha --timeout 5000 --exit test/ && jest --passWithNoTests --runInBand --coverage .",
|
||||
"test-with-db": "npm run migrate && npm run test",
|
||||
"new-schema": "node bin/migrate-tool.js",
|
||||
"postinstall": "node -e \"var s='../',d='node_modules/src',fs=require('fs');fs.exists(d,function(e){e||fs.symlinkSync(s,d,'dir')});\""
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Jay <admin@trj.tw>",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@google-cloud/storage": "5.4.0",
|
||||
"@koa/cors": "^3.0.0",
|
||||
"@koa/router": "^8.0.5",
|
||||
"@mtfos/swagger-generator": "git+https://github.com/otakukaze/swagger-generator.git#1.2.2",
|
||||
"axios": "0.21.0",
|
||||
"debug": "4.2.0",
|
||||
"dotenv": "^8.2.0",
|
||||
"got": "^11.8.2",
|
||||
"ioredis": "4.19.0",
|
||||
"joi": "17.3.0",
|
||||
"jsonwebtoken": "8.5.1",
|
||||
"knex": "0.21.15",
|
||||
"koa": "^2.11.0",
|
||||
"koa-body": "^4.1.1",
|
||||
"koa-logger": "^3.2.1",
|
||||
"koa-mount": "4.0.0",
|
||||
"koa-range": "0.3.0",
|
||||
"koa-static": "5.0.0",
|
||||
"pg": "8.4.1",
|
||||
"uuid": "8.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"chai": "4.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",
|
||||
"mocha": "^8.2.0",
|
||||
"prettier": "^2.0.5",
|
||||
"supertest": "^5.0.0"
|
||||
},
|
||||
"nodemonConfig": {
|
||||
"ignore": [
|
||||
"credential.json",
|
||||
"fcm_credential.json"
|
||||
]
|
||||
}
|
||||
}
|
10
public/html/liveness.html
Normal file
10
public/html/liveness.html
Normal file
@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title></title>
|
||||
</head>
|
||||
<body>
|
||||
live
|
||||
</body>
|
||||
</html>
|
10
public/html/readiness.html
Normal file
10
public/html/readiness.html
Normal file
@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title></title>
|
||||
</head>
|
||||
<body>
|
||||
readiness
|
||||
</body>
|
||||
</html>
|
BIN
public/swagger-ui/favicon-16x16.png
Normal file
BIN
public/swagger-ui/favicon-16x16.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 665 B |
BIN
public/swagger-ui/favicon-32x32.png
Normal file
BIN
public/swagger-ui/favicon-32x32.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 628 B |
62
public/swagger-ui/index.html
Normal file
62
public/swagger-ui/index.html
Normal file
@ -0,0 +1,62 @@
|
||||
<!-- HTML for static distribution bundle build -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Swagger UI</title>
|
||||
<link rel="stylesheet" type="text/css" href="./swagger-ui.css" >
|
||||
<link rel="icon" type="image/png" href="./favicon-32x32.png" sizes="32x32" />
|
||||
<link rel="icon" type="image/png" href="./favicon-16x16.png" sizes="16x16" />
|
||||
<style>
|
||||
html
|
||||
{
|
||||
box-sizing: border-box;
|
||||
overflow: -moz-scrollbars-vertical;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
*,
|
||||
*:before,
|
||||
*:after
|
||||
{
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
body
|
||||
{
|
||||
margin:0;
|
||||
background: #fafafa;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
|
||||
<script src="./swagger-ui-bundle.js" charset="UTF-8"> </script>
|
||||
<script src="./swagger-ui-standalone-preset.js" charset="UTF-8"> </script>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
// Begin Swagger UI call region
|
||||
const ui = SwaggerUIBundle({
|
||||
url: "/api-docs.json",
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
docExpansion: 'none',
|
||||
validatorUrl: null,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
plugins: [
|
||||
SwaggerUIBundle.plugins.DownloadUrl
|
||||
],
|
||||
layout: "StandaloneLayout"
|
||||
})
|
||||
// End Swagger UI call region
|
||||
|
||||
window.ui = ui
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
74
public/swagger-ui/oauth2-redirect.html
Normal file
74
public/swagger-ui/oauth2-redirect.html
Normal file
@ -0,0 +1,74 @@
|
||||
<!doctype html>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<title>Swagger UI: OAuth2 Redirect</title>
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>
|
||||
<script>
|
||||
'use strict';
|
||||
function run () {
|
||||
var oauth2 = window.opener.swaggerUIRedirectOauth2;
|
||||
var sentState = oauth2.state;
|
||||
var redirectUrl = oauth2.redirectUrl;
|
||||
var isValid, qp, arr;
|
||||
|
||||
if (/code|token|error/.test(window.location.hash)) {
|
||||
qp = window.location.hash.substring(1);
|
||||
} else {
|
||||
qp = location.search.substring(1);
|
||||
}
|
||||
|
||||
arr = qp.split("&")
|
||||
arr.forEach(function (v,i,_arr) { _arr[i] = '"' + v.replace('=', '":"') + '"';})
|
||||
qp = qp ? JSON.parse('{' + arr.join() + '}',
|
||||
function (key, value) {
|
||||
return key === "" ? value : decodeURIComponent(value)
|
||||
}
|
||||
) : {}
|
||||
|
||||
isValid = qp.state === sentState
|
||||
|
||||
if ((
|
||||
oauth2.auth.schema.get("flow") === "accessCode"||
|
||||
oauth2.auth.schema.get("flow") === "authorizationCode"
|
||||
) && !oauth2.auth.code) {
|
||||
if (!isValid) {
|
||||
oauth2.errCb({
|
||||
authId: oauth2.auth.name,
|
||||
source: "auth",
|
||||
level: "warning",
|
||||
message: "Authorization may be unsafe, passed state was changed in server Passed state wasn't returned from auth server"
|
||||
});
|
||||
}
|
||||
|
||||
if (qp.code) {
|
||||
delete oauth2.state;
|
||||
oauth2.auth.code = qp.code;
|
||||
oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl});
|
||||
} else {
|
||||
let oauthErrorMsg
|
||||
if (qp.error) {
|
||||
oauthErrorMsg = "["+qp.error+"]: " +
|
||||
(qp.error_description ? qp.error_description+ ". " : "no accessCode received from the server. ") +
|
||||
(qp.error_uri ? "More info: "+qp.error_uri : "");
|
||||
}
|
||||
|
||||
oauth2.errCb({
|
||||
authId: oauth2.auth.name,
|
||||
source: "auth",
|
||||
level: "error",
|
||||
message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server"
|
||||
});
|
||||
}
|
||||
} else {
|
||||
oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl});
|
||||
}
|
||||
window.close();
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', function () {
|
||||
run();
|
||||
});
|
||||
</script>
|
3
public/swagger-ui/swagger-ui-bundle.js
Normal file
3
public/swagger-ui/swagger-ui-bundle.js
Normal file
File diff suppressed because one or more lines are too long
1
public/swagger-ui/swagger-ui-bundle.js.map
Normal file
1
public/swagger-ui/swagger-ui-bundle.js.map
Normal file
File diff suppressed because one or more lines are too long
3
public/swagger-ui/swagger-ui-es-bundle-core.js
Normal file
3
public/swagger-ui/swagger-ui-es-bundle-core.js
Normal file
File diff suppressed because one or more lines are too long
1
public/swagger-ui/swagger-ui-es-bundle-core.js.map
Normal file
1
public/swagger-ui/swagger-ui-es-bundle-core.js.map
Normal file
File diff suppressed because one or more lines are too long
3
public/swagger-ui/swagger-ui-es-bundle.js
Normal file
3
public/swagger-ui/swagger-ui-es-bundle.js
Normal file
File diff suppressed because one or more lines are too long
1
public/swagger-ui/swagger-ui-es-bundle.js.map
Normal file
1
public/swagger-ui/swagger-ui-es-bundle.js.map
Normal file
File diff suppressed because one or more lines are too long
3
public/swagger-ui/swagger-ui-standalone-preset.js
Normal file
3
public/swagger-ui/swagger-ui-standalone-preset.js
Normal file
File diff suppressed because one or more lines are too long
1
public/swagger-ui/swagger-ui-standalone-preset.js.map
Normal file
1
public/swagger-ui/swagger-ui-standalone-preset.js.map
Normal file
File diff suppressed because one or more lines are too long
4
public/swagger-ui/swagger-ui.css
Normal file
4
public/swagger-ui/swagger-ui.css
Normal file
File diff suppressed because one or more lines are too long
1
public/swagger-ui/swagger-ui.css.map
Normal file
1
public/swagger-ui/swagger-ui.css.map
Normal file
File diff suppressed because one or more lines are too long
3
public/swagger-ui/swagger-ui.js
Normal file
3
public/swagger-ui/swagger-ui.js
Normal file
File diff suppressed because one or more lines are too long
1
public/swagger-ui/swagger-ui.js.map
Normal file
1
public/swagger-ui/swagger-ui.js.map
Normal file
File diff suppressed because one or more lines are too long
174
public/url.json
Normal file
174
public/url.json
Normal file
@ -0,0 +1,174 @@
|
||||
{
|
||||
"issuer": "https://auth.trj.tw/auth/realms/school",
|
||||
"authorization_endpoint": "https://auth.trj.tw/auth/realms/school/protocol/openid-connect/auth",
|
||||
"token_endpoint": "https://auth.trj.tw/auth/realms/school/protocol/openid-connect/token",
|
||||
"introspection_endpoint": "https://auth.trj.tw/auth/realms/school/protocol/openid-connect/token/introspect",
|
||||
"userinfo_endpoint": "https://auth.trj.tw/auth/realms/school/protocol/openid-connect/userinfo",
|
||||
"end_session_endpoint": "https://auth.trj.tw/auth/realms/school/protocol/openid-connect/logout",
|
||||
"jwks_uri": "https://auth.trj.tw/auth/realms/school/protocol/openid-connect/certs",
|
||||
"check_session_iframe": "https://auth.trj.tw/auth/realms/school/protocol/openid-connect/login-status-iframe.html",
|
||||
"grant_types_supported": [
|
||||
"authorization_code",
|
||||
"implicit",
|
||||
"refresh_token",
|
||||
"password",
|
||||
"client_credentials"
|
||||
],
|
||||
"response_types_supported": [
|
||||
"code",
|
||||
"none",
|
||||
"id_token",
|
||||
"token",
|
||||
"id_token token",
|
||||
"code id_token",
|
||||
"code token",
|
||||
"code id_token token"
|
||||
],
|
||||
"subject_types_supported": [
|
||||
"public",
|
||||
"pairwise"
|
||||
],
|
||||
"id_token_signing_alg_values_supported": [
|
||||
"PS384",
|
||||
"ES384",
|
||||
"RS384",
|
||||
"HS256",
|
||||
"HS512",
|
||||
"ES256",
|
||||
"RS256",
|
||||
"HS384",
|
||||
"ES512",
|
||||
"PS256",
|
||||
"PS512",
|
||||
"RS512"
|
||||
],
|
||||
"id_token_encryption_alg_values_supported": [
|
||||
"RSA-OAEP",
|
||||
"RSA-OAEP-256",
|
||||
"RSA1_5"
|
||||
],
|
||||
"id_token_encryption_enc_values_supported": [
|
||||
"A256GCM",
|
||||
"A192GCM",
|
||||
"A128GCM",
|
||||
"A128CBC-HS256",
|
||||
"A192CBC-HS384",
|
||||
"A256CBC-HS512"
|
||||
],
|
||||
"userinfo_signing_alg_values_supported": [
|
||||
"PS384",
|
||||
"ES384",
|
||||
"RS384",
|
||||
"HS256",
|
||||
"HS512",
|
||||
"ES256",
|
||||
"RS256",
|
||||
"HS384",
|
||||
"ES512",
|
||||
"PS256",
|
||||
"PS512",
|
||||
"RS512",
|
||||
"none"
|
||||
],
|
||||
"request_object_signing_alg_values_supported": [
|
||||
"PS384",
|
||||
"ES384",
|
||||
"RS384",
|
||||
"HS256",
|
||||
"HS512",
|
||||
"ES256",
|
||||
"RS256",
|
||||
"HS384",
|
||||
"ES512",
|
||||
"PS256",
|
||||
"PS512",
|
||||
"RS512",
|
||||
"none"
|
||||
],
|
||||
"response_modes_supported": [
|
||||
"query",
|
||||
"fragment",
|
||||
"form_post"
|
||||
],
|
||||
"registration_endpoint": "https://auth.trj.tw/auth/realms/school/clients-registrations/openid-connect",
|
||||
"token_endpoint_auth_methods_supported": [
|
||||
"private_key_jwt",
|
||||
"client_secret_basic",
|
||||
"client_secret_post",
|
||||
"tls_client_auth",
|
||||
"client_secret_jwt"
|
||||
],
|
||||
"token_endpoint_auth_signing_alg_values_supported": [
|
||||
"PS384",
|
||||
"ES384",
|
||||
"RS384",
|
||||
"HS256",
|
||||
"HS512",
|
||||
"ES256",
|
||||
"RS256",
|
||||
"HS384",
|
||||
"ES512",
|
||||
"PS256",
|
||||
"PS512",
|
||||
"RS512"
|
||||
],
|
||||
"claims_supported": [
|
||||
"aud",
|
||||
"sub",
|
||||
"iss",
|
||||
"auth_time",
|
||||
"name",
|
||||
"given_name",
|
||||
"family_name",
|
||||
"preferred_username",
|
||||
"email",
|
||||
"acr"
|
||||
],
|
||||
"claim_types_supported": [
|
||||
"normal"
|
||||
],
|
||||
"claims_parameter_supported": true,
|
||||
"scopes_supported": [
|
||||
"openid",
|
||||
"address",
|
||||
"email",
|
||||
"microprofile-jwt",
|
||||
"offline_access",
|
||||
"phone",
|
||||
"profile",
|
||||
"roles",
|
||||
"web-origins"
|
||||
],
|
||||
"request_parameter_supported": true,
|
||||
"request_uri_parameter_supported": true,
|
||||
"require_request_uri_registration": true,
|
||||
"code_challenge_methods_supported": [
|
||||
"plain",
|
||||
"S256"
|
||||
],
|
||||
"tls_client_certificate_bound_access_tokens": true,
|
||||
"revocation_endpoint": "https://auth.trj.tw/auth/realms/school/protocol/openid-connect/revoke",
|
||||
"revocation_endpoint_auth_methods_supported": [
|
||||
"private_key_jwt",
|
||||
"client_secret_basic",
|
||||
"client_secret_post",
|
||||
"tls_client_auth",
|
||||
"client_secret_jwt"
|
||||
],
|
||||
"revocation_endpoint_auth_signing_alg_values_supported": [
|
||||
"PS384",
|
||||
"ES384",
|
||||
"RS384",
|
||||
"HS256",
|
||||
"HS512",
|
||||
"ES256",
|
||||
"RS256",
|
||||
"HS384",
|
||||
"ES512",
|
||||
"PS256",
|
||||
"PS512",
|
||||
"RS512"
|
||||
],
|
||||
"backchannel_logout_supported": true,
|
||||
"backchannel_logout_session_supported": true
|
||||
}
|
12
routes/api/index.js
Normal file
12
routes/api/index.js
Normal file
@ -0,0 +1,12 @@
|
||||
const Router = require('@koa/router');
|
||||
|
||||
const commonCtrl = require('src/controllers/common/index.js');
|
||||
const v1Router = require('./v1/index.js');
|
||||
|
||||
const r = new Router({ prefix: '/api' });
|
||||
module.exports = r;
|
||||
|
||||
// set api handler middleware
|
||||
r.use(commonCtrl.apiHandler(), commonCtrl.getAppVersion());
|
||||
|
||||
r.use(v1Router.routes());
|
25
routes/api/v1/account/index.js
Normal file
25
routes/api/v1/account/index.js
Normal file
@ -0,0 +1,25 @@
|
||||
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()
|
||||
);
|
8
routes/api/v1/index.js
Normal file
8
routes/api/v1/index.js
Normal file
@ -0,0 +1,8 @@
|
||||
const Router = require('@koa/router');
|
||||
|
||||
const accountRouter = require('./account/index.js');
|
||||
|
||||
const r = new Router({ prefix: '/v1' });
|
||||
module.exports = r;
|
||||
|
||||
r.use(accountRouter.routes());
|
14
routes/index.js
Normal file
14
routes/index.js
Normal file
@ -0,0 +1,14 @@
|
||||
const Router = require('@koa/router');
|
||||
|
||||
const controller = require('src/controllers/index.js');
|
||||
const apiRouter = require('./api/index.js');
|
||||
const oauthRouter = require('./oauth/index.js');
|
||||
|
||||
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());
|
8
routes/oauth/index.js
Normal file
8
routes/oauth/index.js
Normal file
@ -0,0 +1,8 @@
|
||||
const Router = require('@koa/router');
|
||||
const oauthCtrl = require('src/controllers/oauth/index.js');
|
||||
|
||||
const r = new Router({ prefix: '/oauth' });
|
||||
|
||||
r.get('/redirect', oauthCtrl.verifyCode());
|
||||
|
||||
module.exports = r;
|
29
routes/types.js
Normal file
29
routes/types.js
Normal file
@ -0,0 +1,29 @@
|
||||
// @ts-nocheck
|
||||
module.exports = {};
|
||||
|
||||
/**
|
||||
* @typedef RespDefault
|
||||
* @description 預設回傳格式
|
||||
* @property {string} message
|
||||
* @property {number} code MessageCode
|
||||
* @property {string} errorStack api error stack (除了prod以外的環境會有)
|
||||
* @property {string} errorMessage api error message (除了prod以外的環境會有)
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef Pager
|
||||
* @description 頁數資訊
|
||||
* @property {number} page 目前頁數
|
||||
* @property {number} count 總筆數
|
||||
* @property {number} total 總頁數
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef Account
|
||||
* @description API回傳使用者資訊
|
||||
* @property {string} id 使用者ID
|
||||
* @property {string} phone 手機
|
||||
* @property {string} display_name 顯示名稱
|
||||
* @property {string} created_time 帳號建立時間
|
||||
* @property {string} updated_time 帳號更新時間
|
||||
*/
|
50
schemas/000000_init.sql
Normal file
50
schemas/000000_init.sql
Normal file
@ -0,0 +1,50 @@
|
||||
--
|
||||
-- PostgreSQL database dump
|
||||
--
|
||||
|
||||
-- Dumped from database version 11.7
|
||||
-- Dumped by pg_dump version 11.7
|
||||
|
||||
SET statement_timeout = 0;
|
||||
SET lock_timeout = 0;
|
||||
SET idle_in_transaction_session_timeout = 0;
|
||||
SET client_encoding = 'UTF8';
|
||||
SET standard_conforming_strings = on;
|
||||
SELECT pg_catalog.set_config('search_path', '', false);
|
||||
SET check_function_bodies = false;
|
||||
SET xmloption = content;
|
||||
SET client_min_messages = warning;
|
||||
SET row_security = off;
|
||||
--
|
||||
-- Name: log; Type: SCHEMA; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
CREATE SCHEMA log;
|
||||
|
||||
|
||||
--
|
||||
-- Name: ltree; Type: EXTENSION; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS ltree WITH SCHEMA public;
|
||||
|
||||
|
||||
--
|
||||
-- Name: EXTENSION ltree; Type: COMMENT; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON EXTENSION ltree IS 'data type for hierarchical tree-like structures';
|
||||
|
||||
|
||||
--
|
||||
-- Name: pgcrypto; Type: EXTENSION; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public;
|
||||
|
||||
|
||||
--
|
||||
-- Name: EXTENSION pgcrypto; Type: COMMENT; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions';
|
99
server.js
Normal file
99
server.js
Normal file
@ -0,0 +1,99 @@
|
||||
const Koa = require('koa');
|
||||
const path = require('path');
|
||||
const url = require('url');
|
||||
const swaggerGenerator = require('@mtfos/swagger-generator');
|
||||
const { copyObject } = require('src/utils/index.js');
|
||||
|
||||
const app = new Koa();
|
||||
const config = require('src/config/index.js');
|
||||
|
||||
// @ts-ignore
|
||||
app.proxy = true;
|
||||
|
||||
// const server = app.listen(config.server.port, () => {
|
||||
// console.log(`server start on port ${server.address().port}`); // eslint-disable-line
|
||||
// });
|
||||
|
||||
// load middleware module
|
||||
const koaLogger = require('koa-logger');
|
||||
const koaCors = require('@koa/cors');
|
||||
const koaBody = require('koa-body');
|
||||
const koaMount = require('koa-mount');
|
||||
const koaStatic = require('koa-static');
|
||||
const rootRouter = require('src/routes/index.js');
|
||||
|
||||
const packageJSON = require('./package.json');
|
||||
|
||||
let swaggerDoc = null;
|
||||
// generate swagger document
|
||||
(async () => {
|
||||
const swaggerSpec = {
|
||||
swaggerDefinition: {
|
||||
info: {
|
||||
description: 'KeyCloak OAuth Demo Server',
|
||||
title: 'KeyCloak',
|
||||
version: packageJSON.version,
|
||||
},
|
||||
host: new url.URL(config.server.url).host,
|
||||
basePath: '',
|
||||
produces: ['application/json'],
|
||||
schemes: ['http', 'https'],
|
||||
securityDefinitions: {
|
||||
JWT: {
|
||||
type: 'apiKey',
|
||||
in: 'header',
|
||||
name: 'Authorization',
|
||||
description: 'Bearer token',
|
||||
},
|
||||
},
|
||||
},
|
||||
basedir: __dirname,
|
||||
files: ['./routes/**/*.js'],
|
||||
};
|
||||
swaggerDoc = await swaggerGenerator.generateSpec(swaggerSpec);
|
||||
})().catch(err => {
|
||||
console.error('[Error] Generate swagger doc failed, ', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// production 不掛上swagger ui
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
app.use(koaMount('/api-docs/', koaStatic(path.resolve(__dirname, 'public', 'swagger-ui'), { index: 'index.html' })));
|
||||
rootRouter.get('/api-docs', async c => {
|
||||
if (!/\/$/.test(c.url)) c.redirect(`${c.url}/`);
|
||||
});
|
||||
}
|
||||
|
||||
// set swagger doc route
|
||||
rootRouter.get('/api-docs.json', async c => {
|
||||
c.type = 'application/json';
|
||||
const spec = copyObject(swaggerDoc || {});
|
||||
|
||||
if (c.protocol === 'https') {
|
||||
spec.schemes = ['https'];
|
||||
}
|
||||
|
||||
c.body = spec;
|
||||
});
|
||||
|
||||
app.use(koaLogger());
|
||||
app.use(
|
||||
koaCors({
|
||||
credentials: true,
|
||||
// allow all origin
|
||||
origin: ctx => ctx.get('origin'),
|
||||
})
|
||||
);
|
||||
app.use(
|
||||
koaBody({
|
||||
multipart: true,
|
||||
formidable: {
|
||||
maxFileSize: 100 * 1024 * 1024, // 100 mb
|
||||
},
|
||||
})
|
||||
);
|
||||
app.use(koaMount('/', koaStatic(path.resolve(__dirname, 'public', 'html'))));
|
||||
app.use(rootRouter.allowedMethods());
|
||||
app.use(rootRouter.routes());
|
||||
|
||||
module.exports = app;
|
19
utils/database.js
Normal file
19
utils/database.js
Normal file
@ -0,0 +1,19 @@
|
||||
const knex = require('knex');
|
||||
const config = require('src/config/index.js');
|
||||
|
||||
const pool = knex({
|
||||
client: 'pg',
|
||||
connection: {
|
||||
user: config.database.user,
|
||||
password: config.database.password,
|
||||
host: config.database.host,
|
||||
port: config.database.port,
|
||||
database: config.database.dbname,
|
||||
},
|
||||
pool: {
|
||||
max: config.database.pool_max,
|
||||
min: config.database.pool_min,
|
||||
},
|
||||
});
|
||||
|
||||
module.exports = pool;
|
166
utils/index.js
Normal file
166
utils/index.js
Normal file
@ -0,0 +1,166 @@
|
||||
/* eslint-disable no-restricted-globals,no-param-reassign,guard-for-in,no-restricted-syntax,no-continue */
|
||||
const mod = {};
|
||||
module.exports = mod;
|
||||
|
||||
/**
|
||||
* check value is number
|
||||
* @param {any} v input value
|
||||
* @return {boolean}
|
||||
*/
|
||||
mod.isNumber = v => !(!isFinite(v) || v === true || v === false || v === null || v === '');
|
||||
|
||||
/**
|
||||
* value to number
|
||||
* @param {object} v source value
|
||||
* @param {number=} defVal default value
|
||||
* @param {number=} min range min
|
||||
* @param {number=} max range max
|
||||
* @return {number}
|
||||
*/
|
||||
mod.toNumber = (v, defVal, min, max) => {
|
||||
let defaultVal = defVal;
|
||||
let inVal = v;
|
||||
if (!mod.isNumber(defVal)) defVal = 0;
|
||||
if (typeof defVal === 'string') defaultVal = parseFloat(defVal);
|
||||
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
|
||||
|
||||
if (!mod.isNumber(v)) return defaultVal;
|
||||
if (typeof v === 'string') inVal = parseFloat(v);
|
||||
if (minVal !== null && inVal < minVal) inVal = min;
|
||||
if (maxVal !== null && inVal > maxVal) inVal = max;
|
||||
|
||||
return inVal;
|
||||
};
|
||||
|
||||
/**
|
||||
* @exports
|
||||
* @typedef pageObject
|
||||
* @prop {number} total page number
|
||||
* @prop {number} page number
|
||||
* @prop {number} count all count number
|
||||
* @prop {number} limit query limit item
|
||||
* @prop {number} offset query offset number
|
||||
*
|
||||
* calc page object
|
||||
* @param {number} argCount all count
|
||||
* @param {number} argPage page number
|
||||
* @param {number} argMaxItem per page item show
|
||||
* @return {pageObject}
|
||||
*/
|
||||
mod.calcPage = (argCount, argPage, argMaxItem = 10) => {
|
||||
const count = mod.toNumber(argCount, 0, 0);
|
||||
let page = mod.toNumber(argPage, 1, 1);
|
||||
const maxItem = mod.toNumber(argMaxItem, 10, 1);
|
||||
|
||||
let total = Math.ceil(count / maxItem);
|
||||
if (total < 1) total = 1;
|
||||
if (page > total) page = total;
|
||||
let offset = (page - 1) * maxItem;
|
||||
if (offset > count) offset = count;
|
||||
const limit = maxItem;
|
||||
|
||||
const pageObject = { total, page, count, offset, limit };
|
||||
for (const key in pageObject) {
|
||||
pageObject[key] = mod.toNumber(pageObject[key]);
|
||||
}
|
||||
|
||||
return pageObject;
|
||||
};
|
||||
|
||||
/**
|
||||
* deep copy object
|
||||
* @param {any} src
|
||||
* @return {any}
|
||||
*/
|
||||
mod.copyObject = src => {
|
||||
if (typeof src !== 'object') return src;
|
||||
|
||||
const isArray = Array.isArray(src);
|
||||
const copy = isArray ? [] : {};
|
||||
for (let it in src) {
|
||||
// @ts-ignore
|
||||
if (isArray) it = parseInt(it, 10);
|
||||
if (typeof src[it] !== 'object') copy[it] = src[it];
|
||||
else copy[it] = mod.copyObject(src[it]);
|
||||
}
|
||||
return copy;
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {(string|number)} SelectObjectAlias
|
||||
* @description pass string and length > 0 rename key to alias, other keep key name
|
||||
*/
|
||||
/**
|
||||
* @exports
|
||||
* @typedef {{[key: string]: SelectObjectAlias }} SelectObjectParam
|
||||
*/
|
||||
|
||||
/**
|
||||
* select object keys
|
||||
* @param {Object<string, any>} obj
|
||||
* @param {SelectObjectParam} param
|
||||
*/
|
||||
mod.selectObject = (obj, param) => {
|
||||
if (typeof obj !== 'object' || typeof param !== 'object') throw new Error('input arg wrong');
|
||||
|
||||
let newObj = {};
|
||||
|
||||
for (const key in param) {
|
||||
const strs = key.split('.');
|
||||
const alias = param[key];
|
||||
if (strs.length > 1) {
|
||||
if (!(strs[0] in obj)) continue;
|
||||
|
||||
newObj = { ...newObj, ...mod.selectObject(obj[strs[0]], { [strs.slice(1).join('.')]: alias }) };
|
||||
}
|
||||
const toAlias = param[key] && typeof param[key] === 'string';
|
||||
if (!(key in obj)) continue;
|
||||
|
||||
newObj[toAlias ? alias : key] = obj[key];
|
||||
}
|
||||
|
||||
return newObj;
|
||||
};
|
||||
|
||||
/**
|
||||
* pad string to target length
|
||||
* @param {any} v source input
|
||||
* @param {number} len target length
|
||||
* @param {number} direct pad direct (-1 left, 1 right)
|
||||
* @param {string} padChar default '0'
|
||||
* @return {string}
|
||||
*/
|
||||
mod.pad = (v, len = 0, direct = -1, padChar = '0') => {
|
||||
if (v === null || v === undefined) return '';
|
||||
if (typeof v !== 'string' && !v.toString) return '';
|
||||
if (direct !== 1 && direct !== -1) return '';
|
||||
if (typeof v !== 'string') v = v.toString();
|
||||
if (typeof padChar !== 'string') padChar = '0';
|
||||
len = mod.toNumber(len, 0, 0);
|
||||
if (v.length < len) {
|
||||
if (direct < 0) v = `${padChar}${v}`;
|
||||
else v = `${v}${padChar}`;
|
||||
return mod.pad(v, len, direct, padChar);
|
||||
}
|
||||
|
||||
return v;
|
||||
};
|
||||
|
||||
/**
|
||||
* pad left
|
||||
* @param {any} v
|
||||
* @param {number} len
|
||||
* @param {string} padChar
|
||||
* @return {string}
|
||||
*/
|
||||
mod.padLeft = (v, len = 0, padChar = '0') => mod.pad(v, len, -1, padChar);
|
||||
|
||||
/**
|
||||
* pad right
|
||||
* @param {any} v
|
||||
* @param {number} len
|
||||
* @param {string} padChar
|
||||
* @return {string}
|
||||
*/
|
||||
mod.padRight = (v, len = 0, padChar = '0') => mod.pad(v, len, 1, padChar);
|
9
utils/pkgs.js
Normal file
9
utils/pkgs.js
Normal file
@ -0,0 +1,9 @@
|
||||
const path = require('path');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const pkg = {};
|
||||
|
||||
pkg.path = path;
|
||||
pkg.jwt = jwt;
|
||||
|
||||
module.exports = pkg;
|
42
utils/redis.js
Normal file
42
utils/redis.js
Normal file
@ -0,0 +1,42 @@
|
||||
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 += ':';
|
||||
super({
|
||||
host,
|
||||
port,
|
||||
password,
|
||||
db,
|
||||
});
|
||||
|
||||
this.prefix = prefix;
|
||||
|
||||
const self = this;
|
||||
// key pattern functions
|
||||
this.Key = {
|
||||
/**
|
||||
* SSO 登入暫存
|
||||
* @param {string} s state
|
||||
* @return {string}
|
||||
*/
|
||||
ssoLoginCache: s => self.getKeyWithPrefix(`sso-login:${s}`),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* combine key and prefix
|
||||
* @param {string} s
|
||||
* @return {string}
|
||||
*/
|
||||
getKeyWithPrefix(s) {
|
||||
if (typeof s !== 'string') throw new Error('input key not a string');
|
||||
|
||||
return `${this.prefix}${s}`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new Redis();
|
72
utils/response/index.js
Normal file
72
utils/response/index.js
Normal file
@ -0,0 +1,72 @@
|
||||
/* eslint-disable no-underscore-dangle */
|
||||
/**
|
||||
* @exports
|
||||
* @typedef {Object} codeMessage
|
||||
* @prop {number} code
|
||||
* @prop {string} message
|
||||
*/
|
||||
/**
|
||||
* @exports
|
||||
* @typedef {Object} respObject
|
||||
* @prop {number} status
|
||||
* @prop {codeMessage} object
|
||||
*/
|
||||
|
||||
/**
|
||||
* create respobject
|
||||
* @param {number} status
|
||||
* @param {codeMessage} codeMsg
|
||||
*/
|
||||
|
||||
const mod = {};
|
||||
module.exports = mod;
|
||||
|
||||
mod.respDefault = (status = 200, codeMsg) => ({ status, object: codeMsg });
|
||||
|
||||
mod.APIError = class extends Error {
|
||||
/**
|
||||
* @param {string} message
|
||||
* @param {respObject} resp
|
||||
*/
|
||||
constructor(message = '', resp) {
|
||||
super(message);
|
||||
this._object = resp || {};
|
||||
}
|
||||
|
||||
get object() {
|
||||
return this._object;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* check response object struct
|
||||
* @param {respObject} v
|
||||
*/
|
||||
mod.checkStruct = v => {
|
||||
if (typeof v !== 'object' || v === null || v === undefined) 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;
|
||||
return true;
|
||||
};
|
||||
|
||||
mod.codeMessage = {
|
||||
CodeSuccess: { code: 1000, message: 'success' },
|
||||
CodeCreated: { code: 1001, message: 'created' },
|
||||
CodeAccepted: { code: 1002, message: 'accepted' },
|
||||
CodeDataFormat: { code: 1003, message: 'data format error' },
|
||||
CodeUnauthorized: { code: 1004, message: 'unauthorized' },
|
||||
CodeForbidden: { code: 1005, message: 'forbidden' },
|
||||
CodeNotFound: { code: 1006, message: 'not found' },
|
||||
CodeInternalError: { code: 1007, message: 'internal error' },
|
||||
};
|
||||
|
||||
mod.resp = {
|
||||
Success: mod.respDefault(200, mod.codeMessage.CodeSuccess),
|
||||
Created: mod.respDefault(201, mod.codeMessage.CodeCreated),
|
||||
Accepted: mod.respDefault(202, mod.codeMessage.CodeAccepted),
|
||||
DataFormat: mod.respDefault(400, mod.codeMessage.CodeDataFormat),
|
||||
Unauthorized: mod.respDefault(401, mod.codeMessage.CodeUnauthorized),
|
||||
Forbidden: mod.respDefault(403, mod.codeMessage.CodeForbidden),
|
||||
NotFound: mod.respDefault(404, mod.codeMessage.CodeNotFound),
|
||||
InternalError: mod.respDefault(500, mod.codeMessage.CodeInternalError),
|
||||
};
|
130
utils/sso/index.js
Normal file
130
utils/sso/index.js
Normal file
@ -0,0 +1,130 @@
|
||||
const joi = require('joi');
|
||||
const url = require('url');
|
||||
const querystring = require('querystring');
|
||||
const got = require('got');
|
||||
const config = require('src/config/index.js');
|
||||
const { jwt } = require('src/utils/pkgs.js');
|
||||
|
||||
const mod = {};
|
||||
module.exports = mod;
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
mod.getAuthURL = state => {
|
||||
const input = joi
|
||||
.object({
|
||||
authorized_endpoint: joi.string().required(),
|
||||
token_endpoint: joi.string().required(),
|
||||
client_id: joi.string().required(),
|
||||
client_secret: joi.string().required(),
|
||||
state: joi.string().allow('', null).default(''),
|
||||
})
|
||||
.unknown()
|
||||
.validate({ ...config.sso, state });
|
||||
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 }}}
|
||||
*/
|
||||
const { value } = input;
|
||||
|
||||
const redirectUri = new url.URL('/oauth/redirect', config.server.url);
|
||||
|
||||
const qs = {
|
||||
client_id: value.client_id,
|
||||
scope: 'openid',
|
||||
response_type: 'code',
|
||||
redirect_uri: redirectUri.toString(),
|
||||
};
|
||||
if (value.state) qs.state = state;
|
||||
|
||||
return `${value.authorized_endpoint}?${querystring.stringify(qs)}`;
|
||||
};
|
||||
|
||||
mod.getLogoutURL = () => {
|
||||
const input = joi
|
||||
.object({
|
||||
logout_endpoint: joi.string().required(),
|
||||
})
|
||||
.unknown()
|
||||
.validate({ ...config.sso });
|
||||
if (input.error) throw new Error(input.error.message);
|
||||
const redirectUri = new url.URL('/oauth/redirect', config.server.url);
|
||||
|
||||
const qs = { state: 'logout', redirect_uri: redirectUri.toString() };
|
||||
|
||||
return `${input.value.logout_endpoint}?${querystring.stringify(qs)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef SSOAccount
|
||||
* @property {string} username
|
||||
* @property {string} display_name
|
||||
* @property {string} email
|
||||
*/
|
||||
|
||||
mod.getToken = async (code, state) => {
|
||||
const input = joi
|
||||
.object({
|
||||
authorized_endpoint: joi.string().required(),
|
||||
token_endpoint: joi.string().required(),
|
||||
client_id: joi.string().required(),
|
||||
client_secret: joi.string().required(),
|
||||
state: joi.string().required(),
|
||||
code: joi.string().required(),
|
||||
})
|
||||
.unknown()
|
||||
.validate({ ...config.sso, state, code });
|
||||
|
||||
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 }}}
|
||||
*/
|
||||
const { value } = input;
|
||||
|
||||
const redirectUri = new url.URL('/oauth/redirect', config.server.url);
|
||||
|
||||
const qs = {
|
||||
client_id: value.client_id,
|
||||
client_secret: value.client_secret,
|
||||
redirect_uri: redirectUri.toString(),
|
||||
code: value.code,
|
||||
client_session_state: value.state,
|
||||
grant_type: 'authorization_code',
|
||||
};
|
||||
|
||||
const resp = await got.default.post(value.token_endpoint, {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: querystring.stringify(qs),
|
||||
responseType: 'json',
|
||||
});
|
||||
|
||||
const { body } = resp;
|
||||
if (!body) throw new Error('resopnse body empty');
|
||||
|
||||
const { id_token: idToken, access_token: accessToken } = 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)
|
||||
// @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} */
|
||||
const ssoAccount = {
|
||||
username: preferredUsername.toLowerCase(),
|
||||
display_name: displayName ?? preferredUsername,
|
||||
email: decoded.email ?? '',
|
||||
};
|
||||
|
||||
return ssoAccount;
|
||||
};
|
Loading…
Reference in New Issue
Block a user