feat: add captcha-backed forum security hardening
This commit is contained in:
@@ -8,6 +8,8 @@ import Toggle from '../../components/ui/Toggle'
|
||||
import Select from '../../components/ui/Select'
|
||||
import Modal from '../../components/ui/Modal'
|
||||
import { RadioGroup } from '../../components/ui/Radio'
|
||||
import { buildBotFingerprint } from '../../lib/security/botFingerprint'
|
||||
import TurnstileField from '../../components/security/TurnstileField'
|
||||
|
||||
const SETTINGS_SECTIONS = [
|
||||
{ key: 'profile', label: 'Profile', icon: 'fa-solid fa-user-astronaut', description: 'Public identity and avatar.' },
|
||||
@@ -57,6 +59,16 @@ function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
async function botHeaders(extra = {}, captcha = {}) {
|
||||
const fingerprint = await buildBotFingerprint()
|
||||
|
||||
return {
|
||||
...extra,
|
||||
'X-Bot-Fingerprint': fingerprint,
|
||||
...(captcha?.token ? { 'X-Captcha-Token': captcha.token } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function toIsoDate(day, month, year) {
|
||||
if (!day || !month || !year) return ''
|
||||
return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
@@ -122,6 +134,8 @@ export default function ProfileEdit() {
|
||||
usernameCooldownDays = 30,
|
||||
usernameCooldownRemainingDays = 0,
|
||||
usernameCooldownActive = false,
|
||||
captcha: initialCaptcha = {},
|
||||
flash = {},
|
||||
} = props
|
||||
|
||||
const fallbackDate = toIsoDate(
|
||||
@@ -194,6 +208,17 @@ export default function ProfileEdit() {
|
||||
notifications: {},
|
||||
security: {},
|
||||
})
|
||||
const [captchaState, setCaptchaState] = useState({
|
||||
required: !!flash?.botCaptchaRequired,
|
||||
section: '',
|
||||
token: '',
|
||||
message: '',
|
||||
nonce: 0,
|
||||
provider: initialCaptcha?.provider || '',
|
||||
siteKey: initialCaptcha?.siteKey || '',
|
||||
inputName: initialCaptcha?.inputName || 'cf-turnstile-response',
|
||||
scriptUrl: initialCaptcha?.scriptUrl || '',
|
||||
})
|
||||
|
||||
const [avatarUrl, setAvatarUrl] = useState(initialAvatarUrl || '')
|
||||
const [avatarFile, setAvatarFile] = useState(null)
|
||||
@@ -346,6 +371,92 @@ export default function ProfileEdit() {
|
||||
setErrorsBySection((prev) => ({ ...prev, [section]: {} }))
|
||||
}
|
||||
|
||||
const resetCaptchaState = () => {
|
||||
setCaptchaState((prev) => ({
|
||||
...prev,
|
||||
required: false,
|
||||
section: '',
|
||||
token: '',
|
||||
message: '',
|
||||
nonce: prev.nonce + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
const captureCaptchaRequirement = (section, payload = {}) => {
|
||||
const requiresCaptcha = !!(payload?.requires_captcha || payload?.requiresCaptcha)
|
||||
|
||||
if (!requiresCaptcha) {
|
||||
return false
|
||||
}
|
||||
|
||||
const nextCaptcha = payload?.captcha || {}
|
||||
const message = payload?.errors?.captcha?.[0] || payload?.message || 'Complete the captcha challenge to continue.'
|
||||
|
||||
setCaptchaState((prev) => ({
|
||||
required: true,
|
||||
section,
|
||||
token: '',
|
||||
message,
|
||||
nonce: prev.nonce + 1,
|
||||
provider: nextCaptcha.provider || payload?.captcha_provider || prev.provider || initialCaptcha?.provider || 'turnstile',
|
||||
siteKey: nextCaptcha.siteKey || payload?.captcha_site_key || prev.siteKey || initialCaptcha?.siteKey || '',
|
||||
inputName: nextCaptcha.inputName || payload?.captcha_input || prev.inputName || initialCaptcha?.inputName || 'cf-turnstile-response',
|
||||
scriptUrl: nextCaptcha.scriptUrl || payload?.captcha_script_url || prev.scriptUrl || initialCaptcha?.scriptUrl || '',
|
||||
}))
|
||||
|
||||
updateSectionErrors(section, {
|
||||
_general: [message],
|
||||
captcha: [message],
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const applyCaptchaPayload = (payload = {}) => {
|
||||
if (!captchaState.required || !captchaState.inputName) {
|
||||
return payload
|
||||
}
|
||||
|
||||
return {
|
||||
...payload,
|
||||
[captchaState.inputName]: captchaState.token || '',
|
||||
}
|
||||
}
|
||||
|
||||
const applyCaptchaFormData = (formData) => {
|
||||
if (captchaState.required && captchaState.inputName) {
|
||||
formData.set(captchaState.inputName, captchaState.token || '')
|
||||
}
|
||||
}
|
||||
|
||||
const renderCaptchaChallenge = (section, placement = 'section') => {
|
||||
if (!captchaState.required || !captchaState.siteKey || activeSection !== section) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (section === 'account' && showEmailChangeModal && placement !== 'modal') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (section === 'account' && !showEmailChangeModal && placement === 'modal') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-400/20 bg-amber-500/10 p-4">
|
||||
<p className="mb-3 text-sm text-amber-100">{captchaState.message || 'Complete the captcha challenge to continue.'}</p>
|
||||
<TurnstileField
|
||||
key={`${section}-${placement}-${captchaState.nonce}`}
|
||||
provider={captchaState.provider}
|
||||
siteKey={captchaState.siteKey}
|
||||
scriptUrl={captchaState.scriptUrl}
|
||||
onToken={(token) => setCaptchaState((prev) => ({ ...prev, token }))}
|
||||
className="rounded-lg border border-white/10 bg-black/20 p-3"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const switchSection = (nextSection) => {
|
||||
if (activeSection === nextSection) return
|
||||
if (dirtyMap[activeSection]) {
|
||||
@@ -397,19 +508,23 @@ export default function ProfileEdit() {
|
||||
if (avatarFile) {
|
||||
formData.append('avatar', avatarFile)
|
||||
}
|
||||
applyCaptchaFormData(formData)
|
||||
|
||||
const response = await fetch('/settings/profile/update', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
headers: await botHeaders({
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
},
|
||||
}, captchaState),
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
if (captureCaptchaRequirement('profile', payload)) {
|
||||
return
|
||||
}
|
||||
updateSectionErrors('profile', payload.errors || { _general: [payload.message || 'Unable to save profile section.'] })
|
||||
return
|
||||
}
|
||||
@@ -421,6 +536,7 @@ export default function ProfileEdit() {
|
||||
setAvatarFile(null)
|
||||
setAvatarPosition('center')
|
||||
setRemoveAvatar(false)
|
||||
resetCaptchaState()
|
||||
setSavedMessage({ section: 'profile', text: payload.message || 'Profile updated successfully.' })
|
||||
} catch (error) {
|
||||
updateSectionErrors('profile', { _general: ['Request failed. Please try again.'] })
|
||||
@@ -446,21 +562,25 @@ export default function ProfileEdit() {
|
||||
const response = await fetch('/settings/account/username', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
headers: await botHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
},
|
||||
body: JSON.stringify({ username: accountForm.username }),
|
||||
}, captchaState),
|
||||
body: JSON.stringify(applyCaptchaPayload({ username: accountForm.username, homepage_url: '' })),
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
if (captureCaptchaRequirement('account', payload)) {
|
||||
return
|
||||
}
|
||||
updateSectionErrors('account', payload.errors || { _general: [payload.message || 'Unable to save account section.'] })
|
||||
return
|
||||
}
|
||||
|
||||
initialRef.current.accountForm = { ...accountForm }
|
||||
resetCaptchaState()
|
||||
setSavedMessage({ section: 'account', text: payload.message || 'Account updated successfully.' })
|
||||
} catch (error) {
|
||||
updateSectionErrors('account', { _general: ['Request failed. Please try again.'] })
|
||||
@@ -478,21 +598,26 @@ export default function ProfileEdit() {
|
||||
const response = await fetch('/settings/email/request', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
headers: await botHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
},
|
||||
body: JSON.stringify({ new_email: emailChangeForm.new_email }),
|
||||
}, captchaState),
|
||||
body: JSON.stringify(applyCaptchaPayload({ new_email: emailChangeForm.new_email, homepage_url: '' })),
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
if (captureCaptchaRequirement('account', payload)) {
|
||||
setEmailChangeError(payload?.errors?.captcha?.[0] || payload?.message || 'Complete the captcha challenge to continue.')
|
||||
return
|
||||
}
|
||||
setEmailChangeError(payload?.errors?.new_email?.[0] || payload?.message || 'Unable to request email change.')
|
||||
return
|
||||
}
|
||||
|
||||
setEmailChangeStep('verify')
|
||||
resetCaptchaState()
|
||||
setEmailChangeInfo(payload.message || 'Verification code sent to your new email address.')
|
||||
} catch (error) {
|
||||
setEmailChangeError('Request failed. Please try again.')
|
||||
@@ -510,16 +635,20 @@ export default function ProfileEdit() {
|
||||
const response = await fetch('/settings/email/verify', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
headers: await botHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
},
|
||||
body: JSON.stringify({ code: emailChangeForm.code }),
|
||||
}, captchaState),
|
||||
body: JSON.stringify(applyCaptchaPayload({ code: emailChangeForm.code, homepage_url: '' })),
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
if (captureCaptchaRequirement('account', payload)) {
|
||||
setEmailChangeError(payload?.errors?.captcha?.[0] || payload?.message || 'Complete the captcha challenge to continue.')
|
||||
return
|
||||
}
|
||||
setEmailChangeError(payload?.errors?.code?.[0] || payload?.message || 'Verification failed.')
|
||||
return
|
||||
}
|
||||
@@ -530,6 +659,7 @@ export default function ProfileEdit() {
|
||||
setShowEmailChangeModal(false)
|
||||
setEmailChangeStep('request')
|
||||
setEmailChangeForm({ new_email: '', code: '' })
|
||||
resetCaptchaState()
|
||||
setSavedMessage({ section: 'account', text: payload.message || 'Email updated successfully.' })
|
||||
} catch (error) {
|
||||
setEmailChangeError('Request failed. Please try again.')
|
||||
@@ -547,25 +677,30 @@ export default function ProfileEdit() {
|
||||
const response = await fetch('/settings/personal/update', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
headers: await botHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
}, captchaState),
|
||||
body: JSON.stringify(applyCaptchaPayload({
|
||||
birthday: toIsoDate(personalForm.day, personalForm.month, personalForm.year) || null,
|
||||
gender: personalForm.gender || null,
|
||||
country: personalForm.country || null,
|
||||
}),
|
||||
homepage_url: '',
|
||||
})),
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
if (captureCaptchaRequirement('personal', payload)) {
|
||||
return
|
||||
}
|
||||
updateSectionErrors('personal', payload.errors || { _general: [payload.message || 'Unable to save personal details.'] })
|
||||
return
|
||||
}
|
||||
|
||||
initialRef.current.personalForm = { ...personalForm }
|
||||
resetCaptchaState()
|
||||
setSavedMessage({ section: 'personal', text: payload.message || 'Personal details saved successfully.' })
|
||||
} catch (error) {
|
||||
updateSectionErrors('personal', { _general: ['Request failed. Please try again.'] })
|
||||
@@ -583,21 +718,25 @@ export default function ProfileEdit() {
|
||||
const response = await fetch('/settings/notifications/update', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
headers: await botHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
},
|
||||
body: JSON.stringify(notificationForm),
|
||||
}, captchaState),
|
||||
body: JSON.stringify(applyCaptchaPayload({ ...notificationForm, homepage_url: '' })),
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
if (captureCaptchaRequirement('notifications', payload)) {
|
||||
return
|
||||
}
|
||||
updateSectionErrors('notifications', payload.errors || { _general: [payload.message || 'Unable to save notifications.'] })
|
||||
return
|
||||
}
|
||||
|
||||
initialRef.current.notificationForm = { ...notificationForm }
|
||||
resetCaptchaState()
|
||||
setSavedMessage({ section: 'notifications', text: payload.message || 'Notification settings saved successfully.' })
|
||||
} catch (error) {
|
||||
updateSectionErrors('notifications', { _general: ['Request failed. Please try again.'] })
|
||||
@@ -615,16 +754,19 @@ export default function ProfileEdit() {
|
||||
const response = await fetch('/settings/security/password', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
headers: await botHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
},
|
||||
body: JSON.stringify(securityForm),
|
||||
}, captchaState),
|
||||
body: JSON.stringify(applyCaptchaPayload({ ...securityForm, homepage_url: '' })),
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
if (captureCaptchaRequirement('security', payload)) {
|
||||
return
|
||||
}
|
||||
updateSectionErrors('security', payload.errors || { _general: [payload.message || 'Unable to update password.'] })
|
||||
return
|
||||
}
|
||||
@@ -634,6 +776,7 @@ export default function ProfileEdit() {
|
||||
new_password: '',
|
||||
new_password_confirmation: '',
|
||||
})
|
||||
resetCaptchaState()
|
||||
setSavedMessage({ section: 'security', text: payload.message || 'Password updated successfully.' })
|
||||
} catch (error) {
|
||||
updateSectionErrors('security', { _general: ['Request failed. Please try again.'] })
|
||||
@@ -857,6 +1000,8 @@ export default function ProfileEdit() {
|
||||
rows={3}
|
||||
error={errorsBySection.profile.description?.[0]}
|
||||
/>
|
||||
|
||||
{renderCaptchaChallenge('profile')}
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
@@ -933,6 +1078,8 @@ export default function ProfileEdit() {
|
||||
<p className="mt-4 rounded-lg border border-white/10 bg-white/[0.02] px-3 py-2 text-xs text-slate-300">
|
||||
You can change your username once every {usernameCooldownDays} days.
|
||||
</p>
|
||||
|
||||
{renderCaptchaChallenge('account')}
|
||||
</SectionCard>
|
||||
</form>
|
||||
) : null}
|
||||
@@ -1034,6 +1181,8 @@ export default function ProfileEdit() {
|
||||
error={errorsBySection.personal.country?.[0]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{renderCaptchaChallenge('personal')}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</form>
|
||||
@@ -1085,6 +1234,8 @@ export default function ProfileEdit() {
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{renderCaptchaChallenge('notifications')}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</form>
|
||||
@@ -1152,6 +1303,8 @@ export default function ProfileEdit() {
|
||||
<div className="rounded-lg border border-white/5 bg-white/[0.02] p-3 text-xs text-slate-400">
|
||||
Future security controls: Two-factor authentication, active sessions, and login history.
|
||||
</div>
|
||||
|
||||
{renderCaptchaChallenge('security')}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</form>
|
||||
@@ -1221,6 +1374,8 @@ export default function ProfileEdit() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{renderCaptchaChallenge('account', 'modal')}
|
||||
|
||||
{emailChangeStep === 'request' ? (
|
||||
<TextInput
|
||||
label="Enter new email address"
|
||||
|
||||
Reference in New Issue
Block a user