Add more edge-cases to MFA

This commit is contained in:
Tuan Dang
2023-02-15 01:29:40 +07:00
parent 280f482fc8
commit b710944630
6 changed files with 70 additions and 21 deletions
@@ -243,7 +243,6 @@ export const sendMfaToken = async (req: Request, res: Response) => {
* @param res * @param res
*/ */
export const verifyMfaToken = async (req: Request, res: Response) => { export const verifyMfaToken = async (req: Request, res: Response) => {
try {
const { email, mfaToken } = req.body; const { email, mfaToken } = req.body;
await TokenService.validateToken({ await TokenService.validateToken({
@@ -296,15 +295,6 @@ export const verifyMfaToken = async (req: Request, res: Response) => {
resObj.protectedKeyTag = user.protectedKeyTag; resObj.protectedKeyTag = user.protectedKeyTag;
} }
// case: user does not have MFA enabled
// return (access) token in response
return res.status(200).send(resObj); return res.status(200).send(resObj);
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
return res.status(400).send({
message: 'Failed to authenticate. Try again?'
});
}
} }
+44 -6
View File
@@ -12,7 +12,7 @@ import {
import { import {
SALT_ROUNDS SALT_ROUNDS
} from '../config'; } from '../config';
import { ForbiddenRequestError } from '../utils/errors'; import { UnauthorizedRequestError } from '../utils/errors';
/** /**
* Create and store a token in the database for purpose [type] * Create and store a token in the database for purpose [type]
@@ -34,7 +34,7 @@ const createTokenHelper = async ({
phoneNumber?: string; phoneNumber?: string;
organizationId?: Types.ObjectId organizationId?: Types.ObjectId
}) => { }) => {
let token, expiresAt; let token, expiresAt, triesLeft;
try { try {
// generate random token based on specified token use-case // generate random token based on specified token use-case
// type [type] // type [type]
@@ -47,6 +47,7 @@ const createTokenHelper = async ({
case TOKEN_EMAIL_MFA: case TOKEN_EMAIL_MFA:
// generate random 6-digit code // generate random 6-digit code
token = String(crypto.randomInt(Math.pow(10, 5), Math.pow(10, 6) - 1)); token = String(crypto.randomInt(Math.pow(10, 5), Math.pow(10, 6) - 1));
triesLeft = 5;
expiresAt = new Date((new Date()).getTime() + 300000); expiresAt = new Date((new Date()).getTime() + 300000);
break; break;
case TOKEN_EMAIL_ORG_INVITATION: case TOKEN_EMAIL_ORG_INVITATION:
@@ -78,6 +79,7 @@ const createTokenHelper = async ({
phoneNumber?: string; phoneNumber?: string;
organization?: Types.ObjectId; organization?: Types.ObjectId;
tokenHash: string; tokenHash: string;
triesLeft?: number;
expiresAt: Date; expiresAt: Date;
} }
@@ -100,6 +102,10 @@ const createTokenHelper = async ({
query.organization = organizationId query.organization = organizationId
update.organization = organizationId update.organization = organizationId
} }
if (triesLeft) {
update.triesLeft = triesLeft;
}
await TokenData.findOneAndUpdate( await TokenData.findOneAndUpdate(
query, query,
@@ -157,19 +163,51 @@ const validateTokenHelper = async ({
if (!tokenData) throw new Error('Failed to find token to validate'); if (!tokenData) throw new Error('Failed to find token to validate');
if (tokenData.expiresAt < new Date()) { if (tokenData.expiresAt < new Date()) {
// case: token expired
await TokenData.findByIdAndDelete(tokenData._id); await TokenData.findByIdAndDelete(tokenData._id);
throw ForbiddenRequestError({ throw UnauthorizedRequestError({
message: 'Failed token data validation due to token is no longer valid' message: 'MFA session expired. Please log in again',
context: {
code: 'mfa_expired'
}
}); });
} }
const isValid = await bcrypt.compare(token, tokenData.tokenHash); const isValid = await bcrypt.compare(token, tokenData.tokenHash);
if (!isValid) { if (!isValid) {
throw ForbiddenRequestError({ // case: token is not valid
message: 'Failed token data validation due to incorrect token' if (tokenData?.triesLeft !== undefined) {
// case: token has a try-limit
if (tokenData.triesLeft === 1) {
// case: token is out of tries
await TokenData.findByIdAndDelete(tokenData._id);
} else {
// case: token has more than 1 try left
await TokenData.findByIdAndUpdate(tokenData._id, {
triesLeft: tokenData.triesLeft - 1
}, {
new: true
});
}
throw UnauthorizedRequestError({
message: 'MFA code is invalid',
context: {
code: 'mfa_invalid',
triesLeft: tokenData.triesLeft - 1
}
});
}
throw UnauthorizedRequestError({
message: 'MFA code is invalid',
context: {
code: 'mfa_invalid'
}
}); });
} }
// case: token is valid
await TokenData.findByIdAndDelete(tokenData._id); await TokenData.findByIdAndDelete(tokenData._id);
} }
@@ -17,9 +17,7 @@ export const requestErrorHandler: ErrorRequestHandler = (error: RequestError | E
} }
//TODO: Find better way to type check for error. In current setting you need to cast type to get the functions and variables from RequestError //TODO: Find better way to type check for error. In current setting you need to cast type to get the functions and variables from RequestError
if (error instanceof TokenExpiredError) { if (!(error instanceof RequestError)) {
error = UnauthorizedRequestError({ stack: error.stack, message: 'Token expired' });
} else if (!(error instanceof RequestError)) {
error = InternalServerError({ context: { exception: error.message }, stack: error.stack }) error = InternalServerError({ context: { exception: error.message }, stack: error.stack })
getLogger('backend-main').log((<RequestError>error).levelName.toLowerCase(), (<RequestError>error).message) getLogger('backend-main').log((<RequestError>error).levelName.toLowerCase(), (<RequestError>error).message)
} }
+4
View File
@@ -6,6 +6,7 @@ export interface ITokenData {
phoneNumber?: string; phoneNumber?: string;
organization?: Types.ObjectId; organization?: Types.ObjectId;
tokenHash: string; tokenHash: string;
triesLeft?: number;
expiresAt: Date; expiresAt: Date;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
@@ -37,6 +38,9 @@ const tokenDataSchema = new Schema<ITokenData>({
select: false, select: false,
required: true required: true
}, },
triesLeft: {
type: Number
},
expiresAt: { expiresAt: {
type: Date, type: Date,
expires: 0, expires: 0,
@@ -63,7 +63,6 @@ export default function LoginStep ({
} }
} catch (err) { } catch (err) {
console.error(err);
setLoginError(true); setLoginError(true);
} }
+21 -1
View File
@@ -30,6 +30,18 @@ const props = {
} }
} as const; } as const;
interface VerifyMfaTokenError {
response: {
data: {
context: {
code: string;
triesLeft: number;
}
},
status: number;
}
}
/** /**
* 2nd step of login - users enter their MFA code * 2nd step of login - users enter their MFA code
* @param {Object} obj * @param {Object} obj
@@ -73,7 +85,15 @@ export default function MFAStep({
} }
} catch (err) { } catch (err) {
console.error(err); const error = err as VerifyMfaTokenError;
if (error?.response?.status === 500) {
window.location.reload();
} else if (error?.response?.data?.context?.triesLeft === 0) {
window.location.reload();
router.push('/login');
}
setIsLoading(false); setIsLoading(false);
setCodeError(true); setCodeError(true);
} }