diff --git a/package.json b/package.json index e33525ff..5aaace4a 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "koa-router": "^10.1.1", "koa2-ratelimit": "^1.1.1", "lodash": "^4.17.21", + "md5": "^2.3.0", "nunjucks": "^3.2.3", "openapi3-ts": "^2.0.2", "ora": "^5.4.1", @@ -38,9 +39,11 @@ "@nrwl/js": "14.0.3", "@nrwl/linter": "14.0.3", "@nrwl/workspace": "14.0.3", + "@types/bcryptjs": "^2.4.2", "@types/from2": "^2.3.1", "@types/glob": "^7.2.0", "@types/inquirer": "^8.0.0", + "@types/is-base64": "^1.1.1", "@types/jest": "27.4.1", "@types/js-yaml": "^4.0.5", "@types/koa": "^2.13.4", @@ -49,11 +52,13 @@ "@types/koa2-ratelimit": "^0.9.3", "@types/koa__cors": "^3.3.0", "@types/lodash": "^4.14.182", + "@types/md5": "^2.3.2", "@types/node": "16.11.7", "@types/supertest": "^2.0.12", "@types/uuid": "^8.3.4", "@typescript-eslint/eslint-plugin": "~5.18.0", "@typescript-eslint/parser": "~5.18.0", + "bcryptjs": "^2.4.3", "commitizen": "^4.2.5", "cz-conventional-changelog": "^3.3.0", "eslint": "~8.12.0", diff --git a/packages/serve/src/containers/modules/extension.ts b/packages/serve/src/containers/modules/extension.ts index db5eaa64..6156336a 100644 --- a/packages/serve/src/containers/modules/extension.ts +++ b/packages/serve/src/containers/modules/extension.ts @@ -1,8 +1,9 @@ import { ExtensionLoader } from '@vulcan-sql/core'; import { AsyncContainerModule } from 'inversify'; -import { ServeConfig } from '../../models/serveConfig'; +import { ServeConfig } from '../../models/serveOptions'; import { BuiltInRouteMiddlewares } from '@vulcan-sql/serve/middleware'; import { BuiltInFormatters } from '@vulcan-sql/serve/response-formatter'; +import { BuiltInAuthenticators } from '../../lib/auth'; export const extensionModule = (options: ServeConfig) => new AsyncContainerModule(async (bind) => { @@ -13,6 +14,8 @@ export const extensionModule = (options: ServeConfig) => loader.loadInternalExtensionModule(BuiltInRouteMiddlewares); // formatter (single module) loader.loadInternalExtensionModule(BuiltInFormatters); + // authenticator (single module) + loader.loadInternalExtensionModule(BuiltInAuthenticators); loader.bindExtensions(bind); }); diff --git a/packages/serve/src/containers/types.ts b/packages/serve/src/containers/types.ts index 1cfddfe3..69e8aebc 100644 --- a/packages/serve/src/containers/types.ts +++ b/packages/serve/src/containers/types.ts @@ -5,10 +5,12 @@ export const TYPES = { PaginationTransformer: Symbol.for('PaginationTransformer'), Route: Symbol.for('Route'), RouteGenerator: Symbol.for('RouteGenerator'), + // Application AppConfig: Symbol.for('AppConfig'), VulcanApplication: Symbol.for('VulcanApplication'), // Extensions Extension_RouteMiddleware: Symbol.for('Extension_RouteMiddleware'), + Extension_Authenticator: Symbol.for('Extension_Authenticator'), Extension_Formatter: Symbol.for('Extension_Formatter'), }; diff --git a/packages/serve/src/index.ts b/packages/serve/src/index.ts index 5ef259f0..6d9f7e5f 100644 --- a/packages/serve/src/index.ts +++ b/packages/serve/src/index.ts @@ -1,5 +1,8 @@ export * from './lib/route'; export * from './lib/middleware'; +export * from './lib/response-formatter'; +export * from './lib/pagination'; +export * from './lib/auth'; export * from './lib/app'; export * from './lib/server'; export * from './models'; diff --git a/packages/serve/src/lib/app.ts b/packages/serve/src/lib/app.ts index bcdc4b4e..a779957f 100644 --- a/packages/serve/src/lib/app.ts +++ b/packages/serve/src/lib/app.ts @@ -85,6 +85,7 @@ export class VulcanApplication { /** load built-in and extensions middleware classes for app used */ public async useMiddleware() { for (const middleware of this.routeMiddlewares) { + if (middleware.activate) await middleware.activate(); this.app.use(middleware.handle.bind(middleware)); } } diff --git a/packages/serve/src/lib/auth/httpBasicAuthenticator.ts b/packages/serve/src/lib/auth/httpBasicAuthenticator.ts new file mode 100644 index 00000000..797b85c2 --- /dev/null +++ b/packages/serve/src/lib/auth/httpBasicAuthenticator.ts @@ -0,0 +1,143 @@ +import * as fs from 'fs'; +import * as readline from 'readline'; +import * as md5 from 'md5'; +import { + BaseAuthenticator, + KoaContext, + AuthStatus, + AuthResult, +} from '@vulcan-sql/serve/models'; +import { VulcanExtensionId, VulcanInternalExtension } from '@vulcan-sql/core'; +import { isEmpty } from 'lodash'; + +interface AuthUserOptions { + /* user name */ + name: string; + /* the user attribute which could used after auth successful */ + attr: { [field: string]: string | boolean | number }; +} + +interface HTPasswdFileOptions { + /** password file path */ + ['path']: string; + /** each user information */ + ['users']: Array; +} + +export interface AuthUserListOptions { + /* user name */ + name: string; + /* hashed password by md5 */ + md5Password: string; + /* the user attribute which could used after auth successful */ + attr: { [field: string]: string | boolean | number }; +} + +export interface BasicOptions { + ['htpasswd-file']?: HTPasswdFileOptions; + ['users-list']?: Array; +} + +type UserCredentialsMap = { + [name: string]: { + /* hashed password by md5 */ + md5Password: string; + /* the user attribute which could used after auth successful */ + attr: { [field: string]: string | boolean | number }; + }; +}; + +/** The http basic authenticator. + * + * Able to set user credentials by file path through "htpasswd-file" or list directly in config by "users-list". + * The password must hash by md5 when setting into "htpasswd-file" or "users-list". + * + * It authenticate by passing encode base64 {username}:{password} to authorization + */ +@VulcanInternalExtension('auth') +@VulcanExtensionId('basic') +export class BasicAuthenticator extends BaseAuthenticator { + private usersCredentials: UserCredentialsMap = {}; + private options: BasicOptions = {}; + /** read basic options to initialize and load user credentials */ + public override async onActivate() { + this.options = (this.getOptions() as BasicOptions) || this.options; + // load "users-list" in options + for (const option of this.options['users-list'] || []) { + const { name, md5Password, attr } = option; + this.usersCredentials[name] = { md5Password, attr }; + } + // load "htpasswd-file" in options + if (!this.options['htpasswd-file']) return; + const { path, users } = this.options['htpasswd-file']; + + if (!fs.existsSync(path) || !fs.statSync(path).isFile()) return; + const reader = readline.createInterface({ + input: fs.createReadStream(path), + }); + // username:md5Password + for await (const line of reader) { + const name = line.split(':')[0] || ''; + const md5Password = line.split(':')[1] || ''; + // if users exist the same name, add attr to here, or as empty + this.usersCredentials[name] = { + md5Password, + attr: users?.find((user) => user.name === name)?.attr || {}, + }; + } + } + + public async authenticate(context: KoaContext) { + const incorrect = { + status: AuthStatus.INDETERMINATE, + type: this.getExtensionId()!, + }; + if (isEmpty(this.options)) return incorrect; + + const authRequest = context.request.headers['authorization']; + if ( + !authRequest || + !authRequest.toLowerCase().startsWith(this.getExtensionId()!) + ) + return incorrect; + + // validate request auth token + const token = authRequest.trim().split(' ')[1]; + const bareToken = Buffer.from(token, 'base64').toString(); + + try { + return await this.verify(bareToken); + } catch (err) { + // if not found matched user credential, add WWW-Authenticate and return failed + context.set('WWW-Authenticate', this.getExtensionId()!); + return { + status: AuthStatus.FAIL, + type: this.getExtensionId()!, + message: (err as Error).message, + }; + } + } + + private async verify(baredToken: string) { + const username = baredToken.split(':')[0] || ''; + // bare password from Basic specification + const password = baredToken.split(':')[1] || ''; + // if authenticated, return user data + if ( + !(username in this.usersCredentials) || + !(md5(password) === this.usersCredentials[username].md5Password) + ) + throw new Error( + `authenticate user by "${this.getExtensionId()}" type failed.` + ); + + return { + status: AuthStatus.SUCCESS, + type: this.getExtensionId()!, // method name + user: { + name: username, + attr: this.usersCredentials[username].attr, + }, + } as AuthResult; + } +} diff --git a/packages/serve/src/lib/auth/index.ts b/packages/serve/src/lib/auth/index.ts new file mode 100644 index 00000000..988de126 --- /dev/null +++ b/packages/serve/src/lib/auth/index.ts @@ -0,0 +1,13 @@ +export * from './simpleTokenAuthenticator'; +export * from './passwordFileAuthenticator'; +export * from './httpBasicAuthenticator'; + +import { SimpleTokenAuthenticator } from './simpleTokenAuthenticator'; +import { PasswordFileAuthenticator } from './passwordFileAuthenticator'; +import { BasicAuthenticator } from './httpBasicAuthenticator'; + +export const BuiltInAuthenticators = [ + BasicAuthenticator, + SimpleTokenAuthenticator, + PasswordFileAuthenticator, +]; diff --git a/packages/serve/src/lib/auth/passwordFileAuthenticator.ts b/packages/serve/src/lib/auth/passwordFileAuthenticator.ts new file mode 100644 index 00000000..56fdb232 --- /dev/null +++ b/packages/serve/src/lib/auth/passwordFileAuthenticator.ts @@ -0,0 +1,122 @@ +import * as fs from 'fs'; +import * as readline from 'readline'; +import * as bcrypt from 'bcryptjs'; +import { + BaseAuthenticator, + KoaContext, + AuthStatus, + AuthResult, +} from '@vulcan-sql/serve/models'; +import { VulcanExtensionId, VulcanInternalExtension } from '@vulcan-sql/core'; +import { isEmpty } from 'lodash'; + +export interface PasswordFileUserOptions { + /* user name */ + name: string; + /* the user attribute which could used after auth successful */ + attr: { [field: string]: string | boolean | number }; +} +interface PasswordFileOptions { + /** password file path */ + ['path']?: string; + /** each user information */ + ['users']?: Array; +} + +type UserCredentialsMap = { + [name: string]: { + /* hashed password by bcrypt */ + bcryptPassword: string; + /* the user attribute which could used after auth successful */ + attr: { [field: string]: string | boolean | number }; + }; +}; + +/** The password-file authenticator. + * + * Setting the password file with {username}:{bcrypt-password} format, we use the bcrypt round 10. + * Then authenticate by passing encode base64 {username}:{password} to authorization. + */ +@VulcanInternalExtension('auth') +@VulcanExtensionId('password-file') +export class PasswordFileAuthenticator extends BaseAuthenticator { + private usersCredentials: UserCredentialsMap = {}; + private options: PasswordFileOptions = {}; + + /** read password file and users info to initialize user credentials */ + public override async onActivate() { + this.options = (this.getOptions() as PasswordFileOptions) || this.options; + const { path, users } = this.options; + if (!path || !fs.existsSync(path) || !fs.statSync(path).isFile()) return; + const reader = readline.createInterface({ + input: fs.createReadStream(path), + }); + // : + for await (const line of reader) { + const name = line.split(':')[0] || ''; + const bcryptPassword = line.split(':')[1] || ''; + if (!isEmpty(bcryptPassword) && !bcryptPassword.startsWith('$2y$')) + throw new Error(`"${this.getExtensionId()}" type must bcrypt in file.`); + + // if users exist the same name, add attr to here, or as empty + this.usersCredentials[name] = { + bcryptPassword, + attr: users?.find((user) => user.name === name)?.attr || {}, + }; + } + } + + public async authenticate(context: KoaContext) { + const incorrect = { + status: AuthStatus.INDETERMINATE, + type: this.getExtensionId()!, + }; + if (isEmpty(this.options)) return incorrect; + + const authRequest = context.request.headers['authorization']; + if ( + !authRequest || + !authRequest.toLowerCase().startsWith(this.getExtensionId()!) + ) + return incorrect; + // validate request auth token + const token = authRequest.trim().split(' ')[1]; + const bareToken = Buffer.from(token, 'base64').toString(); + try { + return await this.verify(bareToken); + } catch (err) { + // if not found matched user credential, return failed + return { + status: AuthStatus.FAIL, + type: this.getExtensionId()!, + message: (err as Error).message, + }; + } + } + + private async verify(baredToken: string) { + const username = baredToken.split(':')[0] || ''; + // bare password in token + const password = baredToken.split(':')[1] || ''; + // if authenticated, return user data + if ( + !(username in this.usersCredentials) || + !bcrypt.compareSync( + password, + this.usersCredentials[username].bcryptPassword + ) + ) + throw new Error( + `authenticate user by "${this.getExtensionId()}" type failed.` + ); + + return { + status: AuthStatus.SUCCESS, + type: this.getExtensionId()!, // method name + user: { + name: username, + attr: this.usersCredentials[username].attr, + }, + } as AuthResult; + } +} diff --git a/packages/serve/src/lib/auth/simpleTokenAuthenticator.ts b/packages/serve/src/lib/auth/simpleTokenAuthenticator.ts new file mode 100644 index 00000000..28494833 --- /dev/null +++ b/packages/serve/src/lib/auth/simpleTokenAuthenticator.ts @@ -0,0 +1,88 @@ +import { + BaseAuthenticator, + KoaContext, + AuthResult, + AuthStatus, +} from '@vulcan-sql/serve/models'; +import { VulcanExtensionId, VulcanInternalExtension } from '@vulcan-sql/core'; +import { isEmpty } from 'lodash'; + +export type SimpleTokenOptions = Array<{ + /* user name */ + name: string; + /* token value, could be any format */ + token: string; + /* the user attribute which could used after auth successful */ + attr: { [field: string]: string | boolean | number }; +}>; + +type UserCredentialsMap = { + /* token value, could be any format */ + [token: string]: { + name: string; + /* the user attribute which could used after auth successful */ + attr: { [field: string]: string | boolean | number }; + }; +}; + +/** The simple-token authenticator. setting the token and authenticate by token directly. + * + * Token could be any format e.g: md5, base64 encode, sha..., but must set it in the token field of "simple-token" list too. + * */ +@VulcanInternalExtension('auth') +@VulcanExtensionId('simple-token') +export class SimpleTokenAuthenticator extends BaseAuthenticator { + private options: SimpleTokenOptions = []; + private usersCredentials: UserCredentialsMap = {}; + /** read simple-token and users info to initialize user credentials */ + public override async onActivate() { + this.options = (this.getOptions() as SimpleTokenOptions) || this.options; + for (const option of this.options) { + const { name, token, attr } = option; + this.usersCredentials[token] = { name, attr }; + } + } + + public async authenticate(context: KoaContext) { + const incorrect = { + status: AuthStatus.INDETERMINATE, + type: this.getExtensionId()!, + }; + if (isEmpty(this.options)) return incorrect; + + const authRequest = context.request.headers['authorization']; + if ( + !authRequest || + !authRequest.toLowerCase().startsWith(this.getExtensionId()!) + ) + return incorrect; + // validate request auth token + const token = authRequest.trim().split(' ')[1]; + try { + return await this.verify(token); + } catch (err) { + // if not found matched user credential, return failed + return { + status: AuthStatus.FAIL, + type: this.getExtensionId()!, + message: (err as Error).message, + }; + } + } + private async verify(token: string) { + // if authenticated + if (!(token in this.usersCredentials)) + throw new Error( + `authenticate user by "${this.getExtensionId()}" type failed.` + ); + + return { + status: AuthStatus.SUCCESS, + type: this.getExtensionId()!, // method name + user: { + name: this.usersCredentials[token].name, + attr: this.usersCredentials[token].attr, + }, + } as AuthResult; + } +} diff --git a/packages/serve/src/lib/middleware/auditLogMiddleware.ts b/packages/serve/src/lib/middleware/auditLogMiddleware.ts index 72822129..931de4cf 100644 --- a/packages/serve/src/lib/middleware/auditLogMiddleware.ts +++ b/packages/serve/src/lib/middleware/auditLogMiddleware.ts @@ -3,8 +3,7 @@ import { LoggerOptions, VulcanInternalExtension, } from '@vulcan-sql/core'; -import { BuiltInMiddleware } from '@vulcan-sql/serve/models'; -import { KoaRouterContext, KoaNext } from '@vulcan-sql/serve/route'; +import { BuiltInMiddleware, KoaContext, Next } from '@vulcan-sql/serve/models'; @VulcanInternalExtension('audit-log') export class AuditLoggingMiddleware extends BuiltInMiddleware { @@ -13,7 +12,7 @@ export class AuditLoggingMiddleware extends BuiltInMiddleware { options: this.getOptions(), }); - public async handle(context: KoaRouterContext, next: KoaNext) { + public async handle(context: KoaContext, next: Next) { if (!this.enabled) return next(); const { path, request, params, response } = context; diff --git a/packages/serve/src/lib/middleware/authMiddleware.ts b/packages/serve/src/lib/middleware/authMiddleware.ts new file mode 100644 index 00000000..30582112 --- /dev/null +++ b/packages/serve/src/lib/middleware/authMiddleware.ts @@ -0,0 +1,84 @@ +import { isEmpty } from 'lodash'; +import { inject, multiInject } from 'inversify'; +import { TYPES as CORE_TYPES, VulcanInternalExtension } from '@vulcan-sql/core'; +import { + Next, + KoaContext, + BuiltInMiddleware, + BaseAuthenticator, + AuthStatus, +} from '@vulcan-sql/serve/models'; +import { TYPES } from '@vulcan-sql/serve/containers'; + +export interface AuthOptions { + // different auth type settings + [authType: string]: any; +} + +export type AuthenticatorMap = { + [name: string]: BaseAuthenticator; +}; + +/** The middleware used to check request auth information. + * It seek the 'auth' module name to match data through built-in and customized authenticator by BaseAuthenticator + * */ +@VulcanInternalExtension('auth') +export class AuthMiddleware extends BuiltInMiddleware { + private authenticators: AuthenticatorMap; + + constructor( + @inject(CORE_TYPES.ExtensionConfig) config: any, + @inject(CORE_TYPES.ExtensionName) name: string, + @multiInject(TYPES.Extension_Authenticator) + authenticators: BaseAuthenticator[] + ) { + super(config, name); + + this.authenticators = authenticators.reduce( + (prev, authenticator) => { + prev[authenticator.getExtensionId()!] = authenticator; + return prev; + }, + {} + ); + } + public override async onActivate() { + for (const authId of Object.keys(this.authenticators)) { + const authenticator = this.authenticators[authId]; + if (authenticator.activate) await authenticator.activate(); + } + } + + public async handle(context: KoaContext, next: Next) { + // return to stop the middleware, if disabled + if (!this.enabled) return next(); + + const options = (this.getOptions() as AuthOptions) || {}; + if (isEmpty(options)) return next(); + + // pass current context to authenticate different users of auth type with with config + for (const name of Object.keys(this.authenticators)) { + // skip the disappeared auth type name in options + if (!options[name]) continue; + // authenticate + const result = await this.authenticators[name].authenticate(context); + // if state is indeterminate, change to next authentication + if (result.status === AuthStatus.INDETERMINATE) continue; + // if state is failed, return directly + if (result.status === AuthStatus.FAIL) { + context.status = 401; + context.body = { + type: result.type, + message: result.message || 'authentication failed', + }; + return; + } + // set auth user information to context + context.state.user = result.user!; + await next(); + return; + } + + throw new Error('all types of authentication failed.'); + } +} diff --git a/packages/serve/src/lib/middleware/corsMiddleware.ts b/packages/serve/src/lib/middleware/corsMiddleware.ts index d0dc608a..a8b6e095 100644 --- a/packages/serve/src/lib/middleware/corsMiddleware.ts +++ b/packages/serve/src/lib/middleware/corsMiddleware.ts @@ -1,6 +1,5 @@ import * as cors from '@koa/cors'; -import { KoaRouterContext, KoaNext } from '@vulcan-sql/serve/route'; -import { BuiltInMiddleware } from '@vulcan-sql/serve/models'; +import { BuiltInMiddleware, KoaContext, Next } from '@vulcan-sql/serve/models'; import { VulcanInternalExtension } from '@vulcan-sql/core'; export type CorsOptions = cors.Options; @@ -9,7 +8,7 @@ export type CorsOptions = cors.Options; export class CorsMiddleware extends BuiltInMiddleware { private koaCors = cors(this.getOptions()); - public async handle(context: KoaRouterContext, next: KoaNext) { + public async handle(context: KoaContext, next: Next) { if (!this.enabled) return next(); return this.koaCors(context, next); } diff --git a/packages/serve/src/lib/middleware/index.ts b/packages/serve/src/lib/middleware/index.ts index bab3afa3..a628c2a1 100644 --- a/packages/serve/src/lib/middleware/index.ts +++ b/packages/serve/src/lib/middleware/index.ts @@ -2,9 +2,11 @@ export * from './corsMiddleware'; export * from './requestIdMiddleware'; export * from './auditLogMiddleware'; export * from './rateLimitMiddleware'; +export * from './authMiddleware'; export * from './response-format'; import { CorsMiddleware } from './corsMiddleware'; +import { AuthMiddleware } from './authMiddleware'; import { RateLimitMiddleware } from './rateLimitMiddleware'; import { RequestIdMiddleware } from './requestIdMiddleware'; import { AuditLoggingMiddleware } from './auditLogMiddleware'; @@ -17,5 +19,6 @@ export const BuiltInRouteMiddlewares: ClassType[] = [ RateLimitMiddleware, RequestIdMiddleware, AuditLoggingMiddleware, + AuthMiddleware, ResponseFormatMiddleware, ]; diff --git a/packages/serve/src/lib/middleware/rateLimitMiddleware.ts b/packages/serve/src/lib/middleware/rateLimitMiddleware.ts index 9e34f616..fd52381d 100644 --- a/packages/serve/src/lib/middleware/rateLimitMiddleware.ts +++ b/packages/serve/src/lib/middleware/rateLimitMiddleware.ts @@ -1,6 +1,5 @@ import { RateLimit, RateLimitOptions } from 'koa2-ratelimit'; -import { KoaRouterContext, KoaNext } from '@vulcan-sql/serve/route'; -import { BuiltInMiddleware } from '@vulcan-sql/serve/models'; +import { BuiltInMiddleware, KoaContext, Next } from '@vulcan-sql/serve/models'; import { VulcanInternalExtension } from '@vulcan-sql/core'; export { RateLimitOptions }; @@ -9,7 +8,7 @@ export { RateLimitOptions }; export class RateLimitMiddleware extends BuiltInMiddleware { private koaRateLimit = RateLimit.middleware(this.getOptions()); - public async handle(context: KoaRouterContext, next: KoaNext) { + public async handle(context: KoaContext, next: Next) { if (!this.enabled) return next(); return this.koaRateLimit(context, next); } diff --git a/packages/serve/src/lib/middleware/requestIdMiddleware.ts b/packages/serve/src/lib/middleware/requestIdMiddleware.ts index b9dcba31..2e04304f 100644 --- a/packages/serve/src/lib/middleware/requestIdMiddleware.ts +++ b/packages/serve/src/lib/middleware/requestIdMiddleware.ts @@ -4,8 +4,7 @@ import { asyncReqIdStorage, VulcanInternalExtension, } from '@vulcan-sql/core'; -import { KoaRouterContext, KoaNext } from '@vulcan-sql/serve/route'; -import { BuiltInMiddleware } from '@vulcan-sql/serve/models'; +import { BuiltInMiddleware, KoaContext, Next } from '@vulcan-sql/serve/models'; import { TYPES as CORE_TYPES } from '@vulcan-sql/core'; import { inject } from 'inversify'; @@ -32,7 +31,7 @@ export class RequestIdMiddleware extends BuiltInMiddleware { if (!this.options['name']) this.options['name'] = 'X-Request-ID'; if (!this.options['fieldIn']) this.options['fieldIn'] = FieldInType.HEADER; } - public async handle(context: KoaRouterContext, next: KoaNext) { + public async handle(context: KoaContext, next: Next) { if (!this.enabled) return next(); const { request } = context; diff --git a/packages/serve/src/lib/middleware/response-format/helpers.ts b/packages/serve/src/lib/middleware/response-format/helpers.ts index 39009b6d..2f6011b6 100644 --- a/packages/serve/src/lib/middleware/response-format/helpers.ts +++ b/packages/serve/src/lib/middleware/response-format/helpers.ts @@ -1,4 +1,4 @@ -import { KoaRouterContext } from '@vulcan-sql/serve/route'; +import { KoaContext } from '@vulcan-sql/serve/models'; import { BaseResponseFormatter } from '@vulcan-sql/serve/models'; export type ResponseFormatterMap = { @@ -12,7 +12,7 @@ export type ResponseFormatterMap = { * @returns boolean, is received */ export const isReceivedFormatRequest = ( - context: KoaRouterContext, + context: KoaContext, format: string ) => { if (context.request.path.endsWith(`.${format}`)) return true; @@ -32,7 +32,7 @@ export const checkUsableFormat = ({ supportedFormats, defaultFormat, }: { - context: KoaRouterContext; + context: KoaContext; formatters: ResponseFormatterMap; supportedFormats: string[]; defaultFormat: string; diff --git a/packages/serve/src/lib/middleware/response-format/middleware.ts b/packages/serve/src/lib/middleware/response-format/middleware.ts index f0f8fac0..6df0ac4a 100644 --- a/packages/serve/src/lib/middleware/response-format/middleware.ts +++ b/packages/serve/src/lib/middleware/response-format/middleware.ts @@ -1,8 +1,8 @@ +import { KoaContext, Next } from '@vulcan-sql/serve/models'; import { BaseResponseFormatter, BuiltInMiddleware, } from '@vulcan-sql/serve/models'; -import { KoaRouterContext, KoaNext } from '@vulcan-sql/serve/route'; import { checkUsableFormat, ResponseFormatterMap } from './helpers'; import { VulcanInternalExtension } from '@vulcan-sql/core'; import { TYPES as CORE_TYPES } from '@vulcan-sql/core'; @@ -40,8 +40,7 @@ export class ResponseFormatMiddleware extends BuiltInMiddleware format.toLowerCase()); this.defaultFormat = !options.default ? 'json' : options.default; } - - public async handle(context: KoaRouterContext, next: KoaNext) { + public async handle(context: KoaContext, next: Next) { // return to skip the middleware, if disabled if (!this.enabled) return next(); diff --git a/packages/serve/src/lib/pagination/strategy/cursorBasedStrategy.ts b/packages/serve/src/lib/pagination/strategy/cursorBasedStrategy.ts index 4c9a267e..90fe72a5 100644 --- a/packages/serve/src/lib/pagination/strategy/cursorBasedStrategy.ts +++ b/packages/serve/src/lib/pagination/strategy/cursorBasedStrategy.ts @@ -1,4 +1,4 @@ -import { RouterContext as KoaRouterContext } from 'koa-router'; +import { KoaContext } from '@vulcan-sql/serve/models'; import { normalizeStringValue, PaginationMode, @@ -7,7 +7,7 @@ import { import { PaginationStrategy } from './strategy'; export class CursorBasedStrategy extends PaginationStrategy { - public async transform(ctx: KoaRouterContext) { + public async transform(ctx: KoaContext) { const checkFelidInQueryString = ['limit', 'cursor'].every((field) => Object.keys(ctx.request.query).includes(field) ); diff --git a/packages/serve/src/lib/pagination/strategy/keysetBasedStrategy.ts b/packages/serve/src/lib/pagination/strategy/keysetBasedStrategy.ts index a39defca..b4c6905d 100644 --- a/packages/serve/src/lib/pagination/strategy/keysetBasedStrategy.ts +++ b/packages/serve/src/lib/pagination/strategy/keysetBasedStrategy.ts @@ -1,36 +1,38 @@ import { normalizeStringValue, PaginationMode, - PaginationSchema, KeysetPagination, } from '@vulcan-sql/core'; -import { KoaRouterContext } from '@vulcan-sql/serve/route'; +import { KoaContext } from '@vulcan-sql/serve/models'; import { PaginationStrategy } from './strategy'; export class KeysetBasedStrategy extends PaginationStrategy { - private pagination: PaginationSchema; - constructor(pagination: PaginationSchema) { + private keyName?: string; + constructor(keyName?: string) { super(); - this.pagination = pagination; + this.keyName = keyName; } - public async transform(ctx: KoaRouterContext) { - if (!this.pagination.keyName) + public async transform(ctx: KoaContext) { + if (!this.keyName) throw new Error( `The keyset pagination need to set "keyName" in schema for indicate what key need to do filter.` ); - const { keyName } = this.pagination; - const checkFelidInQueryString = ['limit', keyName].every((field) => + const checkFelidInQueryString = ['limit', this.keyName].every((field) => Object.keys(ctx.request.query).includes(field) ); if (!checkFelidInQueryString) throw new Error( - `The ${PaginationMode.KEYSET} must provide limit and offset in query string.` + `The ${PaginationMode.KEYSET} must provide limit and key name in query string.` ); const limitVal = ctx.request.query['limit'] as string; - const keyNameVal = ctx.request.query[keyName] as string; + const keyNameVal = ctx.request.query[this.keyName] as string; return { limit: normalizeStringValue(limitVal, 'limit', Number.name), - [keyName]: normalizeStringValue(keyNameVal, keyName, String.name), + [this.keyName]: normalizeStringValue( + keyNameVal, + this.keyName, + String.name + ), } as KeysetPagination; } } diff --git a/packages/serve/src/lib/pagination/strategy/offsetBasedStrategy.ts b/packages/serve/src/lib/pagination/strategy/offsetBasedStrategy.ts index 997f8a3e..00d28486 100644 --- a/packages/serve/src/lib/pagination/strategy/offsetBasedStrategy.ts +++ b/packages/serve/src/lib/pagination/strategy/offsetBasedStrategy.ts @@ -1,4 +1,4 @@ -import { RouterContext as KoaRouterContext } from 'koa-router'; +import { KoaContext } from '@vulcan-sql/serve/models'; import { normalizeStringValue, PaginationMode, @@ -7,7 +7,7 @@ import { import { PaginationStrategy } from './strategy'; export class OffsetBasedStrategy extends PaginationStrategy { - public async transform(ctx: KoaRouterContext) { + public async transform(ctx: KoaContext) { const checkFelidInQueryString = ['limit', 'offset'].every((field) => Object.keys(ctx.request.query).includes(field) ); diff --git a/packages/serve/src/lib/pagination/strategy/strategy.ts b/packages/serve/src/lib/pagination/strategy/strategy.ts index 0aa372b3..ff177553 100644 --- a/packages/serve/src/lib/pagination/strategy/strategy.ts +++ b/packages/serve/src/lib/pagination/strategy/strategy.ts @@ -1,5 +1,5 @@ -import { KoaRouterContext } from '@vulcan-sql/serve/route'; +import { KoaContext } from '@vulcan-sql/serve/models'; export abstract class PaginationStrategy { - public abstract transform(ctx: KoaRouterContext): Promise; + public abstract transform(ctx: KoaContext): Promise; } diff --git a/packages/serve/src/lib/response-formatter/csvFormatter.ts b/packages/serve/src/lib/response-formatter/csvFormatter.ts index a0870c75..0b6454ef 100644 --- a/packages/serve/src/lib/response-formatter/csvFormatter.ts +++ b/packages/serve/src/lib/response-formatter/csvFormatter.ts @@ -6,7 +6,7 @@ import { VulcanInternalExtension, } from '@vulcan-sql/core'; import { isArray, isObject, isUndefined } from 'lodash'; -import { KoaRouterContext } from '../route'; +import { KoaContext } from '@vulcan-sql/serve/models'; import { BaseResponseFormatter, toBuffer, @@ -109,7 +109,7 @@ export class CsvFormatter extends BaseResponseFormatter { public toResponse( stream: Stream.Readable | Stream.Transform, - ctx: KoaRouterContext + ctx: KoaContext ) { // get file name by url path. e.g: url = '/urls/orders', result = orders const size = ctx.url.split('/').length; diff --git a/packages/serve/src/lib/response-formatter/jsonFormatter.ts b/packages/serve/src/lib/response-formatter/jsonFormatter.ts index 3f0209ea..e1ec09e2 100644 --- a/packages/serve/src/lib/response-formatter/jsonFormatter.ts +++ b/packages/serve/src/lib/response-formatter/jsonFormatter.ts @@ -9,7 +9,7 @@ import { toBuffer, } from '../../models/extensions/responseFormatter'; import { isUndefined } from 'lodash'; -import { KoaRouterContext } from '../route'; +import { KoaContext } from '@vulcan-sql/serve/models'; const logger = getLogger({ scopeName: 'SERVE' }); @@ -76,7 +76,7 @@ export class JsonFormatter extends BaseResponseFormatter { public toResponse( stream: Stream.Readable | Stream.Transform, - ctx: KoaRouterContext + ctx: KoaContext ) { // set json stream to response in context ( data is json stream, no need to convert. ) ctx.response.body = stream; diff --git a/packages/serve/src/lib/route/route-component/baseRoute.ts b/packages/serve/src/lib/route/route-component/baseRoute.ts index 88b35752..0e08932a 100644 --- a/packages/serve/src/lib/route/route-component/baseRoute.ts +++ b/packages/serve/src/lib/route/route-component/baseRoute.ts @@ -1,5 +1,4 @@ -import { Next as KoaNext } from 'koa'; -import { RouterContext as KoaRouterContext } from 'koa-router'; +import { AuthUserInfo, KoaContext } from '@vulcan-sql/serve/models'; import { APISchema, TemplateEngine, @@ -10,8 +9,6 @@ import { IRequestValidator } from './requestValidator'; import { IRequestTransformer, RequestParameters } from './requestTransformer'; import { IPaginationTransformer } from './paginationTransformer'; -export { KoaRouterContext, KoaNext }; - export interface TransformedRequest { reqParams: RequestParameters; pagination?: Pagination; @@ -27,7 +24,7 @@ export interface RouteOptions { } export interface IRoute { - respond(ctx: KoaRouterContext): Promise; + respond(ctx: KoaContext): Promise; } export abstract class BaseRoute implements IRoute { @@ -53,13 +50,11 @@ export abstract class BaseRoute implements IRoute { this.templateEngine = templateEngine; } - public abstract respond(ctx: KoaRouterContext): Promise; + public abstract respond(ctx: KoaContext): Promise; - protected abstract prepare( - ctx: KoaRouterContext - ): Promise; + protected abstract prepare(ctx: KoaContext): Promise; - protected async handle(transformed: TransformedRequest) { + protected async handle(user: AuthUserInfo, transformed: TransformedRequest) { const { reqParams } = transformed; // could template name or template path, use for template engine const { templateSource } = this.apiSchema; @@ -67,6 +62,7 @@ export abstract class BaseRoute implements IRoute { const prepared = await this.dataSource.prepare(reqParams); const result = await this.templateEngine.execute(templateSource, { + ['user']: user, ['_prepared']: prepared, }); return result; diff --git a/packages/serve/src/lib/route/route-component/graphQLRoute.ts b/packages/serve/src/lib/route/route-component/graphQLRoute.ts index bf5698e8..5f65a1bb 100644 --- a/packages/serve/src/lib/route/route-component/graphQLRoute.ts +++ b/packages/serve/src/lib/route/route-component/graphQLRoute.ts @@ -1,4 +1,5 @@ -import { BaseRoute, KoaRouterContext, RouteOptions } from './baseRoute'; +import { BaseRoute, RouteOptions } from './baseRoute'; +import { KoaContext } from '@vulcan-sql/serve/models'; export class GraphQLRoute extends BaseRoute { public readonly operationName: string; @@ -13,15 +14,16 @@ export class GraphQLRoute extends BaseRoute { // TODO: generate graphql type by api schema } - public async respond(ctx: KoaRouterContext) { + public async respond(ctx: KoaContext) { const transformed = await this.prepare(ctx); - await this.handle(transformed); + const authUser = ctx.state.user; + await this.handle(authUser, transformed); // TODO: get template engine handled result and return response by checking API schema return transformed; } // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected async prepare(_ctx: KoaRouterContext) { + protected async prepare(_ctx: KoaContext) { /** * TODO: the graphql need to transform from body. * Therefore, current request and pagination transformer not suitable (need to provide another graphql transform method or class) diff --git a/packages/serve/src/lib/route/route-component/paginationTransformer.ts b/packages/serve/src/lib/route/route-component/paginationTransformer.ts index 775fa3f2..3aa0ea1d 100644 --- a/packages/serve/src/lib/route/route-component/paginationTransformer.ts +++ b/packages/serve/src/lib/route/route-component/paginationTransformer.ts @@ -1,4 +1,4 @@ -import { KoaRouterContext } from '@vulcan-sql/serve/route'; +import { KoaContext } from '@vulcan-sql/serve/models'; import { APISchema, PaginationMode, Pagination } from '@vulcan-sql/core'; import { CursorBasedStrategy, @@ -9,14 +9,14 @@ import { injectable } from 'inversify'; export interface IPaginationTransformer { transform( - ctx: KoaRouterContext, + ctx: KoaContext, apiSchema: APISchema ): Promise; } @injectable() export class PaginationTransformer { - public async transform(ctx: KoaRouterContext, apiSchema: APISchema) { + public async transform(ctx: KoaContext, apiSchema: APISchema) { const { pagination } = apiSchema; if (pagination) { @@ -27,7 +27,7 @@ export class PaginationTransformer { const offset = new OffsetBasedStrategy(); const cursor = new CursorBasedStrategy(); - const keyset = new KeysetBasedStrategy(pagination); + const keyset = new KeysetBasedStrategy(pagination.keyName); const strategyMapper = { [PaginationMode.OFFSET]: offset.transform.bind(offset), [PaginationMode.CURSOR]: cursor.transform.bind(cursor), diff --git a/packages/serve/src/lib/route/route-component/requestTransformer.ts b/packages/serve/src/lib/route/route-component/requestTransformer.ts index 0c204780..b66ccdbe 100644 --- a/packages/serve/src/lib/route/route-component/requestTransformer.ts +++ b/packages/serve/src/lib/route/route-component/requestTransformer.ts @@ -7,29 +7,26 @@ import { } from '@vulcan-sql/core'; import { injectable } from 'inversify'; import { assign } from 'lodash'; -import { KoaRouterContext } from './baseRoute'; +import { KoaContext } from '@vulcan-sql/serve/models'; export interface RequestParameters { [name: string]: any; } export interface IRequestTransformer { - transform( - ctx: KoaRouterContext, - apiSchema: APISchema - ): Promise; + transform(ctx: KoaContext, apiSchema: APISchema): Promise; } @injectable() export class RequestTransformer implements IRequestTransformer { public static readonly fieldInMapper: { - [type in FieldInType]: (ctx: KoaRouterContext, fieldName: string) => string; + [type in FieldInType]: (ctx: KoaContext, fieldName: string) => string; } = { - [FieldInType.HEADER]: (ctx: KoaRouterContext, fieldName: string) => + [FieldInType.HEADER]: (ctx: KoaContext, fieldName: string) => ctx.request.header[fieldName] as string, - [FieldInType.QUERY]: (ctx: KoaRouterContext, fieldName: string) => + [FieldInType.QUERY]: (ctx: KoaContext, fieldName: string) => ctx.request.query[fieldName] as string, - [FieldInType.PATH]: (ctx: KoaRouterContext, fieldName: string) => + [FieldInType.PATH]: (ctx: KoaContext, fieldName: string) => ctx.params[fieldName] as string, }; @@ -45,7 +42,7 @@ export class RequestTransformer implements IRequestTransformer { }; public async transform( - ctx: KoaRouterContext, + ctx: KoaContext, apiSchema: APISchema ): Promise { const paramList = await Promise.all( diff --git a/packages/serve/src/lib/route/route-component/restfulRoute.ts b/packages/serve/src/lib/route/route-component/restfulRoute.ts index 0156fe5e..ea94b7c8 100644 --- a/packages/serve/src/lib/route/route-component/restfulRoute.ts +++ b/packages/serve/src/lib/route/route-component/restfulRoute.ts @@ -1,5 +1,5 @@ -import { BaseRoute, KoaRouterContext, RouteOptions } from './baseRoute'; - +import { BaseRoute, RouteOptions } from './baseRoute'; +import { KoaContext } from '@vulcan-sql/serve/models'; export class RestfulRoute extends BaseRoute { public readonly urlPath: string; @@ -9,16 +9,17 @@ export class RestfulRoute extends BaseRoute { this.urlPath = apiSchema.urlPath; } - public async respond(ctx: KoaRouterContext) { + public async respond(ctx: KoaContext) { const transformed = await this.prepare(ctx); - const result = await this.handle(transformed); + const authUser = ctx.state.user; + const result = await this.handle(authUser, transformed); ctx.response.body = { data: result.getData(), columns: result.getColumns(), }; } - protected async prepare(ctx: KoaRouterContext) { + protected async prepare(ctx: KoaContext) { // get request data from context const reqParams = await this.reqTransformer.transform(ctx, this.apiSchema); // validate request format diff --git a/packages/serve/src/models/appConfig.ts b/packages/serve/src/models/appConfig.ts deleted file mode 100644 index 38a47864..00000000 --- a/packages/serve/src/models/appConfig.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { ServeConfig } from './serveConfig'; - -export type AppConfig = Omit; diff --git a/packages/serve/src/models/context.ts b/packages/serve/src/models/context.ts new file mode 100644 index 00000000..6793fc6f --- /dev/null +++ b/packages/serve/src/models/context.ts @@ -0,0 +1,7 @@ +import { Next } from 'koa'; +import { RouterContext } from 'koa-router'; +import { AuthUserInfo } from './extensions/authenticator'; + +type KoaContext = RouterContext<{ user: AuthUserInfo }>; + +export { KoaContext, Next }; diff --git a/packages/serve/src/models/extensions/authenticator.ts b/packages/serve/src/models/extensions/authenticator.ts new file mode 100644 index 00000000..d85fec91 --- /dev/null +++ b/packages/serve/src/models/extensions/authenticator.ts @@ -0,0 +1,52 @@ +import { VulcanExtension, ExtensionBase } from '@vulcan-sql/core'; +import { KoaContext } from '@vulcan-sql/serve/models'; +import { TYPES } from '../../containers/types'; + +export interface AuthUserInfo { + name: string; + attr: { + [field: string]: string | boolean | number; + }; +} + +export enum AuthStatus { + /** + * SUCCESS: Request format correct and match the one of user credentials + * INDETERMINATE: Request format is unclear for authenticator needed, skip and check next authenticator + * FAIL: Request format correct, but not match the user credentials + */ + SUCCESS = 'SUCCESS', + FAIL = 'FAIL', + INDETERMINATE = 'INDETERMINATE', +} +export interface AuthResult { + status: AuthStatus; + /* auth type */ + type: string; + /* auth result message */ + message?: string; + user?: AuthUserInfo; + [key: string]: any; +} + +export interface IAuthenticator { + authenticate(context: KoaContext): Promise; +} + +@VulcanExtension(TYPES.Extension_Authenticator, { enforcedId: true }) +export abstract class BaseAuthenticator + extends ExtensionBase + implements IAuthenticator +{ + public abstract authenticate(context: KoaContext): Promise; + + protected getOptions(): AuthTypeOption | undefined { + if (!this.getConfig()) return undefined; + if (!this.getConfig()['options']) return undefined; + const options = this.getConfig()['options'][ + this.getExtensionId()! + ] as AuthTypeOption; + + return options; + } +} diff --git a/packages/serve/src/models/extensions/index.ts b/packages/serve/src/models/extensions/index.ts index 00c2d76a..1ebacdf1 100644 --- a/packages/serve/src/models/extensions/index.ts +++ b/packages/serve/src/models/extensions/index.ts @@ -1,2 +1,3 @@ export * from './routeMiddleware'; export * from './responseFormatter'; +export * from './authenticator'; diff --git a/packages/serve/src/models/extensions/responseFormatter.ts b/packages/serve/src/models/extensions/responseFormatter.ts index 7aa49143..72a0d672 100644 --- a/packages/serve/src/models/extensions/responseFormatter.ts +++ b/packages/serve/src/models/extensions/responseFormatter.ts @@ -2,7 +2,7 @@ import { DataColumn, ExtensionBase, VulcanExtension } from '@vulcan-sql/core'; import { has } from 'lodash'; import * as Stream from 'stream'; import { TYPES } from '../../containers/types'; -import { KoaRouterContext } from '../../lib/route'; +import { KoaContext } from '@vulcan-sql/serve/models'; export type BodyResponse = { data: Stream.Readable; @@ -20,11 +20,8 @@ export interface IFormatter { columns?: DataColumn[] ): Stream.Readable | Stream.Transform; - toResponse( - stream: Stream.Readable | Stream.Transform, - ctx: KoaRouterContext - ): void; - formatToResponse(ctx: KoaRouterContext): void; + toResponse(stream: Stream.Readable | Stream.Transform, ctx: KoaContext): void; + formatToResponse(ctx: KoaContext): void; } @VulcanExtension(TYPES.Extension_Formatter) @@ -32,7 +29,7 @@ export abstract class BaseResponseFormatter extends ExtensionBase implements IFormatter { - public formatToResponse(ctx: KoaRouterContext) { + public formatToResponse(ctx: KoaContext) { // return empty csv stream data or column is not exist if (!has(ctx.response.body, 'data') || !has(ctx.response.body, 'columns')) { const stream = new Stream.Readable(); @@ -65,6 +62,6 @@ export abstract class BaseResponseFormatter */ public abstract toResponse( stream: Stream.Readable | Stream.Transform, - ctx: KoaRouterContext + ctx: KoaContext ): void; } diff --git a/packages/serve/src/models/extensions/routeMiddleware.ts b/packages/serve/src/models/extensions/routeMiddleware.ts index e7cfa03f..252c72fa 100644 --- a/packages/serve/src/models/extensions/routeMiddleware.ts +++ b/packages/serve/src/models/extensions/routeMiddleware.ts @@ -1,5 +1,5 @@ import { ExtensionBase, VulcanExtension } from '@vulcan-sql/core'; -import { KoaRouterContext, KoaNext } from '@vulcan-sql/serve/route'; +import { KoaContext, Next } from '@vulcan-sql/serve/models'; import { inject } from 'inversify'; import { isUndefined } from 'lodash'; import { TYPES } from '../../containers/types'; @@ -12,10 +12,7 @@ export interface BuiltInMiddlewareConfig