Skip to content

Commit 5f8cd5e

Browse files
authoredApr 21, 2022
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
1 parent b7bd5b5 commit 5f8cd5e

11 files changed

+352
-0
lines changed
 

‎README.md

+193
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ It provides support for seven passport based strategies.
2525
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.
2626
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.
2727
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.
28+
10. custom-passport-otp - Created a Custom Passport strategy for 2-Factor-Authentication using OTP (One Time Password).
2829

2930
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.
3031

@@ -793,6 +794,198 @@ For accessing the authenticated AuthUser and AuthClient model reference, you can
793794
private readonly getCurrentClient: Getter<AuthClient>,
794795
```
795796

797+
### OTP
798+
799+
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.
800+
801+
```ts
802+
@model()
803+
export class OtpCache extends Entity {
804+
@property({
805+
type: 'string',
806+
})
807+
otp: string;
808+
809+
@property({
810+
type: 'string',
811+
})
812+
userId: string;
813+
814+
@property({
815+
type: 'string',
816+
})
817+
clientId: string;
818+
819+
@property({
820+
type: 'string',
821+
})
822+
clientSecret: string;
823+
824+
constructor(data?: Partial<OtpCache>) {
825+
super(data);
826+
}
827+
}
828+
```
829+
830+
Create [redis-repository](https://loopback.io/doc/en/lb4/Repository.html#define-a-keyvaluerepository) for the above model. Use loopback CLI.
831+
832+
```sh
833+
lb4 repository
834+
```
835+
836+
Here is a simple example.
837+
838+
```ts
839+
import {OtpCache} from '../models';
840+
import {AuthCacheSourceName} from 'loopback4-authentication';
841+
842+
export class OtpCacheRepository extends DefaultKeyValueRepository<OtpCache> {
843+
constructor(
844+
@inject(`datasources.${AuthCacheSourceName}`)
845+
dataSource: juggler.DataSource,
846+
) {
847+
super(OtpCache, dataSource);
848+
}
849+
}
850+
```
851+
852+
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.
853+
854+
```ts
855+
export class OtpVerifyProvider implements Provider<VerifyFunction.OtpAuthFn> {
856+
constructor(
857+
@repository(UserRepository)
858+
public userRepository: UserRepository,
859+
@repository(OtpCacheRepository)
860+
public otpCacheRepo: OtpCacheRepository,
861+
) {}
862+
863+
value(): VerifyFunction.OtpAuthFn {
864+
return async (key: string, otp: string) => {
865+
const otpCache = await this.otpCacheRepo.get(key);
866+
if (!otpCache) {
867+
throw new HttpErrors.Unauthorized(AuthErrorKeys.InvalidCredentials);
868+
}
869+
if (otpCache.otp.toString() !== otp) {
870+
throw new HttpErrors.Unauthorized('Invalid OTP');
871+
}
872+
return this.userRepository.findById(otpCache.userId);
873+
};
874+
}
875+
}
876+
```
877+
878+
Please note the Verify function type _VerifyFunction.OtpAuthFn_
879+
880+
Now bind this provider to the application in application.ts.
881+
882+
```ts
883+
import {AuthenticationComponent, Strategies} from 'loopback4-authentication';
884+
```
885+
886+
```ts
887+
// Add authentication component
888+
this.component(AuthenticationComponent);
889+
// Customize authentication verify handlers
890+
this.bind(Strategies.Passport.OTP_VERIFIER).toProvider(OtpVerifyProvider);
891+
```
892+
893+
Finally, add the authenticate function as a sequence action to sequence.ts.
894+
895+
```ts
896+
export class MySequence implements SequenceHandler {
897+
constructor(
898+
@inject(SequenceActions.FIND_ROUTE) protected findRoute: FindRoute,
899+
@inject(SequenceActions.PARSE_PARAMS) protected parseParams: ParseParams,
900+
@inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod,
901+
@inject(SequenceActions.SEND) public send: Send,
902+
@inject(SequenceActions.REJECT) public reject: Reject,
903+
@inject(AuthenticationBindings.USER_AUTH_ACTION)
904+
protected authenticateRequest: AuthenticateFn<AuthUser>,
905+
) {}
906+
907+
async handle(context: RequestContext) {
908+
try {
909+
const {request, response} = context;
910+
911+
const route = this.findRoute(request);
912+
const args = await this.parseParams(request, route);
913+
request.body = args[args.length - 1];
914+
const authUser: AuthUser = await this.authenticateRequest(request);
915+
const result = await this.invoke(route, args);
916+
this.send(response, result);
917+
} catch (err) {
918+
this.reject(context, err);
919+
}
920+
}
921+
}
922+
```
923+
924+
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.
925+
926+
```ts
927+
//You can use your other strategies also
928+
@authenticate(STRATEGY.LOCAL)
929+
@post('/auth/send-otp', {
930+
responses: {
931+
[STATUS_CODE.OK]: {
932+
description: 'Send Otp',
933+
content: {
934+
[CONTENT_TYPE.JSON]: Object,
935+
},
936+
},
937+
},
938+
})
939+
async login(
940+
@requestBody()
941+
req: LoginRequest,
942+
): Promise<{
943+
key: string;
944+
}> {
945+
946+
// User is authenticated before this step.
947+
// Now follow these steps:
948+
// 1. Create a unique key.
949+
// 2. Generate and send OTP to user's email/phone.
950+
// 3. Store the details in redis-cache using key created in step-1. (Refer OtpCache model mentioned above)
951+
// 4. Response will be the key created in step-1
952+
}
953+
```
954+
955+
After this, create an API with @@authenticate(STRATEGY.OTP) decorator. See below.
956+
957+
```ts
958+
@authenticate(STRATEGY.OTP)
959+
@post('/auth/login-otp', {
960+
responses: {
961+
[STATUS_CODE.OK]: {
962+
description: 'Auth Code',
963+
content: {
964+
[CONTENT_TYPE.JSON]: Object,
965+
},
966+
},
967+
},
968+
})
969+
async login(
970+
@requestBody()
971+
req: {
972+
key: 'string';
973+
otp: 'string';
974+
},
975+
): Promise<{
976+
code: string;
977+
}> {
978+
......
979+
}
980+
```
981+
982+
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.
983+
984+
```ts
985+
@inject.getter(AuthenticationBindings.CURRENT_USER)
986+
private readonly getCurrentUser: Getter<User>,
987+
```
988+
796989
### Google Oauth 2
797990

798991
First, create a AuthUser model implementing the IAuthUser interface. You can implement the interface in the user model itself. See sample below.

‎src/component.ts

+5
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import {
3030
LocalPasswordVerifyProvider,
3131
ResourceOwnerPasswordStrategyFactoryProvider,
3232
ResourceOwnerVerifyProvider,
33+
PassportOtpStrategyFactoryProvider,
34+
OtpVerifyProvider,
3335
} from './strategies';
3436
import {Strategies} from './strategies/keys';
3537

@@ -47,6 +49,8 @@ export class AuthenticationComponent implements Component {
4749
// Strategy function factories
4850
[Strategies.Passport.LOCAL_STRATEGY_FACTORY.key]:
4951
LocalPasswordStrategyFactoryProvider,
52+
[Strategies.Passport.OTP_AUTH_STRATEGY_FACTORY.key]:
53+
PassportOtpStrategyFactoryProvider,
5054
[Strategies.Passport.CLIENT_PASSWORD_STRATEGY_FACTORY.key]:
5155
ClientPasswordStrategyFactoryProvider,
5256
[Strategies.Passport.BEARER_STRATEGY_FACTORY.key]:
@@ -71,6 +75,7 @@ export class AuthenticationComponent implements Component {
7175
ClientPasswordVerifyProvider,
7276
[Strategies.Passport.LOCAL_PASSWORD_VERIFIER.key]:
7377
LocalPasswordVerifyProvider,
78+
[Strategies.Passport.OTP_VERIFIER.key]: OtpVerifyProvider,
7479
[Strategies.Passport.BEARER_TOKEN_VERIFIER.key]:
7580
BearerTokenVerifyProvider,
7681
[Strategies.Passport.RESOURCE_OWNER_PASSWORD_VERIFIER.key]:

‎src/strategies/keys.ts

+10
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@ export namespace Strategies {
2323
'sf.passport.verifier.localPassword',
2424
);
2525

26+
// Passport-local-with-otp startegy
27+
export const OTP_AUTH_STRATEGY_FACTORY =
28+
BindingKey.create<LocalPasswordStrategyFactory>(
29+
'sf.passport.strategyFactory.otpAuth',
30+
);
31+
export const OTP_VERIFIER =
32+
BindingKey.create<VerifyFunction.LocalPasswordFn>(
33+
'sf.passport.verifier.otpAuth',
34+
);
35+
2636
// Passport-oauth2-client-password strategy
2737
export const CLIENT_PASSWORD_STRATEGY_FACTORY =
2838
BindingKey.create<ClientPasswordStrategyFactory>(

‎src/strategies/passport/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ export * from './passport-azure-ad';
88
export * from './passport-insta-oauth2';
99
export * from './passport-apple-oauth2';
1010
export * from './passport-facebook-oauth2';
11+
export * from './passport-otp';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './otp-auth';
2+
export * from './otp-strategy-factory.provider';
3+
export * from './otp-verify.provider';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import * as passport from 'passport';
3+
4+
export namespace Otp {
5+
export interface VerifyFunction {
6+
(
7+
key: string,
8+
otp: string,
9+
done: (error: any, user?: any, info?: any) => void,
10+
): void;
11+
}
12+
13+
export interface StrategyOptions {
14+
key?: string;
15+
otp?: string;
16+
}
17+
18+
export type VerifyCallback = (
19+
err?: string | Error | null,
20+
user?: any,
21+
info?: any,
22+
) => void;
23+
24+
export class Strategy extends passport.Strategy {
25+
constructor(_options?: StrategyOptions, verify?: VerifyFunction) {
26+
super();
27+
this.name = 'otp';
28+
if (verify) {
29+
this.verify = verify;
30+
}
31+
}
32+
33+
name: string;
34+
private readonly verify: VerifyFunction;
35+
36+
authenticate(req: any, options?: StrategyOptions): void {
37+
const key = req.body.key || options?.key;
38+
const otp = req.body.otp || options?.otp;
39+
40+
if (!key || !otp) {
41+
this.fail();
42+
return;
43+
}
44+
45+
const verified = (err?: any, user?: any, _info?: any) => {
46+
if (err) {
47+
this.error(err);
48+
return;
49+
}
50+
if (!user) {
51+
this.fail();
52+
return;
53+
}
54+
this.success(user);
55+
};
56+
57+
this.verify(key, otp, verified);
58+
}
59+
}
60+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import {inject, Provider} from '@loopback/core';
2+
import {HttpErrors} from '@loopback/rest';
3+
import {AuthErrorKeys} from '../../../error-keys';
4+
import {Strategies} from '../../keys';
5+
import {VerifyFunction} from '../../types';
6+
import {Otp} from './otp-auth';
7+
8+
export interface PassportOtpStrategyFactory {
9+
(
10+
options: Otp.StrategyOptions,
11+
verifierPassed?: VerifyFunction.OtpAuthFn,
12+
): Otp.Strategy;
13+
}
14+
15+
export class PassportOtpStrategyFactoryProvider
16+
implements Provider<PassportOtpStrategyFactory>
17+
{
18+
constructor(
19+
@inject(Strategies.Passport.OTP_VERIFIER)
20+
private readonly verifierOtp: VerifyFunction.OtpAuthFn,
21+
) {}
22+
23+
value(): PassportOtpStrategyFactory {
24+
return (options, verifier) =>
25+
this.getPassportOtpStrategyVerifier(options, verifier);
26+
}
27+
28+
getPassportOtpStrategyVerifier(
29+
options?: Otp.StrategyOptions,
30+
verifierPassed?: VerifyFunction.OtpAuthFn,
31+
): Otp.Strategy {
32+
const verifyFn = verifierPassed ?? this.verifierOtp;
33+
return new Otp.Strategy(
34+
options,
35+
// eslint-disable-next-line @typescript-eslint/no-misused-promises
36+
async (key: string, otp: string, cb: Otp.VerifyCallback) => {
37+
try {
38+
const user = await verifyFn(key, otp);
39+
if (!user) {
40+
throw new HttpErrors.Unauthorized(AuthErrorKeys.InvalidCredentials);
41+
}
42+
cb(null, user);
43+
} catch (err) {
44+
cb(err);
45+
}
46+
},
47+
);
48+
}
49+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import {Provider} from '@loopback/context';
2+
import {HttpErrors} from '@loopback/rest';
3+
4+
import {VerifyFunction} from '../../types';
5+
6+
export class OtpVerifyProvider implements Provider<VerifyFunction.OtpAuthFn> {
7+
constructor() {}
8+
9+
value(): VerifyFunction.OtpAuthFn {
10+
return async (_key: string, _otp: string) => {
11+
throw new HttpErrors.NotImplemented(
12+
`VerifyFunction.OtpAuthFn is not implemented`,
13+
);
14+
};
15+
}
16+
}

‎src/strategies/types/types.ts

+5
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as AppleStrategy from 'passport-apple';
77
import {DecodedIdToken} from 'passport-apple';
88
import {IAuthClient, IAuthUser} from '../../types';
99
import {Keycloak} from './keycloak.types';
10+
import {Otp} from '../passport';
1011

1112
export type VerifyCallback = (
1213
err?: string | Error | null,
@@ -25,6 +26,10 @@ export namespace VerifyFunction {
2526
(username: string, password: string, req?: Request): Promise<T | null>;
2627
}
2728

29+
export interface OtpAuthFn<T = IAuthUser> extends GenericAuthFn<T> {
30+
(key: string, otp: string, cb: Otp.VerifyCallback): Promise<T | null>;
31+
}
32+
2833
export interface BearerFn<T = IAuthUser> extends GenericAuthFn<T> {
2934
(token: string, req?: Request): Promise<T | null>;
3035
}

‎src/strategies/user-auth-strategy.provider.ts

+9
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import {
2525
InstagramAuthStrategyFactory,
2626
KeycloakStrategyFactory,
2727
FacebookAuthStrategyFactory,
28+
PassportOtpStrategyFactory,
29+
Otp,
2830
} from './passport';
2931
import {Keycloak, VerifyFunction} from './types';
3032

@@ -38,6 +40,8 @@ export class AuthStrategyProvider implements Provider<Strategy | undefined> {
3840
private readonly metadata: AuthenticationMetadata,
3941
@inject(Strategies.Passport.LOCAL_STRATEGY_FACTORY)
4042
private readonly getLocalStrategyVerifier: LocalPasswordStrategyFactory,
43+
@inject(Strategies.Passport.OTP_AUTH_STRATEGY_FACTORY)
44+
private readonly getOtpVerifier: PassportOtpStrategyFactory,
4145
@inject(Strategies.Passport.BEARER_STRATEGY_FACTORY)
4246
private readonly getBearerStrategyVerifier: BearerStrategyFactory,
4347
@inject(Strategies.Passport.RESOURCE_OWNER_STRATEGY_FACTORY)
@@ -129,6 +133,11 @@ export class AuthStrategyProvider implements Provider<Strategy | undefined> {
129133
| ExtendedStrategyOption,
130134
verifier as VerifyFunction.FacebookAuthFn,
131135
);
136+
} else if (name === STRATEGY.OTP) {
137+
return this.getOtpVerifier(
138+
this.metadata.options as Otp.StrategyOptions,
139+
verifier as VerifyFunction.OtpAuthFn,
140+
);
132141
} else {
133142
return Promise.reject(`The strategy ${name} is not available.`);
134143
}

‎src/strategy-name.enum.ts

+1
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ export const enum STRATEGY {
99
FACEBOOK_OAUTH2 = 'Facebook Oauth 2.0',
1010
AZURE_AD = 'Azure AD',
1111
KEYCLOAK = 'keycloak',
12+
OTP = 'otp',
1213
}

0 commit comments

Comments
 (0)
Please sign in to comment.