Skip to content

Commit

Permalink
feat: biometric authentication for mobile devices (#693)
Browse files Browse the repository at this point in the history
  • Loading branch information
kkopanidis committed Oct 2, 2023
1 parent 0a06464 commit d69ff51
Show file tree
Hide file tree
Showing 5 changed files with 278 additions and 20 deletions.
6 changes: 6 additions & 0 deletions modules/authentication/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ export default {
default: false,
},
},
biometricAuthentication: {
enabled: {
format: 'Boolean',
default: true,
},
},
captcha: {
enabled: {
format: 'Boolean',
Expand Down
1 change: 1 addition & 0 deletions modules/authentication/src/constants/TokenType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
189 changes: 189 additions & 0 deletions modules/authentication/src/handlers/biometric.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<UnparsedRouterResponse> {
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<UnparsedRouterResponse> {
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<UnparsedRouterResponse> {
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,
};
}
}
52 changes: 52 additions & 0 deletions modules/authentication/src/models/BiometricToken.schema.ts
Original file line number Diff line number Diff line change
@@ -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<BiometricToken> {
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;
}
}
50 changes: 30 additions & 20 deletions modules/authentication/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
Expand All @@ -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) => {
Expand All @@ -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,
Expand Down

0 comments on commit d69ff51

Please sign in to comment.