-
Notifications
You must be signed in to change notification settings - Fork 25
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: biometric authentication for mobile devices (#693)
- Loading branch information
1 parent
0a06464
commit d69ff51
Showing
5 changed files
with
278 additions
and
20 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
52
modules/authentication/src/models/BiometricToken.schema.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters