Skip to content
This repository was archived by the owner on Feb 4, 2025. It is now read-only.

Commit 02ec353

Browse files
authored
Merge pull request #534 from redpwn/feature/recaptcha
2 parents c204b23 + f6c2cc8 commit 02ec353

File tree

25 files changed

+339
-80
lines changed

25 files changed

+339
-80
lines changed

client/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
<meta name="mobile-web-app-capable" content="yes">
88
<meta name="apple-mobile-web-app-capable" content="yes">
99

10+
<link rel="shortcut icon" href="{{ config.faviconUrl }}">
1011
<meta name="title" content="{{ config.ctfName }}">
1112
<meta name="description" content="{{ config.meta.description }}">
1213
<meta property="og:type" content="website">

client/src/api/auth.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,12 @@ export const verify = async ({ verifyToken }) => {
5858
}
5959
}
6060

61-
export const register = async ({ email, name, ctftimeToken }) => {
61+
export const register = async ({ email, name, ctftimeToken, recaptchaCode }) => {
6262
const resp = await request('POST', '/auth/register', {
6363
email,
6464
name,
65-
ctftimeToken
65+
ctftimeToken,
66+
recaptchaCode
6667
})
6768
switch (resp.kind) {
6869
case 'goodRegister':
@@ -113,9 +114,10 @@ export const deleteCtftime = () => {
113114
return request('DELETE', '/users/me/auth/ctftime')
114115
}
115116

116-
export const recover = async ({ email }) => {
117+
export const recover = async ({ email, recaptchaCode }) => {
117118
const resp = await request('POST', '/auth/recover', {
118-
email
119+
email,
120+
recaptchaCode
119121
})
120122
switch (resp.kind) {
121123
case 'goodVerifySent':

client/src/api/profile.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,10 @@ export const updateAccount = async ({ name, division }) => {
4848
return handleResponse({ resp, valid: ['goodUserUpdate'] })
4949
}
5050

51-
export const updateEmail = async ({ email }) => {
51+
export const updateEmail = async ({ email, recaptchaCode }) => {
5252
const resp = await request('PUT', '/users/me/auth/email', {
53-
email
53+
email,
54+
recaptchaCode
5455
})
5556

5657
return handleResponse({ resp, valid: ['goodVerifySent', 'goodEmailSet'], resolveDataMessage: true })

client/src/components/profile/members-card.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ const MembersCard = withStyles({
8080
<div class='card'>
8181
<div class='content'>
8282
<p>Team Information</p>
83-
<p class='font-thin u-no-margin'>There is no limit on team members. Please enter a separate email for each team member. This data is collected for informational purposes only. Ensure that this section is up to date in order to remain prize eligible.</p>
83+
<p class='font-thin u-no-margin'>Please enter a separate email for each team member. This data is collected for informational purposes only. Ensure that this section is up to date in order to remain prize eligible.</p>
8484
<div class='row u-center'>
8585
<Form class={`col-12 ${classes.form}`} onSubmit={handleSubmit} disabled={buttonDisabled} buttonText='Add Member'>
8686
<input

client/src/components/recaptcha.js

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import config from '../config'
2+
import withStyles from './jss'
3+
import { useEffect, useCallback } from 'preact/hooks'
4+
5+
const loadRecaptchaScript = () => new Promise((resolve, reject) => {
6+
const script = document.createElement('script')
7+
script.src = 'https://www.google.com/recaptcha/api.js?render=explicit'
8+
script.addEventListener('load', () => {
9+
window.grecaptcha.ready(() => resolve(window.grecaptcha))
10+
})
11+
script.addEventListener('error', reject)
12+
document.body.appendChild(script)
13+
})
14+
15+
const recaptchaQueue = []
16+
let recaptchaPromise
17+
let recaptchaId
18+
const handleRecaptchaDone = async (code) => {
19+
(await loadRecaptcha()).reset(recaptchaId)
20+
const { resolve } = recaptchaQueue.shift()
21+
resolve(code)
22+
handleRecaptchaNext()
23+
}
24+
const handleRecaptchaError = async (err) => {
25+
(await loadRecaptcha()).reset(recaptchaId)
26+
const { reject } = recaptchaQueue.shift()
27+
reject(err)
28+
handleRecaptchaNext()
29+
}
30+
const handleRecaptchaNext = async () => {
31+
if (recaptchaQueue.length === 0) {
32+
return
33+
}
34+
(await loadRecaptcha()).execute(recaptchaId)
35+
}
36+
const loadRecaptcha = async () => {
37+
if (!recaptchaPromise) {
38+
recaptchaPromise = loadRecaptchaScript()
39+
}
40+
if (!recaptchaId) {
41+
recaptchaId = (await recaptchaPromise).render({
42+
theme: 'dark',
43+
sitekey: config.recaptcha.siteKey,
44+
callback: handleRecaptchaDone,
45+
'error-callback': handleRecaptchaError
46+
})
47+
}
48+
return recaptchaPromise
49+
}
50+
const requestRecaptchaCode = () => new Promise((resolve, reject) => {
51+
recaptchaQueue.push({ resolve, reject })
52+
handleRecaptchaNext()
53+
})
54+
55+
// exported for legacy class component usage
56+
export { loadRecaptcha, requestRecaptchaCode }
57+
58+
export const RecaptchaLegalNotice = withStyles({
59+
root: {
60+
fontSize: '12px',
61+
textAlign: 'center'
62+
},
63+
link: {
64+
display: 'inline',
65+
padding: '0'
66+
}
67+
}, ({ classes }) => (
68+
<div class={classes.root}>
69+
This site is protected by reCAPTCHA.
70+
<br />
71+
The Google{' '}
72+
<a class={classes.link} href='https://policies.google.com/privacy' target='_blank' rel='noopener noreferrer'>Privacy Policy</a>
73+
{' '}and{' '}
74+
<a class={classes.link} href='https://policies.google.com/terms' target='_blank' rel='noopener noreferrer'>Terms of Service</a>
75+
{' '}apply.
76+
</div>
77+
))
78+
79+
export default (action) => {
80+
const recaptchaEnabled = config.recaptcha?.protectedActions.includes(action)
81+
useEffect(() => {
82+
if (recaptchaEnabled) {
83+
loadRecaptcha()
84+
}
85+
}, [recaptchaEnabled])
86+
const callback = useCallback(requestRecaptchaCode, [recaptchaEnabled])
87+
if (recaptchaEnabled) {
88+
return callback
89+
}
90+
}

client/src/index.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,14 @@ export default withStyles({
9393
'@global body': {
9494
overflowX: 'hidden'
9595
},
96+
'@global .grecaptcha-badge': {
97+
// we show the google legal notice on each protected form
98+
visibility: 'hidden'
99+
},
100+
// cirrus makes recaptcha position the modal incorrectly, so we reset it here
101+
'@global body > div[style*="position: absolute"]': {
102+
top: '10px !important'
103+
},
96104
root: {
97105
display: 'flex',
98106
flexDirection: 'column',

client/src/routes/challs.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,11 +182,11 @@ const Challenges = ({ classes }) => {
182182
<div class='frame__body'>
183183
<div class='frame__title title'>Categories</div>
184184
{
185-
Object.entries(categories).sort((a, b) => a[0].localeCompare(b[0])).map(([category, checked]) => {
185+
Array.from(categoryCounts.entries()).sort((a, b) => a[0].localeCompare(b[0])).map(([category, { solved, total }]) => {
186186
return (
187187
<div key={category} class='form-ext-control form-ext-checkbox'>
188-
<input id={`category-${category}`} data-category={category} class='form-ext-input' type='checkbox' checked={checked} onChange={handleCategoryCheckedChange} />
189-
<label for={`category-${category}`} class='form-ext-label'>{category} ({categoryCounts.get(category).solved}/{categoryCounts.get(category).total} solved)</label>
188+
<input id={`category-${category}`} data-category={category} class='form-ext-input' type='checkbox' checked={categories[category]} onChange={handleCategoryCheckedChange} />
189+
<label for={`category-${category}`} class='form-ext-label'>{category} ({solved}/{total} solved)</label>
190190
</div>
191191
)
192192
})

client/src/routes/login.js

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ export default withStyles({
128128
setAuthToken({ authToken: this.state.pendingAuthToken })
129129
}
130130

131-
handleSubmit = e => {
131+
handleSubmit = async (e) => {
132132
e.preventDefault()
133133
this.setState({
134134
disabledButton: true
@@ -143,16 +143,16 @@ export default withStyles({
143143
}
144144
} catch {}
145145

146-
login({ teamToken })
147-
.then(result => {
148-
if (result.authToken) {
149-
setAuthToken({ authToken: result.authToken })
150-
return
151-
}
152-
this.setState({
153-
errors: result,
154-
disabledButton: false
155-
})
156-
})
146+
const result = await login({
147+
teamToken
148+
})
149+
if (result.authToken) {
150+
setAuthToken({ authToken: result.authToken })
151+
return
152+
}
153+
this.setState({
154+
errors: result,
155+
disabledButton: false
156+
})
157157
}
158158
})

client/src/routes/profile.js

Lines changed: 42 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import UserCircle from '../icons/user-circle.svg'
1717
import EnvelopeOpen from '../icons/envelope-open.svg'
1818
import Rank from '../icons/rank.svg'
1919
import Ctftime from '../icons/ctftime.svg'
20+
import useRecaptcha, { RecaptchaLegalNotice } from '../components/recaptcha'
2021

2122
const SummaryCard = memo(withStyles({
2223
icon: {
@@ -159,9 +160,13 @@ const UpdateCard = withStyles({
159160
},
160161
divisionSelect: {
161162
paddingLeft: '2.75rem'
163+
},
164+
recaptchaLegalNotice: {
165+
marginTop: '20px'
162166
}
163167
}, ({ name: oldName, email: oldEmail, divisionId: oldDivision, allowedDivisions, onUpdate, classes }) => {
164168
const { toast } = useToast()
169+
const requestRecaptchaCode = useRecaptcha('setEmail')
165170

166171
const [name, setName] = useState(oldName)
167172
const handleSetName = useCallback((e) => setName(e.target.value), [])
@@ -174,7 +179,7 @@ const UpdateCard = withStyles({
174179

175180
const [isButtonDisabled, setIsButtonDisabled] = useState(false)
176181

177-
const doUpdate = useCallback((e) => {
182+
const doUpdate = useCallback(async (e) => {
178183
e.preventDefault()
179184

180185
let updated = false
@@ -183,65 +188,62 @@ const UpdateCard = withStyles({
183188
updated = true
184189

185190
setIsButtonDisabled(true)
186-
updateAccount({
191+
const { error, data } = await updateAccount({
187192
name: oldName === name ? undefined : name,
188193
division: oldDivision === division ? undefined : division
189194
})
190-
.then(({ error, data }) => {
191-
setIsButtonDisabled(false)
195+
setIsButtonDisabled(false)
192196

193-
if (error !== undefined) {
194-
toast({ body: error, type: 'error' })
195-
return
196-
}
197+
if (error !== undefined) {
198+
toast({ body: error, type: 'error' })
199+
return
200+
}
197201

198-
toast({ body: 'Profile updated' })
202+
toast({ body: 'Profile updated' })
199203

200-
onUpdate({
201-
name: data.user.name,
202-
divisionId: data.user.division
203-
})
204-
})
204+
onUpdate({
205+
name: data.user.name,
206+
divisionId: data.user.division
207+
})
205208
}
206209

207210
if (email !== oldEmail) {
208211
updated = true
209212

210-
setIsButtonDisabled(true)
211-
212-
const handleResponse = ({ error, data }) => {
213-
setIsButtonDisabled(false)
213+
let error, data
214+
if (email === '') {
215+
setIsButtonDisabled(true)
216+
;({ error, data } = await deleteEmail())
217+
} else {
218+
const recaptchaCode = await requestRecaptchaCode?.()
219+
setIsButtonDisabled(true)
220+
;({ error, data } = await updateEmail({
221+
email,
222+
recaptchaCode
223+
}))
224+
}
214225

215-
if (error !== undefined) {
216-
toast({ body: error, type: 'error' })
217-
return
218-
}
226+
setIsButtonDisabled(false)
219227

220-
toast({ body: data })
221-
onUpdate({ email })
228+
if (error !== undefined) {
229+
toast({ body: error, type: 'error' })
230+
return
222231
}
223232

224-
if (email === '') {
225-
deleteEmail()
226-
.then(handleResponse)
227-
} else {
228-
updateEmail({
229-
email
230-
})
231-
.then(handleResponse)
232-
}
233+
toast({ body: data })
234+
onUpdate({ email })
233235
}
234236

235237
if (!updated) {
236238
toast({ body: 'Nothing to update!' })
237239
}
238-
}, [name, email, division, oldName, oldEmail, oldDivision, onUpdate, toast])
240+
}, [name, email, division, oldName, oldEmail, oldDivision, onUpdate, toast, requestRecaptchaCode])
239241

240242
return (
241243
<div class='card'>
242244
<div class='content'>
243245
<p>Update Information</p>
244-
<p class='font-thin u-no-margin'>This will change how your team appears on the scoreboard. Note that you may only change your team's name once every 10 minutes.</p>
246+
<p class='font-thin u-no-margin'>This will change how your team appears on the scoreboard. You may only change your team's name once every 10 minutes.</p>
245247
<div class='row u-center'>
246248
<Form class={`col-12 ${classes.form}`} onSubmit={doUpdate} disabled={isButtonDisabled} buttonText='Update'>
247249
<input
@@ -276,6 +278,11 @@ const UpdateCard = withStyles({
276278
}
277279
</select>
278280
</Form>
281+
{requestRecaptchaCode && (
282+
<div class={classes.recaptchaLegalNotice}>
283+
<RecaptchaLegalNotice />
284+
</div>
285+
)}
279286
</div>
280287
</div>
281288
</div>

0 commit comments

Comments
 (0)