Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: biometric authentication for mobile devices #693

Merged
merged 3 commits into from
Oct 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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