From d69ff51fc43176967355f7982e858dcd3e443b88 Mon Sep 17 00:00:00 2001 From: Konstantinos Kopanidis Date: Mon, 2 Oct 2023 16:26:28 +0300 Subject: [PATCH] feat: biometric authentication for mobile devices (#693) --- modules/authentication/src/config/config.ts | 6 + .../authentication/src/constants/TokenType.ts | 1 + .../authentication/src/handlers/biometric.ts | 189 ++++++++++++++++++ .../src/models/BiometricToken.schema.ts | 52 +++++ modules/authentication/src/routes/index.ts | 50 +++-- 5 files changed, 278 insertions(+), 20 deletions(-) create mode 100644 modules/authentication/src/handlers/biometric.ts create mode 100644 modules/authentication/src/models/BiometricToken.schema.ts diff --git a/modules/authentication/src/config/config.ts b/modules/authentication/src/config/config.ts index 028c6065b..0f774c6c6 100644 --- a/modules/authentication/src/config/config.ts +++ b/modules/authentication/src/config/config.ts @@ -37,6 +37,12 @@ export default { default: false, }, }, + biometricAuthentication: { + enabled: { + format: 'Boolean', + default: true, + }, + }, captcha: { enabled: { format: 'Boolean', diff --git a/modules/authentication/src/constants/TokenType.ts b/modules/authentication/src/constants/TokenType.ts index 85ffe3337..bec933163 100644 --- a/modules/authentication/src/constants/TokenType.ts +++ b/modules/authentication/src/constants/TokenType.ts @@ -8,6 +8,7 @@ export enum TokenType { VERIFY_PHONE_NUMBER_TOKEN = 'VERIFY_PHONE_NUMBER_TOKEN', LOGIN_WITH_PHONE_NUMBER_TOKEN = 'LOGIN_WITH_PHONE_NUMBER_TOKEN', REGISTER_WITH_PHONE_NUMBER_TOKEN = 'REGISTER_WITH_PHONE_NUMBER_TOKEN', + REGISTER_BIOMETRICS_TOKEN = 'REGISTER_BIOMETRICS_TOKEN', TEAM_INVITE_TOKEN = 'TEAM_INVITE_TOKEN', MAGIC_LINK = 'MAGIC_LINK', STATE_TOKEN = 'STATE_TOKEN', diff --git a/modules/authentication/src/handlers/biometric.ts b/modules/authentication/src/handlers/biometric.ts new file mode 100644 index 000000000..12f8283a8 --- /dev/null +++ b/modules/authentication/src/handlers/biometric.ts @@ -0,0 +1,189 @@ +import ConduitGrpcSdk, { + ConduitRouteActions, + ConduitRouteReturnDefinition, + GrpcError, + ParsedRouterRequest, + UnparsedRouterResponse, +} from '@conduitplatform/grpc-sdk'; + +import { + ConduitString, + ConfigController, + RoutingManager, +} from '@conduitplatform/module-tools'; +import { status } from '@grpc/grpc-js'; +import { Token, User } from '../models'; +import { AuthUtils } from '../utils'; +import { TokenType } from '../constants'; +import { IAuthenticationStrategy } from '../interfaces'; +import { TokenProvider } from './tokenProvider'; +import { v4 as uuid } from 'uuid'; +import crypto from 'crypto'; +import { BiometricToken } from '../models/BiometricToken.schema'; + +export class BiometricHandlers implements IAuthenticationStrategy { + private initialized: boolean = false; + + constructor(private readonly grpcSdk: ConduitGrpcSdk) {} + + async validate(): Promise { + const config = ConfigController.getInstance().config; + if (config.biometricAuthentication.enabled) { + ConduitGrpcSdk.Logger.log('Biometric authentication is available'); + return (this.initialized = true); + } else { + ConduitGrpcSdk.Logger.log('Biometric authentication not available'); + return (this.initialized = false); + } + } + + async declareRoutes(routingManager: RoutingManager) { + routingManager.route( + { + path: '/biometrics', + action: ConduitRouteActions.POST, + description: `Endpoint that can be used to authenticate with + biometric authentication from mobile devices. + It expects the key ID that you will be using to encrypt the data with.`, + bodyParams: { + encryptedData: ConduitString.Required, + keyId: ConduitString.Required, + }, + }, + new ConduitRouteReturnDefinition('BiometricsAuthenticateResponse', { + accessToken: ConduitString.Optional, + refreshToken: ConduitString.Optional, + }), + this.biometricLogin.bind(this), + ); + routingManager.route( + { + path: '/biometrics/enroll', + action: ConduitRouteActions.POST, + description: `Endpoint that can be used to enroll a user with + biometric authentication from mobile devices.`, + bodyParams: { + publicKey: ConduitString.Required, + }, + middlewares: ['authMiddleware'], + }, + new ConduitRouteReturnDefinition('BiometricsAuthenticateResponse', { + challenge: ConduitString.Required, + }), + this.enroll.bind(this), + ); + routingManager.route( + { + path: '/biometrics/enroll/verify', + action: ConduitRouteActions.POST, + description: `Verifies the encrypted information which is used for biometric authentication. + The identifier field is either the user id when logging in or the token when registering (user or the method).`, + bodyParams: { + encryptedData: ConduitString.Required, + }, + }, + new ConduitRouteReturnDefinition('VerifyBiometricEnrollResponse', { + keyId: ConduitString.Required, + }), + this.biometricVerifyEnroll.bind(this), + ); + } + + async biometricLogin(call: ParsedRouterRequest): Promise { + ConduitGrpcSdk.Metrics?.increment('login_requests_total'); + const { encryptedData, keyId } = call.request.params; + const config = ConfigController.getInstance().config; + const key = await BiometricToken.getInstance().findOne( + { + _id: keyId, + }, + undefined, + 'user', + ); + if (!key) { + throw new GrpcError(status.INVALID_ARGUMENT, 'Key not found!'); + } + + const data = Buffer.from((key.user as User)._id); + const verificationResult = crypto.verify('SHA256', data, key.publicKey, encryptedData); + if (!verificationResult) { + throw new GrpcError(status.INVALID_ARGUMENT, 'Invalid signature!'); + } + return TokenProvider.getInstance().provideUserTokens({ + user: key.user as User, + clientId: call.request.context.clientId, + config, + }); + } + + async enroll(call: ParsedRouterRequest): Promise { + const { publicKey } = call.request.params; + const { clientId, user } = call.request.context; + const existingToken = await Token.getInstance().findOne({ + tokenType: TokenType.REGISTER_BIOMETRICS_TOKEN, + user: user._id, + }); + if (existingToken) { + AuthUtils.checkResendThreshold(existingToken); + await Token.getInstance().deleteMany({ + tokenType: TokenType.REGISTER_BIOMETRICS_TOKEN, + user: user._id, + }); + } + const challenge = crypto.randomBytes(64).toString('hex'); + const token = await Token.getInstance().create({ + tokenType: TokenType.REGISTER_BIOMETRICS_TOKEN, + user: user._id, + data: { + clientId, + challenge, + publicKey, + }, + token: uuid(), + }); + return { + token: token.token, + }; + } + + async biometricVerifyEnroll( + call: ParsedRouterRequest, + ): Promise { + const { encryptedData } = call.request.params; + const { clientId, user } = call.request.context; + const existingToken = await Token.getInstance().findOne({ + tokenType: TokenType.REGISTER_BIOMETRICS_TOKEN, + user: user._id, + }); + if (!existingToken) { + throw new GrpcError(status.INVALID_ARGUMENT, 'Invalid token!'); + } + if (existingToken.data.clientId !== clientId) { + throw new GrpcError( + status.PERMISSION_DENIED, + "Responding client doesn't match requesting!", + ); + } + await Token.getInstance().deleteMany({ + tokenType: TokenType.REGISTER_BIOMETRICS_TOKEN, + user: user._id, + }); + const data = Buffer.from(existingToken.data.challenge); + const verificationResult = crypto.verify( + 'SHA256', + data, + existingToken.data.publicKey, + encryptedData, + ); + if (!verificationResult) { + throw new GrpcError(status.INVALID_ARGUMENT, 'Invalid signature!'); + } + const biometricToken = await BiometricToken.getInstance().create({ + user: user._id, + publicKey: existingToken.data.publicKey, + }); + return { + keyId: biometricToken._id, + }; + } +} diff --git a/modules/authentication/src/models/BiometricToken.schema.ts b/modules/authentication/src/models/BiometricToken.schema.ts new file mode 100644 index 000000000..d5983222d --- /dev/null +++ b/modules/authentication/src/models/BiometricToken.schema.ts @@ -0,0 +1,52 @@ +import { ConduitModel, DatabaseProvider, TYPE } from '@conduitplatform/grpc-sdk'; +import { ConduitActiveSchema } from '@conduitplatform/module-tools'; +import { User } from './User.schema'; + +const schema: ConduitModel = { + _id: TYPE.ObjectId, + user: { + type: TYPE.Relation, + model: 'User', + required: true, + }, + publicKey: { + type: TYPE.String, + required: true, + }, + createdAt: TYPE.Date, + updatedAt: TYPE.Date, +}; +const modelOptions = { + timestamps: true, + conduit: { + permissions: { + extendable: false, + canCreate: false, + canModify: 'Nothing', + canDelete: false, + }, + }, +} as const; +const collectionName = undefined; + +export class BiometricToken extends ConduitActiveSchema { + private static _instance: BiometricToken; + _id: string; + user: string | User; + publicKey: string; + createdAt: Date; + updatedAt: Date; + + constructor(database: DatabaseProvider) { + super(database, BiometricToken.name, schema, modelOptions, collectionName); + } + + static getInstance(database?: DatabaseProvider) { + if (BiometricToken._instance) return BiometricToken._instance; + if (!database) { + throw new Error('No database instance provided!'); + } + BiometricToken._instance = new BiometricToken(database); + return BiometricToken._instance; + } +} diff --git a/modules/authentication/src/routes/index.ts b/modules/authentication/src/routes/index.ts index 603d6d24f..957ebd475 100644 --- a/modules/authentication/src/routes/index.ts +++ b/modules/authentication/src/routes/index.ts @@ -21,6 +21,7 @@ import { authMiddleware, captchaMiddleware } from './middleware'; import { MagicLinkHandlers } from '../handlers/magicLink'; import { Config } from '../config'; import { TeamsHandler } from '../handlers/team'; +import { BiometricHandlers } from '../handlers/biometric'; type OAuthHandler = typeof oauth2; @@ -32,6 +33,7 @@ export class AuthenticationRoutes { private readonly _routingManager: RoutingManager; private readonly twoFaHandlers: TwoFa; private readonly magicLinkHandlers: MagicLinkHandlers; + private readonly biometricHandlers: BiometricHandlers; constructor(readonly server: GrpcServer, private readonly grpcSdk: ConduitGrpcSdk) { this._routingManager = new RoutingManager(this.grpcSdk.router!, server); @@ -41,50 +43,57 @@ export class AuthenticationRoutes { this.localHandlers = new LocalHandlers(this.grpcSdk); this.twoFaHandlers = new TwoFa(this.grpcSdk); this.magicLinkHandlers = new MagicLinkHandlers(this.grpcSdk); + this.biometricHandlers = new BiometricHandlers(this.grpcSdk); } async registerRoutes() { const config: Config = ConfigController.getInstance().config; this._routingManager.clear(); let enabled = false; - let errorMessage = null; + const phoneActive = await this.phoneHandlers .validate() - .catch(e => (errorMessage = e)); + .catch(e => ConduitGrpcSdk.Logger.error(e)); - if (phoneActive && !errorMessage) { + if (phoneActive) { await this.phoneHandlers.declareRoutes(this._routingManager); } + const biometricActive = await this.biometricHandlers + .validate() + .catch(e => ConduitGrpcSdk.Logger.error(e)); + + if (biometricActive) { + await this.biometricHandlers.declareRoutes(this._routingManager); + } + const magicLinkActive = await this.magicLinkHandlers .validate() - .catch(e => (errorMessage = e)); - if (magicLinkActive && !errorMessage) { + .catch(e => ConduitGrpcSdk.Logger.error(e)); + if (magicLinkActive) { await this.magicLinkHandlers.declareRoutes(this._routingManager); } - - let authActive = await this.localHandlers.validate().catch(e => (errorMessage = e)); - if (!errorMessage && authActive) { + let authActive = await this.localHandlers + .validate() + .catch(e => ConduitGrpcSdk.Logger.error(e)); + if (authActive) { await this.localHandlers.declareRoutes(this._routingManager); enabled = true; } - const teamsActivated = await TeamsHandler.getInstance(this.grpcSdk) .validate() - .catch(e => (errorMessage = e)); - if (!errorMessage && teamsActivated) { - await TeamsHandler.getInstance().declareRoutes(this._routingManager); + .catch(e => ConduitGrpcSdk.Logger.error(e)); + if (teamsActivated) { + TeamsHandler.getInstance().declareRoutes(this._routingManager); enabled = true; } - errorMessage = null; const twoFaActive = await this.twoFaHandlers .validate() - .catch(e => (errorMessage = e)); - if (!errorMessage && twoFaActive) { - await this.twoFaHandlers.declareRoutes(this._routingManager); + .catch(e => ConduitGrpcSdk.Logger.error(e)); + if (twoFaActive) { + this.twoFaHandlers.declareRoutes(this._routingManager); enabled = true; } - errorMessage = null; await Promise.all( (Object.keys(oauth2) as (keyof OAuthHandler)[]).map((key: keyof OAuthHandler) => { @@ -107,9 +116,10 @@ export class AuthenticationRoutes { }), ); - errorMessage = null; - authActive = await this.serviceHandler.validate().catch(e => (errorMessage = e)); - if (!errorMessage && authActive) { + authActive = await this.serviceHandler + .validate() + .catch(e => ConduitGrpcSdk.Logger.error(e)); + if (authActive) { const returnField: ConduitReturn = { serviceId: ConduitString.Required, accessToken: ConduitString.Optional,