Skip to content

Commit a608f57

Browse files
authoredAug 31, 2022
Merge pull request #27 from Canner/feature/enforce-https
Feature: Add enforce HTTPS middleware
2 parents 658f0f5 + 1437c32 commit a608f57

File tree

12 files changed

+558
-30
lines changed

12 files changed

+558
-30
lines changed
 

‎package.json

+2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"koa-bodyparser": "^4.3.0",
2121
"koa-compose": "^4.1.0",
2222
"koa-router": "^10.1.1",
23+
"koa-sslify": "^5.0.0",
2324
"koa2-ratelimit": "^1.1.1",
2425
"lodash": "^4.17.21",
2526
"md5": "^2.3.0",
@@ -49,6 +50,7 @@
4950
"@types/koa": "^2.13.4",
5051
"@types/koa-compose": "^3.2.5",
5152
"@types/koa-router": "^7.4.4",
53+
"@types/koa-sslify": "^4.0.3",
5254
"@types/koa2-ratelimit": "^0.9.3",
5355
"@types/koa__cors": "^3.3.0",
5456
"@types/lodash": "^4.14.182",

‎packages/integration-testing/src/example1/buildAndServe.spec.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ const projectConfig: ServeConfig & IBuildOptions = {
4444
'rate-limit': {
4545
options: { interval: { min: 1 }, max: 10000 },
4646
},
47+
'enforce-https': {
48+
enabled: false,
49+
},
4750
};
4851

4952
let server: VulcanServer;
@@ -56,7 +59,7 @@ it('Example1: Build and serve should work', async () => {
5659
const builder = new VulcanBuilder(projectConfig);
5760
await builder.build();
5861
server = new VulcanServer(projectConfig);
59-
const httpServer = await server.start(3000);
62+
const httpServer = (await server.start())['http'];
6063

6164
const agent = supertest(httpServer);
6265
const result = await agent.get(

‎packages/serve/src/containers/types.ts

-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ export const TYPES = {
77
RouteGenerator: Symbol.for('RouteGenerator'),
88

99
// Application
10-
AppConfig: Symbol.for('AppConfig'),
1110
VulcanApplication: Symbol.for('VulcanApplication'),
1211
// Extensions
1312
Extension_RouteMiddleware: Symbol.for('Extension_RouteMiddleware'),

‎packages/serve/src/lib/app.ts

+4-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { APISchema } from '@vulcan-sql/core';
22
import * as Koa from 'koa';
33
import * as KoaRouter from 'koa-router';
4-
import { isEmpty, uniq } from 'lodash';
4+
import { uniq } from 'lodash';
55
import {
66
RestfulRoute,
77
BaseRoute,
@@ -42,7 +42,7 @@ export class VulcanApplication {
4242
}
4343
public async buildRoutes(
4444
schemas: Array<APISchema>,
45-
apiTypes: Array<APIProviderType>
45+
apiTypes?: Array<APIProviderType>
4646
) {
4747
// setup API route according to api types and api schemas
4848
const routeMapper = {
@@ -52,9 +52,8 @@ export class VulcanApplication {
5252
this.setGraphQL(routes as Array<GraphQLRoute>),
5353
};
5454

55-
// check existed at least one type
56-
const types = uniq(apiTypes);
57-
if (isEmpty(types)) throw new Error(`The API type must provided.`);
55+
// check existed at least one type, if not provide, default is restful
56+
const types = uniq(apiTypes || [APIProviderType.RESTFUL]);
5857

5958
for (const type of types) {
6059
const routes = await this.generator.multiGenerate(schemas, type);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { isUndefined, omit } from 'lodash';
2+
import { inject } from 'inversify';
3+
import { TYPES as CORE_TYPES } from '@vulcan-sql/core';
4+
import { VulcanInternalExtension } from '@vulcan-sql/core';
5+
import { BuiltInMiddleware } from '@vulcan-sql/serve/models';
6+
import { KoaContext, Next } from '@vulcan-sql/serve/models';
7+
import {
8+
Options as SslOptions,
9+
httpsResolver,
10+
xForwardedProtoResolver,
11+
customProtoHeaderResolver,
12+
azureResolver,
13+
forwardedResolver,
14+
} from 'koa-sslify';
15+
import sslify from 'koa-sslify';
16+
17+
// resolver type for sslify options
18+
export enum ResolverType {
19+
/* use local server to run https server, suit for local usage. */
20+
LOCAL = 'LOCAL',
21+
/*
22+
* RFC standard header (RFC7239) to carry information in a organized way for reverse proxy used.
23+
* However, currently only little reverse proxies support it. e.g: nginx supported.
24+
* refer: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded
25+
* refer: https://www.nginx.com/resources/wiki/start/topics/examples/forwarded/
26+
*/
27+
FORWARDED = 'FORWARDED',
28+
/*
29+
* X-Forwarded-Proto header flag is one of the de-facto standard (But not RFC standard) to check and enforce https or not, almost reverse proxies supported.
30+
* e.g: Heroku, GKE ingress, AWS ELB, nginx.
31+
*/
32+
X_FORWARDED_PROTO = 'X_FORWARDED_PROTO',
33+
/*
34+
* if use Azure Application Request Routing as reverse proxy, then it use X-ARR-SSL header flag to check and enforce https.
35+
* refer: https://abhimantiwari.github.io/blog/ARR/
36+
*/
37+
AZURE_ARR = 'AZURE_ARR',
38+
/* customize the header flag to check and enforce https, when use the type, need to define an custom header flag for checking and enforcing https */
39+
CUSTOM = 'CUSTOM',
40+
}
41+
42+
export type EnforceHttpsOptions = Omit<SslOptions, 'resolver'> & {
43+
type?: string;
44+
/* custom proto name when when type is CUSTOM */
45+
proto?: string;
46+
};
47+
48+
export interface EnforceHttpsConfig {
49+
enabled: boolean;
50+
options: EnforceHttpsOptions;
51+
}
52+
53+
// enforce https middleware
54+
@VulcanInternalExtension('enforce-https')
55+
export class EnforceHttpsMiddleware extends BuiltInMiddleware<EnforceHttpsOptions> {
56+
private koaEnforceHttps = sslify(
57+
// if not setup "enforce-https", default sslify is LOCAL type
58+
this.getOptions() ? this.transformOptions(this.getOptions()!) : undefined
59+
);
60+
61+
constructor(
62+
@inject(CORE_TYPES.ExtensionConfig) config: any,
63+
@inject(CORE_TYPES.ExtensionName) name: string
64+
) {
65+
super(config, name);
66+
const rawOptions = this.getOptions() as EnforceHttpsOptions;
67+
68+
const options = rawOptions ? this.transformOptions(rawOptions) : undefined;
69+
this.koaEnforceHttps = sslify(options);
70+
}
71+
72+
public async handle(context: KoaContext, next: Next) {
73+
if (!this.enabled) return next();
74+
else return this.koaEnforceHttps(context, next);
75+
}
76+
77+
private transformOptions(rawOptions: EnforceHttpsOptions) {
78+
// given default value if not exist.
79+
rawOptions.type = rawOptions.type || ResolverType.LOCAL;
80+
81+
// check incorrect type
82+
this.checkResolverType(rawOptions.type);
83+
const type = rawOptions.type.toUpperCase();
84+
85+
const resolverMapper = {
86+
[ResolverType.LOCAL.toString()]: () => httpsResolver,
87+
[ResolverType.FORWARDED.toString()]: () => forwardedResolver,
88+
[ResolverType.X_FORWARDED_PROTO.toString()]: () =>
89+
xForwardedProtoResolver,
90+
[ResolverType.AZURE_ARR.toString()]: () => azureResolver,
91+
};
92+
// if type is CUSTOM
93+
if (type === ResolverType.CUSTOM) {
94+
if (!rawOptions.proto)
95+
throw new Error(
96+
'The "CUSTOM" type need also provide "proto" in options.'
97+
);
98+
99+
return {
100+
resolver: customProtoHeaderResolver(rawOptions.proto),
101+
...omit(rawOptions, ['type', 'proto']),
102+
} as SslOptions;
103+
}
104+
// if not CUSTOM.
105+
return {
106+
resolver: resolverMapper[type](),
107+
...omit(rawOptions, ['type', 'proto']),
108+
} as SslOptions;
109+
}
110+
111+
private checkResolverType(type: string) {
112+
// check incorrect type
113+
if (!(type.toUpperCase() in ResolverType))
114+
throw new Error(
115+
`The type is incorrect, only support type in ${JSON.stringify(
116+
Object.keys(ResolverType)
117+
)}.`
118+
);
119+
}
120+
}
121+
122+
/**
123+
* Get enforce https options in config
124+
* @param options EnforceHttpsOptions
125+
* @returns beside you disabled it, or it return enforce https options when setup "enforce-https"( if not found options, default is LOCAL type ).
126+
*/
127+
export const getEnforceHttpsOptions = (options?: {
128+
enabled: boolean;
129+
options: EnforceHttpsOptions;
130+
}): {
131+
enabled: boolean;
132+
options: EnforceHttpsOptions;
133+
} => {
134+
// if not given "enforce-https" options, return default options
135+
if (!options)
136+
return {
137+
enabled: true,
138+
options: { type: ResolverType.LOCAL } as EnforceHttpsOptions,
139+
};
140+
141+
return {
142+
enabled: isUndefined(options['enabled']) ? true : false,
143+
options:
144+
options['options'] ||
145+
({ type: ResolverType.LOCAL } as EnforceHttpsOptions),
146+
};
147+
};

‎packages/serve/src/lib/middleware/index.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,24 @@ export * from './auditLogMiddleware';
44
export * from './rateLimitMiddleware';
55
export * from './authMiddleware';
66
export * from './response-format';
7+
export * from './enforceHttpsMiddleware';
78

89
import { CorsMiddleware } from './corsMiddleware';
910
import { AuthMiddleware } from './authMiddleware';
1011
import { RateLimitMiddleware } from './rateLimitMiddleware';
1112
import { RequestIdMiddleware } from './requestIdMiddleware';
1213
import { AuditLoggingMiddleware } from './auditLogMiddleware';
1314
import { ResponseFormatMiddleware } from './response-format';
15+
import { EnforceHttpsMiddleware } from './enforceHttpsMiddleware';
1416
import { ClassType, ExtensionBase } from '@vulcan-sql/core';
1517

1618
// The order is the middleware running order
1719
export const BuiltInRouteMiddlewares: ClassType<ExtensionBase>[] = [
1820
CorsMiddleware,
19-
RateLimitMiddleware,
21+
EnforceHttpsMiddleware,
2022
RequestIdMiddleware,
2123
AuditLoggingMiddleware,
24+
RateLimitMiddleware,
2225
AuthMiddleware,
2326
ResponseFormatMiddleware,
2427
];

‎packages/serve/src/lib/server.ts

+81-13
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,40 @@
1+
import { isEmpty } from 'lodash';
2+
import * as fs from 'fs';
3+
import * as http from 'http';
4+
import * as https from 'https';
15
import {
26
VulcanArtifactBuilder,
37
TYPES as CORE_TYPES,
48
CodeLoader,
59
} from '@vulcan-sql/core';
6-
import * as http from 'http';
710
import { Container, TYPES } from '../containers';
8-
import { ServeConfig } from '../models';
11+
import { ServeConfig, sslFileOptions } from '../models';
912
import { VulcanApplication } from './app';
13+
import {
14+
EnforceHttpsOptions,
15+
getEnforceHttpsOptions,
16+
ResolverType,
17+
} from './middleware';
1018
export class VulcanServer {
1119
private config: ServeConfig;
1220
private container: Container;
13-
private server?: http.Server;
14-
21+
private servers?: {
22+
http: http.Server;
23+
https?: https.Server;
24+
};
1525
constructor(config: ServeConfig) {
1626
this.config = config;
1727
this.container = new Container();
1828
}
19-
20-
public async start(port = 3000) {
21-
if (this.server)
29+
/**
30+
* Start the vulcan server. default http port is 3000, you could also change it by setting "port" under config.
31+
*
32+
* When you enabled "enforce-https" options and add "ssl" options in the config, it will run the https server too locally (default "type" = LOCAL under "enforce-https" options).
33+
*
34+
* If you don't set the "port" under "enforce-https" options, it will use the default 3001 as https port.
35+
*/
36+
public async start() {
37+
if (!isEmpty(this.servers))
2238
throw new Error('Server has created, please close it first.');
2339

2440
// Load container
@@ -42,15 +58,67 @@ export class VulcanServer {
4258
// Create application
4359
const app = this.container.get<VulcanApplication>(TYPES.VulcanApplication);
4460
await app.useMiddleware();
45-
await app.buildRoutes(schemas, this.config.types);
61+
await app.buildRoutes(schemas, this.config['types']);
4662
// Run server
47-
const server = http.createServer(app.getHandler()).listen(port);
48-
this.server = server;
49-
return server;
63+
this.servers = this.runServer(app);
64+
return this.servers;
5065
}
51-
5266
public async close() {
53-
if (this.server) this.server.close();
67+
if (this.servers) {
68+
if (this.servers['http']) this.servers['http'].close();
69+
if (this.servers['https']) this.servers['https'].close();
70+
this.servers = undefined;
71+
}
5472
this.container.unload();
5573
}
74+
75+
/**
76+
* Run server
77+
* for https when config has setup ssl and middleware 'enforce-https' enabled with "LOCAL" type, or keep http
78+
*/
79+
private runServer(app: VulcanApplication) {
80+
const { enabled, options } = getEnforceHttpsOptions(
81+
this.config['enforce-https']
82+
);
83+
84+
const httpPort = this.config['port'] || 3000;
85+
const httpServer = http.createServer(app.getHandler()).listen(httpPort);
86+
87+
if (enabled && options['type'] === ResolverType.LOCAL) {
88+
const httpsServer = this.createHttpsServer(
89+
app,
90+
options,
91+
this.config['ssl']!
92+
);
93+
return { http: httpServer, https: httpsServer };
94+
}
95+
96+
return { http: httpServer };
97+
}
98+
99+
private createHttpsServer(
100+
app: VulcanApplication,
101+
options: EnforceHttpsOptions,
102+
ssl: sslFileOptions
103+
) {
104+
// check ssl file
105+
if (!fs.existsSync(ssl.key) || !fs.existsSync(ssl.cert))
106+
throw new Error(
107+
'Must need key and cert file at least when open https server.'
108+
);
109+
110+
// create https server
111+
const httpsPort = options['port'] || 3001;
112+
return https
113+
.createServer(
114+
{
115+
key: fs.readFileSync(ssl.key),
116+
cert: fs.readFileSync(ssl.cert),
117+
// if ca not exist, set undefined
118+
ca: fs.existsSync(ssl.ca) ? fs.readFileSync(ssl.ca) : undefined,
119+
},
120+
app.getHandler()
121+
)
122+
.listen(httpsPort);
123+
}
56124
}

‎packages/serve/src/models/serveOptions.ts

+14-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
import { ICoreOptions } from '@vulcan-sql/core';
22
import { APIProviderType } from '@vulcan-sql/serve/route';
33

4+
export interface sslFileOptions {
5+
/* key file path */
6+
key: string;
7+
/* certificate file path */
8+
cert: string;
9+
/** certificate bundle */
10+
ca: string;
11+
}
12+
413
// The serve package config
514
export interface ServeConfig extends ICoreOptions {
15+
/* http port, if not setup, default is 3000 */
16+
['port']?: number;
617
/* The API types would like to build */
7-
['types']: Array<APIProviderType>;
18+
['types']?: Array<APIProviderType>;
19+
/** When 'enforce-https' is enabled and type is LOCAL in middleware, need the ssl key and cert*/
20+
['ssl']?: sslFileOptions;
821
}
9-
10-
export type AppConfig = Omit<ServeConfig, 'artifact' | 'template' | 'types'>;

0 commit comments

Comments
 (0)
Please sign in to comment.