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: login with v3 recaptcha (#4091) #727

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
5 changes: 4 additions & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ executors:
TEST_DATABASE_URL: "postgres://bloom-ci@localhost:5432/bloom"
PARTNERS_PORTAL_URL: "http://localhost:3001"
JURISDICTION_NAME: Alameda
GOOGLE_API_EMAIL: "secret-key"
GOOGLE_API_ID: "secret-key"
GOOGLE_API_KEY: "secret-key"
GOOGLE_CLOUD_PROJECT_ID: "secret-key"
standard-node:
docker:
- image: "cimg/node:18.14.2"
Expand Down Expand Up @@ -188,7 +192,6 @@ jobs:
APP_SECRET: "CI-LONG-SECRET-KEY"
# DB URL for migration and seeds:
DATABASE_URL: "postgres://bloom-ci@localhost:5432/bloom_prisma"


workflows:
build:
Expand Down
8 changes: 8 additions & 0 deletions api/.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,11 @@ THROTTLE_LIMIT=100
API_PASS_KEY="some-key-here"
# this is used to test the script runner's data transfer job
TEST_CONNECTION_STRING=""
# recaptcha api key, if set enables recaptcha on backend
RECAPTCHA_KEY=
# needed for recaptcha setup
GOOGLE_CLOUD_PROJECT_ID=
# the score from 0 to 1 that a recaptcha check must pass to continue without 2fa
RECAPTCHA_THRESHOLD=0.7
# if scores should block login flows
ENABLE_RECAPTCHA=TRUE
1 change: 1 addition & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"setup:dev": "yarn install && yarn prisma generate && yarn build && yarn db:setup"
},
"dependencies": {
"@google-cloud/recaptcha-enterprise": "^5.8.0",
"@google-cloud/translate": "^7.2.1",
"@nestjs/axios": "~3.0.0",
"@nestjs/common": "^10.3.2",
Expand Down
11 changes: 10 additions & 1 deletion api/src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,17 @@ export class AuthController {
async login(
@Request() req: ExpressRequest,
@Response({ passthrough: true }) res: ExpressResponse,
@Body() dto: Login,
): Promise<SuccessDTO> {
return await this.authService.setCredentials(res, mapTo(User, req['user']));
return await this.authService.setCredentials(
res,
mapTo(User, req['user']),
undefined,
dto.reCaptchaToken,
!!process.env.RECAPTCHA_KEY,
!!dto.mfaCode,
process.env.ENABLE_RECAPTCHA === 'TRUE',
);
}

@Post('loginViaSingleUseCode')
Expand Down
5 changes: 5 additions & 0 deletions api/src/dtos/auth/login.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,9 @@ export class Login {
@IsEnum(MfaType, { groups: [ValidationsGroupsEnum.default] })
@ApiPropertyOptional({ enum: MfaType, enumName: 'MfaType' })
mfaType?: MfaType;

@Expose()
@IsString({ groups: [ValidationsGroupsEnum.default] })
@ApiPropertyOptional()
reCaptchaToken?: string;
}
6 changes: 0 additions & 6 deletions api/src/passports/single-use-code.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,6 @@ export class SingleUseCodeStrategy extends PassportStrategy(
);
}

if (!juris.allowSingleUseCodeLogin) {
throw new BadRequestException(
`Single use code login is not setup for ${jurisName}`,
);
}

const rawUser = await this.prisma.userAccounts.findFirst({
include: {
userRoles: true,
Expand Down
70 changes: 64 additions & 6 deletions api/src/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from '@nestjs/common';
import { CookieOptions, Response } from 'express';
import { sign, verify } from 'jsonwebtoken';
import { RecaptchaEnterpriseServiceClient } from '@google-cloud/recaptcha-enterprise';
import { Prisma } from '@prisma/client';
import { UpdatePassword } from '../dtos/auth/update-password.dto';
import { MfaType } from '../enums/mfa/mfa-type-enum';
Expand Down Expand Up @@ -84,11 +85,74 @@ export class AuthService {
res: Response,
user: User,
incomingRefreshToken?: string,
reCaptchaToken?: string,
reCaptchaConfigured?: boolean,
mfaCode?: boolean,
shouldReCaptchaBlockLogin?: boolean,
): Promise<SuccessDTO> {
if (!user?.id) {
throw new UnauthorizedException('no user found');
}

if (reCaptchaConfigured && !user.mfaEnabled && !mfaCode) {
const client = new RecaptchaEnterpriseServiceClient({
credentials: {
private_key: process.env.GOOGLE_API_KEY.replace(/\\n/gm, '\n'),
client_email: process.env.GOOGLE_API_EMAIL,
},
projectID: process.env.GOOGLE_API_ID,
});

const request = {
assessment: {
event: {
token: reCaptchaToken,
siteKey: process.env.RECAPTCHA_KEY,
},
},
parent: client.projectPath(process.env.GOOGLE_CLOUD_PROJECT_ID),
};

const [response] = await client.createAssessment(request);
client.close();

if (!response.tokenProperties.valid && shouldReCaptchaBlockLogin) {
throw new UnauthorizedException({
name: 'failedReCaptchaToken',
knownError: true,
message: `The ReCaptcha CreateAssessment call failed because the token was: ${response.tokenProperties.invalidReason}`,
});
}

if (response.tokenProperties.action === 'login') {
response.riskAnalysis.reasons.forEach((reason) => {
console.log(reason);
});

console.log(`The ReCaptcha score is ${response.riskAnalysis.score}`);

const threshold = parseFloat(process.env.RECAPTCHA_THRESHOLD);

if (
response.riskAnalysis.score < threshold &&
shouldReCaptchaBlockLogin
) {
throw new UnauthorizedException({
name: 'failedReCaptchaScore',
knownError: true,
message: `ReCaptcha failed because the score was ${response.riskAnalysis.score}`,
});
}
} else {
if (shouldReCaptchaBlockLogin)
throw new UnauthorizedException({
name: 'failedReCaptchaAction',
knownError: true,
message: `ReCaptcha failed because the action didn't match, action was: ${response.tokenProperties.action}`,
});
}
}

if (incomingRefreshToken) {
// if token is provided, verify that its the correct refresh token
const userCount = await this.prisma.userAccounts.count({
Expand Down Expand Up @@ -190,12 +254,6 @@ export class AuthService {
UserViews.full,
);

if (!user.mfaEnabled) {
throw new UnauthorizedException(
`user ${dto.email} requested an mfa code, but has mfa disabled`,
);
}

if (!(await isPasswordValid(user.passwordHash, dto.password))) {
throw new UnauthorizedException(
`user ${dto.email} requested an mfa code, but provided incorrect password`,
Expand Down
6 changes: 0 additions & 6 deletions api/src/services/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -984,12 +984,6 @@ export class UserService {
);
}

if (!juris.allowSingleUseCodeLogin) {
throw new BadRequestException(
`Single use code login is not setup for ${jurisdictionName}`,
);
}

const singleUseCode = generateSingleUseCode(
Number(process.env.MFA_CODE_LENGTH),
);
Expand Down
44 changes: 0 additions & 44 deletions api/test/integration/user.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -778,50 +778,6 @@ describe('User Controller Tests', () => {
expect(user.singleUseCodeUpdatedAt).not.toBeNull();
});

it('should request single use code, but jurisdiction does not allow', async () => {
const storedUser = await prisma.userAccounts.create({
data: await userFactory({
roles: { isAdmin: true },
mfaEnabled: true,
confirmedAt: new Date(),
phoneNumber: '111-111-1111',
phoneNumberVerified: true,
}),
});

const jurisdiction = await prisma.jurisdictions.create({
data: {
name: 'single_use_code_2',
allowSingleUseCodeLogin: false,
rentalAssistanceDefault: 'test',
},
});
emailService.sendSingleUseCode = jest.fn();

const res = await request(app.getHttpServer())
.post('/user/request-single-use-code')
.set({ passkey: process.env.API_PASS_KEY || '' })
.send({
email: storedUser.email,
} as RequestMfaCode)
.set({ jurisdictionname: jurisdiction.name })
.expect(400);

expect(res.body.message).toEqual(
'Single use code login is not setup for single_use_code_2',
);

expect(emailService.sendSingleUseCode).not.toHaveBeenCalled();

const user = await prisma.userAccounts.findUnique({
where: {
id: storedUser.id,
},
});

expect(user.singleUseCode).toBeNull();
});

it('should request single use code, but user does not exist', async () => {
const jurisdiction = await prisma.jurisdictions.create({
data: {
Expand Down
1 change: 0 additions & 1 deletion api/test/jest-with-coverage.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,4 @@ module.exports = {
lines: 85,
},
},
workerIdleMemoryLimit: '70%',
};
54 changes: 0 additions & 54 deletions api/test/unit/passports/single-use-code.strategy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,60 +515,6 @@ describe('Testing single-use-code strategy', () => {
});
});

it('should fail if jurisdiction disallows single use code login', async () => {
const id = randomUUID();
prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({
id: id,
lastLoginAt: new Date(),
failedLoginAttemptsCount: 0,
confirmedAt: new Date(),
passwordValidForDays: 100,
passwordUpdatedAt: new Date(),
userRoles: { isAdmin: false },
passwordHash: await passwordToHash('Abcdef12345!'),
mfaEnabled: true,
phoneNumberVerified: false,
singleUseCode: 'zyxwv',
singleUseCodeUpdatedAt: new Date(0),
});

prisma.userAccounts.update = jest.fn().mockResolvedValue({ id });

prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({
id: randomUUID(),
allowSingleUseCodeLogin: false,
});

const request = {
body: {
email: '[email protected]',
singleUseCode: 'zyxwv',
} as LoginViaSingleUseCode,
headers: { jurisdictionname: 'juris 1' },
};

await expect(
async () => await strategy.validate(request as unknown as Request),
).rejects.toThrowError(`Single use code login is not setup for juris 1`);

expect(prisma.userAccounts.findFirst).not.toHaveBeenCalled();

expect(prisma.userAccounts.update).not.toHaveBeenCalled();

expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({
select: {
id: true,
allowSingleUseCodeLogin: true,
},
where: {
name: 'juris 1',
},
orderBy: {
allowSingleUseCodeLogin: OrderByEnum.DESC,
},
});
});

it('should fail if jurisdiction is missing from header', async () => {
const id = randomUUID();
prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({
Expand Down
Loading
Loading