Skip to content

Commit

Permalink
feat: integrate captcha for auth resolver
Browse files Browse the repository at this point in the history
  • Loading branch information
darkskygit committed Mar 29, 2024
1 parent 711632a commit db49ad9
Show file tree
Hide file tree
Showing 5 changed files with 55 additions and 70 deletions.
2 changes: 2 additions & 0 deletions packages/backend/server/src/core/auth/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import { TokenService, TokenType } from './token';
class SignInCredential {
email!: string;
password?: string;
token?: string;
challenge?: string;
}

@Controller('/api/auth')
Expand Down
31 changes: 26 additions & 5 deletions packages/backend/server/src/core/auth/resolver.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
BadRequestException,
ForbiddenException,
Optional,
UseGuards,
} from '@nestjs/common';
import {
Expand All @@ -17,6 +18,7 @@ import {
import type { Request, Response } from 'express';

import { CloudThrottlerGuard, Config, Throttle } from '../../fundamentals';
import { CaptchaService } from '../../plugins/captcha/service';
import { UserService } from '../user';
import { UserType } from '../user/types';
import { validators } from '../utils/validators';
Expand Down Expand Up @@ -50,7 +52,8 @@ export class AuthResolver {
private readonly config: Config,
private readonly auth: AuthService,
private readonly user: UserService,
private readonly token: TokenService
private readonly token: TokenService,
@Optional() private readonly captcha?: CaptchaService
) {}

@Throttle({
Expand Down Expand Up @@ -112,9 +115,18 @@ export class AuthResolver {
@Context() ctx: { req: Request; res: Response },
@Args('name') name: string,
@Args('email') email: string,
@Args('password') password: string
@Args('password') password: string,
@Args('token') token: string,
@Args('challenge') challenge?: string
) {
validators.assertValidCredential({ email, password });
const credential = validators.assertValidCredential({
email,
password,
challenge,
token,
});
await this.captcha?.verifyRequest(credential, ctx.req);

const user = await this.auth.signUp(name, email, password);
await this.auth.setCookie(ctx.req, ctx.res, user);
ctx.req.user = user;
Expand All @@ -132,9 +144,18 @@ export class AuthResolver {
async signIn(
@Context() ctx: { req: Request; res: Response },
@Args('email') email: string,
@Args('password') password: string
@Args('password') password: string,
@Args('token') token: string,
@Args('challenge') challenge?: string
) {
validators.assertValidEmail(email);
const credential = validators.assertValidCredential({
email,
password,
challenge,
token,
});
await this.captcha?.verifyRequest(credential, ctx.req);

const user = await this.auth.signIn(email, password);
await this.auth.setCookie(ctx.req, ctx.res, user);
ctx.req.user = user;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,33 +12,28 @@ test('can validate', t => {
t.throws(() => validators.assertValidPassword(''));
t.throws(() => validators.assertValidPassword('aaaaaaaaaaaaaaaaaaaaa'));

// verify with captcha
t.notThrows(() =>
validators.assertValidCredential({
email: '[email protected]',
password: 'password',
token: 'captchaToken',
})
);
// verify with challenge
t.notThrows(() =>
validators.assertValidCredential({
email: '[email protected]',
password: 'password',
token: 'verifyToken',
challenge: 'challenge',
})
);
t.notThrows(() =>
validators.assertValidCredential({
email: '[email protected]',
password: 'password',
verifyToken: 'verifyToken',
})
);
// challenge and verifyToken should not be both provided
t.throws(() =>
validators.assertValidCredential({
email: '[email protected]',
password: 'password',
challenge: 'challenge',
verifyToken: 'verifyToken',
})
);
});
44 changes: 12 additions & 32 deletions packages/backend/server/src/core/utils/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,33 +17,13 @@ function getAuthCredentialValidator() {
.object({
email,
password,
})
.required();
}

function getAuthCredentialWithCaptchaValidator() {
return getAuthCredentialValidator()
.extend({
verifyToken: z.string().optional(),
token: z.string(),
challenge: z.string().optional(),
})
.refine(
data => {
const hasChallenge = !!data.challenge;
const hasVerifyToken = !!data.verifyToken;
return (
(!hasChallenge && !hasVerifyToken) ||
(hasChallenge && !hasVerifyToken) ||
(!hasChallenge && hasVerifyToken)
);
},
{
message: 'verifyToken and challenge should not be both provided',
}
);
.strict();
}

function assertValid<T>(z: z.ZodType<T>, value: unknown) {
function assertValid<T>(z: z.ZodType<T>, value: unknown): T {
const result = z.safeParse(value);

if (!result.success) {
Expand All @@ -54,25 +34,25 @@ function assertValid<T>(z: z.ZodType<T>, value: unknown) {
throw new BadRequestException('Invalid credential');
}
}
return result.data;
}

export function assertValidEmail(email: string) {
assertValid(getAuthCredentialValidator().shape.email, email);
return assertValid(getAuthCredentialValidator().shape.email, email);
}

export function assertValidPassword(password: string) {
assertValid(getAuthCredentialValidator().shape.password, password);
return assertValid(getAuthCredentialValidator().shape.password, password);
}

export function assertValidCredential(credential: {
email: string;
password: string;
challenge?: string;
verifyToken?: string;
}) {
assertValid(getAuthCredentialWithCaptchaValidator(), credential);
export function assertValidCredential(
credential: Omit<Credential, 'token'> & { token?: string }
) {
return assertValid(getAuthCredentialValidator(), credential);
}

export type Credential = z.infer<ReturnType<typeof getAuthCredentialValidator>>;

export const validators = {
assertValidEmail,
assertValidPassword,
Expand Down
35 changes: 11 additions & 24 deletions packages/backend/server/src/plugins/captcha/service.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import assert from 'node:assert';
import { randomUUID } from 'node:crypto';

import { Injectable, Logger } from '@nestjs/common';
import type { Request, Response } from 'express';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import type { Request } from 'express';
import { nanoid } from 'nanoid';

import { TokenService, TokenType } from '../../core/auth/token';
import { Credential } from '../../core/utils/validators';
import { Config, verifyChallengeResponse } from '../../fundamentals';
import { CaptchaConfig } from './types';

Expand Down Expand Up @@ -68,52 +69,38 @@ export class CaptchaService {
};
}

private rejectResponse(res: Response, error: string, status = 400) {
res.status(status);
res.json({
url: `${this.config.baseUrl}/api/auth/error?${new URLSearchParams({
error,
}).toString()}`,
error,
});
}

async verifyRequest(req: Request, res: Response): Promise<boolean> {
const challenge = req.query?.challenge;
async verifyRequest(credential: Credential, req: Request) {
const challenge = credential.challenge;
if (typeof challenge === 'string' && challenge) {
const resource = await this.token
.verifyToken(TokenType.Challenge, challenge)
.then(token => token?.credential);

if (!resource) {
this.rejectResponse(res, 'Invalid Challenge');
return false;
throw new BadRequestException('Invalid Challenge');
}

const isChallengeVerified = await this.verifyChallengeResponse(
req.query?.token,
credential.token,
resource
);

this.logger.debug(
`Challenge: ${challenge}, Resource: ${resource}, Response: ${req.query?.token}, isChallengeVerified: ${isChallengeVerified}`
`Challenge: ${challenge}, Resource: ${resource}, Response: ${credential.token}, isChallengeVerified: ${isChallengeVerified}`
);

if (!isChallengeVerified) {
this.rejectResponse(res, 'Invalid Challenge Response');
return false;
throw new BadRequestException('Invalid Challenge Response');
}
} else {
const isTokenVerified = await this.verifyCaptchaToken(
req.query?.token,
credential.token,
req.headers['CF-Connecting-IP'] as string
);

if (!isTokenVerified) {
this.rejectResponse(res, 'Invalid Captcha Response');
return false;
throw new BadRequestException('Invalid Captcha Response');
}
}
return true;
}
}

0 comments on commit db49ad9

Please sign in to comment.