Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Add Auth middleware and Authenticator. #26

Merged
merged 8 commits into from
Aug 30, 2022
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion packages/serve/src/containers/modules/extension.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -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);
});
2 changes: 2 additions & 0 deletions packages/serve/src/containers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
};
3 changes: 3 additions & 0 deletions packages/serve/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
1 change: 1 addition & 0 deletions packages/serve/src/lib/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
Expand Down
143 changes: 143 additions & 0 deletions packages/serve/src/lib/auth/httpBasicAuthenticator.ts
Original file line number Diff line number Diff line change
@@ -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<AuthUserOptions>;
}

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<AuthUserListOptions>;
}

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<BasicOptions> {
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;
}
}
13 changes: 13 additions & 0 deletions packages/serve/src/lib/auth/index.ts
Original file line number Diff line number Diff line change
@@ -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,
];
122 changes: 122 additions & 0 deletions packages/serve/src/lib/auth/passwordFileAuthenticator.ts
Original file line number Diff line number Diff line change
@@ -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<PasswordFileUserOptions>;
}

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<PasswordFileOptions> {
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),
});
// <username>:<bcrypt-password>
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;
}
}
Loading