From 5f8cd5e5c607a08efaf17d9248fab8ca146e393c Mon Sep 17 00:00:00 2001 From: Ankur Bansal <85155003+AnkurBansalSF@users.noreply.github.com> Date: Thu, 21 Apr 2022 16:59:36 +0530 Subject: [PATCH] feat(component): add a new strategy for otp (#67) * feat(component): add a new strategy for otp added a new 2-factor authentication strategy GH-69 * feat(component): add a new strategy for otp added a new 2-factor authentication strategy GH-69 * feat(component): add a new strategy for otp added a new 2-factor authentication strategy. gh-69 --- README.md | 193 ++++++++++++++++++ src/component.ts | 5 + src/strategies/keys.ts | 10 + src/strategies/passport/index.ts | 1 + src/strategies/passport/passport-otp/index.ts | 3 + .../passport/passport-otp/otp-auth.ts | 60 ++++++ .../otp-strategy-factory.provider.ts | 49 +++++ .../passport-otp/otp-verify.provider.ts | 16 ++ src/strategies/types/types.ts | 5 + src/strategies/user-auth-strategy.provider.ts | 9 + src/strategy-name.enum.ts | 1 + 11 files changed, 352 insertions(+) create mode 100644 src/strategies/passport/passport-otp/index.ts create mode 100644 src/strategies/passport/passport-otp/otp-auth.ts create mode 100644 src/strategies/passport/passport-otp/otp-strategy-factory.provider.ts create mode 100644 src/strategies/passport/passport-otp/otp-verify.provider.ts diff --git a/README.md b/README.md index 851e821..c8d2683 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ It provides support for seven passport based strategies. 7. [passport-instagram](https://github.com/jaredhanson/passport-instagram) - Passport strategy for authenticating with Instagram using the Instagram OAuth 2.0 API. This module lets you authenticate using Instagram in your Node.js applications. 8. [passport-apple](https://github.com/ananay/passport-apple) - Passport strategy for authenticating with Apple using the Apple OAuth 2.0 API. This module lets you authenticate using Apple in your Node.js applications. 9. [passport-facebook](https://github.com/jaredhanson/passport-facebook) - Passport strategy for authenticating with Facebook using the Facebook OAuth 2.0 API. This module lets you authenticate using Facebook in your Node.js applications. +10. custom-passport-otp - Created a Custom Passport strategy for 2-Factor-Authentication using OTP (One Time Password). You can use one or more strategies of the above in your application. For each of the strategy (only which you use), you just need to provide your own verifier function, making it easily configurable. Rest of the strategy implementation intricacies is handled by extension. @@ -793,6 +794,198 @@ For accessing the authenticated AuthUser and AuthClient model reference, you can private readonly getCurrentClient: Getter, ``` +### OTP + +First, create a OtpCache model. This model should have OTP and few details of user and client (which will be used to retrieve them from database), it will be used to verify otp and get user, client. See sample below. + +```ts +@model() +export class OtpCache extends Entity { + @property({ + type: 'string', + }) + otp: string; + + @property({ + type: 'string', + }) + userId: string; + + @property({ + type: 'string', + }) + clientId: string; + + @property({ + type: 'string', + }) + clientSecret: string; + + constructor(data?: Partial) { + super(data); + } +} +``` + +Create [redis-repository](https://loopback.io/doc/en/lb4/Repository.html#define-a-keyvaluerepository) for the above model. Use loopback CLI. + +```sh +lb4 repository +``` + +Here is a simple example. + +```ts +import {OtpCache} from '../models'; +import {AuthCacheSourceName} from 'loopback4-authentication'; + +export class OtpCacheRepository extends DefaultKeyValueRepository { + constructor( + @inject(`datasources.${AuthCacheSourceName}`) + dataSource: juggler.DataSource, + ) { + super(OtpCache, dataSource); + } +} +``` + +Add the verifier function for the strategy. You need to create a provider for the same. You can add your application specific business logic for auth here. Here is a simple example. + +```ts +export class OtpVerifyProvider implements Provider { + constructor( + @repository(UserRepository) + public userRepository: UserRepository, + @repository(OtpCacheRepository) + public otpCacheRepo: OtpCacheRepository, + ) {} + + value(): VerifyFunction.OtpAuthFn { + return async (key: string, otp: string) => { + const otpCache = await this.otpCacheRepo.get(key); + if (!otpCache) { + throw new HttpErrors.Unauthorized(AuthErrorKeys.InvalidCredentials); + } + if (otpCache.otp.toString() !== otp) { + throw new HttpErrors.Unauthorized('Invalid OTP'); + } + return this.userRepository.findById(otpCache.userId); + }; + } +} +``` + +Please note the Verify function type _VerifyFunction.OtpAuthFn_ + +Now bind this provider to the application in application.ts. + +```ts +import {AuthenticationComponent, Strategies} from 'loopback4-authentication'; +``` + +```ts +// Add authentication component +this.component(AuthenticationComponent); +// Customize authentication verify handlers +this.bind(Strategies.Passport.OTP_VERIFIER).toProvider(OtpVerifyProvider); +``` + +Finally, add the authenticate function as a sequence action to sequence.ts. + +```ts +export class MySequence implements SequenceHandler { + constructor( + @inject(SequenceActions.FIND_ROUTE) protected findRoute: FindRoute, + @inject(SequenceActions.PARSE_PARAMS) protected parseParams: ParseParams, + @inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod, + @inject(SequenceActions.SEND) public send: Send, + @inject(SequenceActions.REJECT) public reject: Reject, + @inject(AuthenticationBindings.USER_AUTH_ACTION) + protected authenticateRequest: AuthenticateFn, + ) {} + + async handle(context: RequestContext) { + try { + const {request, response} = context; + + const route = this.findRoute(request); + const args = await this.parseParams(request, route); + request.body = args[args.length - 1]; + const authUser: AuthUser = await this.authenticateRequest(request); + const result = await this.invoke(route, args); + this.send(response, result); + } catch (err) { + this.reject(context, err); + } + } +} +``` + +Then, you need to create APIs, where you will first authenticate the user, and then send the OTP to user's email/phone. See below. + +```ts + //You can use your other strategies also + @authenticate(STRATEGY.LOCAL) + @post('/auth/send-otp', { + responses: { + [STATUS_CODE.OK]: { + description: 'Send Otp', + content: { + [CONTENT_TYPE.JSON]: Object, + }, + }, + }, + }) + async login( + @requestBody() + req: LoginRequest, + ): Promise<{ + key: string; + }> { + + // User is authenticated before this step. + // Now follow these steps: + // 1. Create a unique key. + // 2. Generate and send OTP to user's email/phone. + // 3. Store the details in redis-cache using key created in step-1. (Refer OtpCache model mentioned above) + // 4. Response will be the key created in step-1 + } +``` + +After this, create an API with @@authenticate(STRATEGY.OTP) decorator. See below. + +```ts + @authenticate(STRATEGY.OTP) + @post('/auth/login-otp', { + responses: { + [STATUS_CODE.OK]: { + description: 'Auth Code', + content: { + [CONTENT_TYPE.JSON]: Object, + }, + }, + }, + }) + async login( + @requestBody() + req: { + key: 'string'; + otp: 'string'; + }, + ): Promise<{ + code: string; + }> { + ...... + } +``` + +For accessing the authenticated AuthUser model reference, you can inject the CURRENT_USER provider, provided by the extension, which is populated by the auth action sequence above. + +```ts + @inject.getter(AuthenticationBindings.CURRENT_USER) + private readonly getCurrentUser: Getter, +``` + ### Google Oauth 2 First, create a AuthUser model implementing the IAuthUser interface. You can implement the interface in the user model itself. See sample below. diff --git a/src/component.ts b/src/component.ts index 7c52326..ce4d283 100644 --- a/src/component.ts +++ b/src/component.ts @@ -30,6 +30,8 @@ import { LocalPasswordVerifyProvider, ResourceOwnerPasswordStrategyFactoryProvider, ResourceOwnerVerifyProvider, + PassportOtpStrategyFactoryProvider, + OtpVerifyProvider, } from './strategies'; import {Strategies} from './strategies/keys'; @@ -47,6 +49,8 @@ export class AuthenticationComponent implements Component { // Strategy function factories [Strategies.Passport.LOCAL_STRATEGY_FACTORY.key]: LocalPasswordStrategyFactoryProvider, + [Strategies.Passport.OTP_AUTH_STRATEGY_FACTORY.key]: + PassportOtpStrategyFactoryProvider, [Strategies.Passport.CLIENT_PASSWORD_STRATEGY_FACTORY.key]: ClientPasswordStrategyFactoryProvider, [Strategies.Passport.BEARER_STRATEGY_FACTORY.key]: @@ -71,6 +75,7 @@ export class AuthenticationComponent implements Component { ClientPasswordVerifyProvider, [Strategies.Passport.LOCAL_PASSWORD_VERIFIER.key]: LocalPasswordVerifyProvider, + [Strategies.Passport.OTP_VERIFIER.key]: OtpVerifyProvider, [Strategies.Passport.BEARER_TOKEN_VERIFIER.key]: BearerTokenVerifyProvider, [Strategies.Passport.RESOURCE_OWNER_PASSWORD_VERIFIER.key]: diff --git a/src/strategies/keys.ts b/src/strategies/keys.ts index d7a8030..2d12891 100644 --- a/src/strategies/keys.ts +++ b/src/strategies/keys.ts @@ -23,6 +23,16 @@ export namespace Strategies { 'sf.passport.verifier.localPassword', ); + // Passport-local-with-otp startegy + export const OTP_AUTH_STRATEGY_FACTORY = + BindingKey.create( + 'sf.passport.strategyFactory.otpAuth', + ); + export const OTP_VERIFIER = + BindingKey.create( + 'sf.passport.verifier.otpAuth', + ); + // Passport-oauth2-client-password strategy export const CLIENT_PASSWORD_STRATEGY_FACTORY = BindingKey.create( diff --git a/src/strategies/passport/index.ts b/src/strategies/passport/index.ts index b5a444b..95e8c07 100644 --- a/src/strategies/passport/index.ts +++ b/src/strategies/passport/index.ts @@ -8,3 +8,4 @@ export * from './passport-azure-ad'; export * from './passport-insta-oauth2'; export * from './passport-apple-oauth2'; export * from './passport-facebook-oauth2'; +export * from './passport-otp'; diff --git a/src/strategies/passport/passport-otp/index.ts b/src/strategies/passport/passport-otp/index.ts new file mode 100644 index 0000000..06a59eb --- /dev/null +++ b/src/strategies/passport/passport-otp/index.ts @@ -0,0 +1,3 @@ +export * from './otp-auth'; +export * from './otp-strategy-factory.provider'; +export * from './otp-verify.provider'; diff --git a/src/strategies/passport/passport-otp/otp-auth.ts b/src/strategies/passport/passport-otp/otp-auth.ts new file mode 100644 index 0000000..594366a --- /dev/null +++ b/src/strategies/passport/passport-otp/otp-auth.ts @@ -0,0 +1,60 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import * as passport from 'passport'; + +export namespace Otp { + export interface VerifyFunction { + ( + key: string, + otp: string, + done: (error: any, user?: any, info?: any) => void, + ): void; + } + + export interface StrategyOptions { + key?: string; + otp?: string; + } + + export type VerifyCallback = ( + err?: string | Error | null, + user?: any, + info?: any, + ) => void; + + export class Strategy extends passport.Strategy { + constructor(_options?: StrategyOptions, verify?: VerifyFunction) { + super(); + this.name = 'otp'; + if (verify) { + this.verify = verify; + } + } + + name: string; + private readonly verify: VerifyFunction; + + authenticate(req: any, options?: StrategyOptions): void { + const key = req.body.key || options?.key; + const otp = req.body.otp || options?.otp; + + if (!key || !otp) { + this.fail(); + return; + } + + const verified = (err?: any, user?: any, _info?: any) => { + if (err) { + this.error(err); + return; + } + if (!user) { + this.fail(); + return; + } + this.success(user); + }; + + this.verify(key, otp, verified); + } + } +} diff --git a/src/strategies/passport/passport-otp/otp-strategy-factory.provider.ts b/src/strategies/passport/passport-otp/otp-strategy-factory.provider.ts new file mode 100644 index 0000000..f37b1a4 --- /dev/null +++ b/src/strategies/passport/passport-otp/otp-strategy-factory.provider.ts @@ -0,0 +1,49 @@ +import {inject, Provider} from '@loopback/core'; +import {HttpErrors} from '@loopback/rest'; +import {AuthErrorKeys} from '../../../error-keys'; +import {Strategies} from '../../keys'; +import {VerifyFunction} from '../../types'; +import {Otp} from './otp-auth'; + +export interface PassportOtpStrategyFactory { + ( + options: Otp.StrategyOptions, + verifierPassed?: VerifyFunction.OtpAuthFn, + ): Otp.Strategy; +} + +export class PassportOtpStrategyFactoryProvider + implements Provider +{ + constructor( + @inject(Strategies.Passport.OTP_VERIFIER) + private readonly verifierOtp: VerifyFunction.OtpAuthFn, + ) {} + + value(): PassportOtpStrategyFactory { + return (options, verifier) => + this.getPassportOtpStrategyVerifier(options, verifier); + } + + getPassportOtpStrategyVerifier( + options?: Otp.StrategyOptions, + verifierPassed?: VerifyFunction.OtpAuthFn, + ): Otp.Strategy { + const verifyFn = verifierPassed ?? this.verifierOtp; + return new Otp.Strategy( + options, + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async (key: string, otp: string, cb: Otp.VerifyCallback) => { + try { + const user = await verifyFn(key, otp); + if (!user) { + throw new HttpErrors.Unauthorized(AuthErrorKeys.InvalidCredentials); + } + cb(null, user); + } catch (err) { + cb(err); + } + }, + ); + } +} diff --git a/src/strategies/passport/passport-otp/otp-verify.provider.ts b/src/strategies/passport/passport-otp/otp-verify.provider.ts new file mode 100644 index 0000000..2a379b9 --- /dev/null +++ b/src/strategies/passport/passport-otp/otp-verify.provider.ts @@ -0,0 +1,16 @@ +import {Provider} from '@loopback/context'; +import {HttpErrors} from '@loopback/rest'; + +import {VerifyFunction} from '../../types'; + +export class OtpVerifyProvider implements Provider { + constructor() {} + + value(): VerifyFunction.OtpAuthFn { + return async (_key: string, _otp: string) => { + throw new HttpErrors.NotImplemented( + `VerifyFunction.OtpAuthFn is not implemented`, + ); + }; + } +} diff --git a/src/strategies/types/types.ts b/src/strategies/types/types.ts index 67df819..243208b 100644 --- a/src/strategies/types/types.ts +++ b/src/strategies/types/types.ts @@ -7,6 +7,7 @@ import * as AppleStrategy from 'passport-apple'; import {DecodedIdToken} from 'passport-apple'; import {IAuthClient, IAuthUser} from '../../types'; import {Keycloak} from './keycloak.types'; +import {Otp} from '../passport'; export type VerifyCallback = ( err?: string | Error | null, @@ -25,6 +26,10 @@ export namespace VerifyFunction { (username: string, password: string, req?: Request): Promise; } + export interface OtpAuthFn extends GenericAuthFn { + (key: string, otp: string, cb: Otp.VerifyCallback): Promise; + } + export interface BearerFn extends GenericAuthFn { (token: string, req?: Request): Promise; } diff --git a/src/strategies/user-auth-strategy.provider.ts b/src/strategies/user-auth-strategy.provider.ts index 4d7cd93..9063f0a 100644 --- a/src/strategies/user-auth-strategy.provider.ts +++ b/src/strategies/user-auth-strategy.provider.ts @@ -25,6 +25,8 @@ import { InstagramAuthStrategyFactory, KeycloakStrategyFactory, FacebookAuthStrategyFactory, + PassportOtpStrategyFactory, + Otp, } from './passport'; import {Keycloak, VerifyFunction} from './types'; @@ -38,6 +40,8 @@ export class AuthStrategyProvider implements Provider { private readonly metadata: AuthenticationMetadata, @inject(Strategies.Passport.LOCAL_STRATEGY_FACTORY) private readonly getLocalStrategyVerifier: LocalPasswordStrategyFactory, + @inject(Strategies.Passport.OTP_AUTH_STRATEGY_FACTORY) + private readonly getOtpVerifier: PassportOtpStrategyFactory, @inject(Strategies.Passport.BEARER_STRATEGY_FACTORY) private readonly getBearerStrategyVerifier: BearerStrategyFactory, @inject(Strategies.Passport.RESOURCE_OWNER_STRATEGY_FACTORY) @@ -129,6 +133,11 @@ export class AuthStrategyProvider implements Provider { | ExtendedStrategyOption, verifier as VerifyFunction.FacebookAuthFn, ); + } else if (name === STRATEGY.OTP) { + return this.getOtpVerifier( + this.metadata.options as Otp.StrategyOptions, + verifier as VerifyFunction.OtpAuthFn, + ); } else { return Promise.reject(`The strategy ${name} is not available.`); } diff --git a/src/strategy-name.enum.ts b/src/strategy-name.enum.ts index 9fdb2c3..b411589 100644 --- a/src/strategy-name.enum.ts +++ b/src/strategy-name.enum.ts @@ -9,4 +9,5 @@ export const enum STRATEGY { FACEBOOK_OAUTH2 = 'Facebook Oauth 2.0', AZURE_AD = 'Azure AD', KEYCLOAK = 'keycloak', + OTP = 'otp', }