diff --git a/modules/authentication/package.json b/modules/authentication/package.json index 7f52d0f67..23ab6283d 100755 --- a/modules/authentication/package.json +++ b/modules/authentication/package.json @@ -36,6 +36,7 @@ "jsonwebtoken": "^8.5.1", "lodash": "^4.17.21", "moment": "^2.29.4", + "node-2fa": "^2.0.3", "uuid": "^8.3.2" }, "devDependencies": { diff --git a/modules/authentication/src/TwoFactorAuth.ts b/modules/authentication/src/TwoFactorAuth.ts new file mode 100644 index 000000000..fc92af9b1 --- /dev/null +++ b/modules/authentication/src/TwoFactorAuth.ts @@ -0,0 +1,71 @@ +import * as twoFactor from 'node-2fa'; +import ConduitGrpcSdk, { ConfigController, GrpcError } from '@conduitplatform/grpc-sdk'; +import { status } from '@grpc/grpc-js'; +import { AccessToken, RefreshToken, TwoFactorSecret, User } from './models'; +import { isNil } from 'lodash'; +import { AuthUtils } from './utils/auth'; +import { ISignTokenOptions } from './interfaces/ISignTokenOptions'; +import moment from 'moment'; + +export namespace TwoFactorAuth { + export function generateSecret(options?: { name: string; account: string }) { + return twoFactor.generateSecret(options); + } + + export function generateToken(secret: string) { + return twoFactor.generateToken(secret); + } + + export function verifyToken(secret: string, token?: string, window?: number) { + return twoFactor.verifyToken(secret, token, window); + } + + export async function verifyCode( + grpcSdk: ConduitGrpcSdk, + clientId: string, + user: User, + code: string, + ): Promise<{ userId: string; accessToken: string; refreshToken: string }> { + const secret = await TwoFactorSecret.getInstance().findOne({ + userId: user._id, + }); + if (isNil(secret)) throw new GrpcError(status.NOT_FOUND, 'Verification unsuccessful'); + + const verification = verifyToken(secret.secret, code); + if (isNil(verification)) { + throw new GrpcError(status.UNAUTHENTICATED, 'Verification unsuccessful'); + } + await Promise.all( + AuthUtils.deleteUserTokens(grpcSdk, { + userId: user._id, + clientId, + }), + ); + const config = ConfigController.getInstance().config; + const signTokenOptions: ISignTokenOptions = { + secret: config.jwtSecret, + expiresIn: config.tokenInvalidationPeriod, + }; + const accessToken: AccessToken = await AccessToken.getInstance().create({ + userId: user._id, + clientId, + token: AuthUtils.signToken({ id: user._id }, signTokenOptions), + expiresOn: moment() + .add(config.tokenInvalidationPeriod as number, 'milliseconds') + .toDate(), + }); + const refreshToken: RefreshToken = await RefreshToken.getInstance().create({ + userId: user._id, + clientId, + token: AuthUtils.randomToken(), + expiresOn: moment() + .add(config.refreshTokenInvalidationPeriod as number, 'milliseconds') + .toDate(), + }); + return { + userId: user._id.toString(), + accessToken: accessToken.token, + refreshToken: refreshToken.token, + }; + } +} diff --git a/modules/authentication/src/constants/TokenType.ts b/modules/authentication/src/constants/TokenType.ts index df1423844..853521713 100644 --- a/modules/authentication/src/constants/TokenType.ts +++ b/modules/authentication/src/constants/TokenType.ts @@ -5,5 +5,6 @@ export enum TokenType { CHANGE_EMAIL_TOKEN = 'CHANGE_EMAIL_TOKEN', TWO_FA_VERIFICATION_TOKEN = 'TWO_FA_VERIFICATION_TOKEN', VERIFY_PHONE_NUMBER_TOKEN = 'VERIFY_PHONE_NUMBER_TOKEN', + VERIFY_QR_TOKEN = 'VERIFY_QR_TOKEN', LOGIN_WITH_PHONE_NUMBER_TOKEN = 'LOGIN_WITH_PHONE_NUMBER_TOKEN', } diff --git a/modules/authentication/src/handlers/local.ts b/modules/authentication/src/handlers/local.ts index 4f420d1f7..e07635cd3 100644 --- a/modules/authentication/src/handlers/local.ts +++ b/modules/authentication/src/handlers/local.ts @@ -16,10 +16,11 @@ import ConduitGrpcSdk, { ConduitRouteReturnDefinition, } from '@conduitplatform/grpc-sdk'; import * as templates from '../templates'; -import { AccessToken, Token, User } from '../models'; +import { AccessToken, Token, TwoFactorSecret, User } from '../models'; import { status } from '@grpc/grpc-js'; import { Cookie } from '../interfaces/Cookie'; import { IAuthenticationStrategy } from '../interfaces/AuthenticationStrategy'; +import { TwoFactorAuth } from '../TwoFactorAuth'; export class LocalHandlers implements IAuthenticationStrategy { private emailModule: Email; @@ -200,17 +201,32 @@ export class LocalHandlers implements IAuthenticationStrategy { { path: '/local/enable-twofa', action: ConduitRouteActions.UPDATE, - description: `Enables a phone based 2FA method for a user and - requires their phone number.`, + description: `Enables a phone or qr based 2FA method for a user and + requires their phone number in case of phone 2FA.`, middlewares: ['authMiddleware'], bodyParams: { - phoneNumber: ConduitString.Required, + method: ConduitString.Required, + phoneNumber: ConduitString.Optional, }, }, new ConduitRouteReturnDefinition('EnableTwoFaResponse', 'String'), this.enableTwoFa.bind(this), ); + routingManager.route( + { + path: '/local/verify-qr-code', + action: ConduitRouteActions.GET, + description: `Verifies the code the user received from scanning the QR image`, + middlewares: ['authMiddleware'], + bodyParams: { + code: ConduitString.Required, + }, + }, + new ConduitRouteReturnDefinition('VerifyQRCodeResponse', 'String'), + this.verifyQrCode.bind(this), + ); + routingManager.route( { path: '/local/verifyPhoneNumber', @@ -358,32 +374,43 @@ export class LocalHandlers implements IAuthenticationStrategy { await this._authenticateChecks(password, config, user); if (user.hasTwoFA) { - const verificationSid = await AuthUtils.sendVerificationCode( - this.smsModule, - user.phoneNumber!, - ); - if (verificationSid === '') { - throw new GrpcError(status.INTERNAL, 'Could not send verification code'); - } + if (user.twoFaMethod === 'phone') { + const verificationSid = await AuthUtils.sendVerificationCode( + this.smsModule, + user.phoneNumber!, + ); + if (verificationSid === '') { + throw new GrpcError(status.INTERNAL, 'Could not send verification code'); + } + + await Token.getInstance() + .deleteMany({ + userId: user._id, + type: TokenType.TWO_FA_VERIFICATION_TOKEN, + }) + .catch(e => { + ConduitGrpcSdk.Logger.error(e); + }); - await Token.getInstance() - .deleteMany({ + await Token.getInstance().create({ userId: user._id, type: TokenType.TWO_FA_VERIFICATION_TOKEN, - }) - .catch(e => { - ConduitGrpcSdk.Logger.error(e); + token: verificationSid, }); - await Token.getInstance().create({ - userId: user._id, - type: TokenType.TWO_FA_VERIFICATION_TOKEN, - token: verificationSid, - }); - - return { - message: 'Verification code sent', - }; + return { + message: 'Verification code sent', + }; + } else if (user.twoFaMethod === 'qrcode') { + const secret = await TwoFactorSecret.getInstance().findOne({ + userId: user._id, + }); + if (isNil(secret)) + throw new GrpcError(status.NOT_FOUND, 'Authentication unsuccessful'); + return { message: 'OTP required' }; + } else { + throw new GrpcError(status.FAILED_PRECONDITION, '2FA method not specified'); + } } const clientConfig = config.clients; await AuthUtils.signInClientOperations( @@ -556,14 +583,6 @@ export class LocalHandlers implements IAuthenticationStrategy { const hashedPassword = await AuthUtils.hashPassword(newPassword); if (dbUser.hasTwoFA) { - const verificationSid = await AuthUtils.sendVerificationCode( - this.smsModule, - dbUser.phoneNumber!, - ); - if (verificationSid === '') { - throw new GrpcError(status.INTERNAL, 'Could not send verification code'); - } - await Token.getInstance() .deleteMany({ userId: dbUser._id, @@ -573,16 +592,42 @@ export class LocalHandlers implements IAuthenticationStrategy { ConduitGrpcSdk.Logger.error(e); }); - await Token.getInstance().create({ - userId: dbUser._id, - type: TokenType.CHANGE_PASSWORD_TOKEN, - token: verificationSid, - data: { - password: hashedPassword, - }, - }); + if (user.twoFaMethod === 'phone') { + const verificationSid = await AuthUtils.sendVerificationCode( + this.smsModule, + dbUser.phoneNumber!, + ); + if (verificationSid === '') { + throw new GrpcError(status.INTERNAL, 'Could not send verification code'); + } + await Token.getInstance().create({ + userId: dbUser._id, + type: TokenType.CHANGE_PASSWORD_TOKEN, + token: verificationSid, + data: { + password: hashedPassword, + }, + }); + return 'Verification code sent'; + } else if (user.twoFaMethod == 'qrcode') { + const secret = await TwoFactorSecret.getInstance().findOne({ + userId: user._id, + }); + if (isNil(secret)) + throw new GrpcError(status.NOT_FOUND, 'Authentication unsuccessful'); - return 'Verification code sent'; + await Token.getInstance().create({ + userId: dbUser._id, + type: TokenType.CHANGE_PASSWORD_TOKEN, + token: uuid(), + data: { + password: hashedPassword, + }, + }); + return 'OTP required'; + } else { + throw new GrpcError(status.FAILED_PRECONDITION, '2FA method not specified'); + } } await User.getInstance().findByIdAndUpdate(dbUser._id, { hashedPassword }); @@ -686,10 +731,23 @@ export class LocalHandlers implements IAuthenticationStrategy { throw new GrpcError(status.NOT_FOUND, 'Change password token does not exist'); } - const verified = await this.smsModule.verify(token.token, code); + if (user.twoFaMethod === 'phone') { + const verified = await this.smsModule.verify(token.token, code); + if (!verified.verified) { + throw new GrpcError(status.UNAUTHENTICATED, 'Invalid code'); + } + } else if (user.twoFaMethod === 'qrcode') { + const secret = await TwoFactorSecret.getInstance().findOne({ + userId: user._id, + }); + if (isNil(secret)) + throw new GrpcError(status.NOT_FOUND, 'Verification unsuccessful'); - if (!verified.verified) { - throw new GrpcError(status.UNAUTHENTICATED, 'Invalid code'); + const verification = TwoFactorAuth.verifyToken(secret.secret, code); + if (isNil(verification)) + throw new GrpcError(status.UNAUTHENTICATED, 'Verification unsuccessful'); + } else { + throw new GrpcError(status.FAILED_PRECONDITION, '2FA method not specified'); } await Token.getInstance() @@ -855,50 +913,120 @@ export class LocalHandlers implements IAuthenticationStrategy { if (isNil(user)) throw new GrpcError(status.UNAUTHENTICATED, 'User not found'); - return await AuthUtils.verifyCode( - this.grpcSdk, - clientId, - user, - TokenType.TWO_FA_VERIFICATION_TOKEN, - code, - ); + if (user.twoFaMethod == 'phone') { + return await AuthUtils.verifyCode( + this.grpcSdk, + clientId, + user, + TokenType.TWO_FA_VERIFICATION_TOKEN, + code, + ); + } else if (user.twoFaMethod == 'qrcode') { + return await TwoFactorAuth.verifyCode(this.grpcSdk, clientId, user, code); + } else { + throw new GrpcError(status.FAILED_PRECONDITION, 'Method not valid'); + } } async enableTwoFa(call: ParsedRouterRequest): Promise { - const { phoneNumber } = call.request.params; + const { method, phoneNumber } = call.request.params; const context = call.request.context; if (isNil(context) || isNil(context.user)) { throw new GrpcError(status.UNAUTHENTICATED, 'Unauthorized'); } - - const verificationSid = await AuthUtils.sendVerificationCode( - this.smsModule, - phoneNumber, - ); - if (verificationSid === '') { - throw new GrpcError(status.INTERNAL, 'Could not send verification code'); + if (context.user.hasTwoFA) { + return '2FA already enabled'; } - await Token.getInstance() - .deleteMany({ + if (method === 'phone') { + const verificationSid = await AuthUtils.sendVerificationCode( + this.smsModule, + phoneNumber, + ); + if (verificationSid === '') { + throw new GrpcError(status.INTERNAL, 'Could not send verification code'); + } + + await Token.getInstance() + .deleteMany({ + userId: context.user._id, + type: TokenType.VERIFY_PHONE_NUMBER_TOKEN, + }) + .catch(e => { + ConduitGrpcSdk.Logger.error(e); + }); + + await Token.getInstance().create({ userId: context.user._id, type: TokenType.VERIFY_PHONE_NUMBER_TOKEN, - }) - .catch(e => { - ConduitGrpcSdk.Logger.error(e); + token: verificationSid, + data: { + phoneNumber, + }, + }); + + await User.getInstance().findByIdAndUpdate(context.user._id, { + twoFaMethod: 'phone', }); - await Token.getInstance().create({ + return 'Verification code sent'; + } else if (method === 'qrcode') { + const secret = TwoFactorAuth.generateSecret({ + //to do: add logic for app name insertion + name: 'Conduit', + account: context.user.email, + }); + + await TwoFactorSecret.getInstance().deleteMany({ + userId: context.user._id, + }); + + await TwoFactorSecret.getInstance().create({ + userId: context.user._id, + secret: secret.secret, + uri: secret.uri, + qr: secret.qr, + }); + + await User.getInstance().findByIdAndUpdate(context.user._id, { + twoFaMethod: 'qrcode', + }); + return secret.qr.toString(); + } + throw new GrpcError(status.INVALID_ARGUMENT, 'Method not valid'); + } + + async verifyQrCode(call: ParsedRouterRequest): Promise { + const context = call.request.context; + const { code } = call.request.params; + + if (isNil(context) || isEmpty(context)) { + throw new GrpcError(status.UNAUTHENTICATED, 'User unauthenticated'); + } + if (context.user.hasTwoFA) { + return '2FA already enabled'; + } + + const secret = await TwoFactorSecret.getInstance().findOne({ userId: context.user._id, - type: TokenType.VERIFY_PHONE_NUMBER_TOKEN, - token: verificationSid, - data: { - phoneNumber, - }, }); + if (isNil(secret)) throw new GrpcError(status.NOT_FOUND, 'Verification unsuccessful'); - return 'Verification code sent'; + const verification = TwoFactorAuth.verifyToken(secret.secret, code); + if (isNil(verification)) { + throw new GrpcError(status.UNAUTHENTICATED, 'Verification unsuccessful'); + } + + await User.getInstance().findByIdAndUpdate(context.user._id, { + hasTwoFA: true, + }); + + this.grpcSdk.bus?.publish( + 'authentication:enableTwofa:user', + JSON.stringify({ id: context.user._id }), + ); + return '2FA enabled'; } async verifyPhoneNumber(call: ParsedRouterRequest): Promise { diff --git a/modules/authentication/src/models/TwoFactorSecret.schema.ts b/modules/authentication/src/models/TwoFactorSecret.schema.ts new file mode 100644 index 000000000..b3c210076 --- /dev/null +++ b/modules/authentication/src/models/TwoFactorSecret.schema.ts @@ -0,0 +1,61 @@ +import { ConduitActiveSchema, DatabaseProvider, TYPE } from '@conduitplatform/grpc-sdk'; +import { User } from './User.schema'; + +const schema = { + _id: TYPE.ObjectId, + userId: { + type: TYPE.Relation, + model: 'User', + required: true, + }, + secret: { + type: TYPE.String, + required: true, + }, + uri: { + type: TYPE.String, + required: true, + }, + qr: { + type: TYPE.String, + required: true, + }, + createdAt: TYPE.Date, + updatedAt: TYPE.Date, +}; + +const schemaOptions = { + timestamps: true, + conduit: { + permissions: { + extendable: true, + canCreate: false, + canModify: 'ExtensionOnly', + canDelete: false, + }, + }, +} as const; +const collectionName = undefined; + +export class TwoFactorSecret extends ConduitActiveSchema { + private static _instance: TwoFactorSecret; + _id: string; + userId: string | User; + secret: string; + uri: string; + qr: string; + createdAt: Date; + updatedAt: Date; + + constructor(database: DatabaseProvider) { + super(database, TwoFactorSecret.name, schema, schemaOptions, collectionName); + } + + static getInstance(database?: DatabaseProvider) { + if (TwoFactorSecret._instance) return TwoFactorSecret._instance; + if (!database) throw new Error('No database instance provided!'); + + TwoFactorSecret._instance = new TwoFactorSecret(database); + return TwoFactorSecret._instance; + } +} diff --git a/modules/authentication/src/models/User.schema.ts b/modules/authentication/src/models/User.schema.ts index 3fee08180..1dbfeacf4 100644 --- a/modules/authentication/src/models/User.schema.ts +++ b/modules/authentication/src/models/User.schema.ts @@ -142,6 +142,7 @@ const schema = { type: TYPE.Boolean, default: false, }, + twoFaMethod: TYPE.String, phoneNumber: TYPE.String, createdAt: TYPE.Date, updatedAt: TYPE.Date, @@ -218,6 +219,7 @@ export class User extends ConduitActiveSchema { active: boolean; isVerified: boolean; hasTwoFA: boolean; + twoFaMethod: string; phoneNumber?: string; createdAt: Date; updatedAt: Date; diff --git a/modules/authentication/src/models/index.ts b/modules/authentication/src/models/index.ts index 844166477..71d27d9d4 100644 --- a/modules/authentication/src/models/index.ts +++ b/modules/authentication/src/models/index.ts @@ -3,3 +3,4 @@ export * from './RefreshToken.schema'; export * from './Token.schema'; export * from './User.schema'; export * from './Service.schema'; +export * from './TwoFactorSecret.schema'; diff --git a/yarn.lock b/yarn.lock index ea6fb9671..3b25379fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2998,6 +2998,13 @@ resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw== +"@types/notp@^2.0.0": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@types/notp/-/notp-2.0.2.tgz#7283f4918b2770555e0f2df72acc9f46ebd41ae9" + integrity sha512-JUcVYN9Tmw0AjoAfvjslS4hbv39fPBbZdftBK3b50g5z/DmhLsu6cd0UOEBiQuMwy2FirshF2Gk9gAvfWjshMw== + dependencies: + "@types/node" "*" + "@types/object-hash@^1.3.0": version "1.3.4" resolved "https://registry.yarnpkg.com/@types/object-hash/-/object-hash-1.3.4.tgz#079ba142be65833293673254831b5e3e847fe58b" @@ -7948,6 +7955,16 @@ nice-grpc@^1.2.0: nice-grpc-common "^1.1.0" node-abort-controller "^1.2.1" +node-2fa@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/node-2fa/-/node-2fa-2.0.3.tgz#74a5f3c618e803c3b7a9b04c38d36cbf4a5e26d6" + integrity sha512-PQldrOhjuoZyoydMvMSctllPN1ZPZ1/NwkEcgYwY9faVqE/OymxR+3awPpbWZxm6acLKqvmNqQmdqTsqYyflFw== + dependencies: + "@types/notp" "^2.0.0" + notp "^2.0.3" + thirty-two "1.0.2" + tslib "^2.1.0" + node-abort-controller@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/node-abort-controller/-/node-abort-controller-1.2.1.tgz#1eddb57eb8fea734198b11b28857596dc6165708" @@ -8110,6 +8127,11 @@ notepack.io@~2.2.0: resolved "https://registry.yarnpkg.com/notepack.io/-/notepack.io-2.2.0.tgz#d7ea71d1cb90094f88c6f3c8d84277c2d0cd101c" integrity sha512-9b5w3t5VSH6ZPosoYnyDONnUTF8o0UkBw7JLA6eBlYJWyGT1Q3vQa8Hmuj1/X6RYvHjjygBDgw6fJhe0JEojfw== +notp@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/notp/-/notp-2.0.3.tgz#a9fd11e25cfe1ccb39fc6689544ee4c10ef9a577" + integrity sha512-oBig/2uqkjQ5AkBuw4QJYwkEWa/q+zHxI5/I5z6IeP2NT0alpJFsP/trrfCC+9xOAgQSZXssNi962kp5KBmypQ== + npm-bundled@^1.1.1, npm-bundled@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.1.2.tgz#944c78789bd739035b70baa2ca5cc32b8d860bc1" @@ -10315,6 +10337,11 @@ thenify-all@^1.0.0: dependencies: any-promise "^1.0.0" +thirty-two@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/thirty-two/-/thirty-two-1.0.2.tgz#4ca2fffc02a51290d2744b9e3f557693ca6b627a" + integrity sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA== + through2@^2.0.0, through2@^2.0.1: version "2.0.5" resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd"