-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathcaptcha.ts
More file actions
130 lines (115 loc) · 3.91 KB
/
captcha.ts
File metadata and controls
130 lines (115 loc) · 3.91 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
import { Logger } from '@pgpmjs/logger';
import type { ConstructiveOptions } from '@constructive-io/graphql-types';
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import './types'; // for Request type
const log = new Logger('captcha');
/** Google reCAPTCHA verification endpoint */
const RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
/**
* Header name the client sends the CAPTCHA response token in.
* Follows the common pattern: X-Captcha-Token.
*/
const CAPTCHA_HEADER = 'x-captcha-token';
/**
* GraphQL mutation names that require CAPTCHA verification when enabled.
* Only sign-up and password-reset are gated; normal sign-in is not.
*/
const CAPTCHA_PROTECTED_OPERATIONS = new Set([
'signUp',
'signUpWithMagicLink',
'signUpWithSms',
'resetPassword',
'requestPasswordReset',
]);
interface RecaptchaResponse {
success: boolean;
'error-codes'?: string[];
}
/**
* Attempt to extract the GraphQL operation name from the request body.
* Works for both JSON and already-parsed bodies.
*/
const getOperationName = (req: Request): string | undefined => {
const body = (req as any).body;
if (!body) return undefined;
// Already parsed (express.json ran first)
if (typeof body === 'object' && body.operationName) {
return body.operationName;
}
return undefined;
};
/**
* Verify a reCAPTCHA token with Google's API.
*/
const verifyToken = async (token: string, secretKey: string): Promise<boolean> => {
try {
const params = new URLSearchParams({ secret: secretKey, response: token });
const res = await fetch(RECAPTCHA_VERIFY_URL, {
method: 'POST',
body: params,
});
const data = (await res.json()) as RecaptchaResponse;
if (!data.success) {
log.debug(`[captcha] Verification failed: ${data['error-codes']?.join(', ') ?? 'unknown'}`);
}
return data.success;
} catch (e: any) {
log.error('[captcha] Error verifying token:', e.message);
return false;
}
};
/**
* Creates a CAPTCHA verification middleware.
*
* When `enable_captcha` is true in app_auth_settings, this middleware checks
* the X-Captcha-Token header on protected mutations (sign-up, password reset).
* The secret key is read from the RECAPTCHA_SECRET_KEY environment variable
* (the public site key is stored in app_auth_settings for the frontend).
*
* Skips verification when:
* - CAPTCHA is not enabled in auth settings
* - The request is not a protected mutation
* - No secret key is configured server-side
*/
export const createCaptchaMiddleware = (opts: ConstructiveOptions): RequestHandler => {
const secretKey = opts.captcha?.recaptchaSecretKey;
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const authSettings = req.api?.authSettings;
// Skip if CAPTCHA is not enabled
if (!authSettings?.enableCaptcha) {
return next();
}
// Only gate protected operations
const opName = getOperationName(req);
if (!opName || !CAPTCHA_PROTECTED_OPERATIONS.has(opName)) {
return next();
}
// Secret key must be set server-side (via opts.captcha.recaptchaSecretKey, not stored in DB)
if (!secretKey) {
log.warn('[captcha] enable_captcha is true but captcha.recaptchaSecretKey is not configured; skipping verification');
return next();
}
const captchaToken = req.get(CAPTCHA_HEADER);
if (!captchaToken) {
res.status(200).json({
errors: [{
message: 'CAPTCHA verification required',
extensions: { code: 'CAPTCHA_REQUIRED' },
}],
});
return;
}
const valid = await verifyToken(captchaToken, secretKey);
if (!valid) {
res.status(200).json({
errors: [{
message: 'CAPTCHA verification failed',
extensions: { code: 'CAPTCHA_FAILED' },
}],
});
return;
}
log.info(`[captcha] Verified for operation=${opName}`);
next();
};
};