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"
|
||||
|
||||
165
resources/js/components/security/TurnstileField.jsx
Normal file
165
resources/js/components/security/TurnstileField.jsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
|
||||
const providerAdapters = {
|
||||
turnstile: {
|
||||
globalName: 'turnstile',
|
||||
render(api, container, { siteKey, theme, onToken }) {
|
||||
return api.render(container, {
|
||||
sitekey: siteKey,
|
||||
theme,
|
||||
callback: (token) => onToken?.(token || ''),
|
||||
'expired-callback': () => onToken?.(''),
|
||||
'error-callback': () => onToken?.(''),
|
||||
})
|
||||
},
|
||||
cleanup(api, widgetId, container, onToken) {
|
||||
if (widgetId !== null && api?.remove) {
|
||||
api.remove(widgetId)
|
||||
}
|
||||
if (container) {
|
||||
container.innerHTML = ''
|
||||
}
|
||||
onToken?.('')
|
||||
},
|
||||
},
|
||||
recaptcha: {
|
||||
globalName: 'grecaptcha',
|
||||
render(api, container, { siteKey, theme, onToken }) {
|
||||
return api.render(container, {
|
||||
sitekey: siteKey,
|
||||
theme,
|
||||
callback: (token) => onToken?.(token || ''),
|
||||
'expired-callback': () => onToken?.(''),
|
||||
'error-callback': () => onToken?.(''),
|
||||
})
|
||||
},
|
||||
cleanup(api, widgetId, container, onToken) {
|
||||
if (widgetId !== null && api?.reset) {
|
||||
api.reset(widgetId)
|
||||
}
|
||||
if (container) {
|
||||
container.innerHTML = ''
|
||||
}
|
||||
onToken?.('')
|
||||
},
|
||||
},
|
||||
hcaptcha: {
|
||||
globalName: 'hcaptcha',
|
||||
render(api, container, { siteKey, theme, onToken }) {
|
||||
return api.render(container, {
|
||||
sitekey: siteKey,
|
||||
theme,
|
||||
callback: (token) => onToken?.(token || ''),
|
||||
'expired-callback': () => onToken?.(''),
|
||||
'error-callback': () => onToken?.(''),
|
||||
})
|
||||
},
|
||||
cleanup(api, widgetId, container, onToken) {
|
||||
if (widgetId !== null && api?.remove) {
|
||||
api.remove(widgetId)
|
||||
}
|
||||
if (container) {
|
||||
container.innerHTML = ''
|
||||
}
|
||||
onToken?.('')
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function loadCaptchaScript(src) {
|
||||
if (!src) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
if (!window.__skinbaseCaptchaScripts) {
|
||||
window.__skinbaseCaptchaScripts = {}
|
||||
}
|
||||
|
||||
if (!window.__skinbaseCaptchaScripts[src]) {
|
||||
window.__skinbaseCaptchaScripts[src] = new Promise((resolve, reject) => {
|
||||
const existing = document.querySelector(`script[src="${src}"]`)
|
||||
|
||||
if (existing) {
|
||||
if (existing.dataset.loaded === 'true') {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
existing.addEventListener('load', () => resolve(), { once: true })
|
||||
existing.addEventListener('error', () => reject(new Error(`Failed to load captcha script: ${src}`)), { once: true })
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.src = src
|
||||
script.async = true
|
||||
script.defer = true
|
||||
script.addEventListener('load', () => {
|
||||
script.dataset.loaded = 'true'
|
||||
resolve()
|
||||
}, { once: true })
|
||||
script.addEventListener('error', () => reject(new Error(`Failed to load captcha script: ${src}`)), { once: true })
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
return window.__skinbaseCaptchaScripts[src]
|
||||
}
|
||||
|
||||
export default function TurnstileField({ provider = 'turnstile', siteKey, scriptUrl = '', onToken, theme = 'dark', className = '' }) {
|
||||
const containerRef = useRef(null)
|
||||
const widgetIdRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const adapter = providerAdapters[provider] || providerAdapters.turnstile
|
||||
|
||||
if (!siteKey || !containerRef.current) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
let intervalId = null
|
||||
|
||||
const mountWidget = () => {
|
||||
const api = window[adapter.globalName]
|
||||
|
||||
if (cancelled || !api?.render || widgetIdRef.current !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
widgetIdRef.current = adapter.render(api, containerRef.current, {
|
||||
siteKey,
|
||||
theme,
|
||||
onToken,
|
||||
})
|
||||
}
|
||||
|
||||
loadCaptchaScript(scriptUrl).catch(() => onToken?.('')).finally(() => {
|
||||
const api = window[adapter.globalName]
|
||||
if (typeof api?.ready === 'function') {
|
||||
api.ready(mountWidget)
|
||||
} else {
|
||||
mountWidget()
|
||||
}
|
||||
|
||||
if (widgetIdRef.current === null) {
|
||||
intervalId = window.setInterval(mountWidget, 250)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (intervalId) {
|
||||
window.clearInterval(intervalId)
|
||||
}
|
||||
adapter.cleanup(window[adapter.globalName], widgetIdRef.current, containerRef.current, onToken)
|
||||
widgetIdRef.current = null
|
||||
}
|
||||
}, [className, onToken, provider, scriptUrl, siteKey, theme])
|
||||
|
||||
if (!siteKey) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div ref={containerRef} className={className} />
|
||||
}
|
||||
65
resources/js/lib/security/botFingerprint.js
Normal file
65
resources/js/lib/security/botFingerprint.js
Normal file
@@ -0,0 +1,65 @@
|
||||
async function sha256Hex(value) {
|
||||
if (!window.crypto?.subtle) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const encoded = new TextEncoder().encode(value)
|
||||
const digest = await window.crypto.subtle.digest('SHA-256', encoded)
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((part) => part.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
function readWebglVendor() {
|
||||
try {
|
||||
const canvas = document.createElement('canvas')
|
||||
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl')
|
||||
if (!gl) {
|
||||
return 'no-webgl'
|
||||
}
|
||||
|
||||
const extension = gl.getExtension('WEBGL_debug_renderer_info')
|
||||
if (!extension) {
|
||||
return 'webgl-hidden'
|
||||
}
|
||||
|
||||
return [
|
||||
gl.getParameter(extension.UNMASKED_VENDOR_WEBGL),
|
||||
gl.getParameter(extension.UNMASKED_RENDERER_WEBGL),
|
||||
].join(':')
|
||||
} catch {
|
||||
return 'webgl-error'
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildBotFingerprint() {
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'unknown'
|
||||
const screenSize = typeof window.screen !== 'undefined'
|
||||
? `${window.screen.width}x${window.screen.height}x${window.devicePixelRatio || 1}`
|
||||
: 'no-screen'
|
||||
|
||||
const payload = [
|
||||
navigator.userAgent || 'unknown-ua',
|
||||
navigator.language || 'unknown-language',
|
||||
navigator.platform || 'unknown-platform',
|
||||
timezone,
|
||||
screenSize,
|
||||
readWebglVendor(),
|
||||
].join('|')
|
||||
|
||||
return sha256Hex(payload)
|
||||
}
|
||||
|
||||
export async function populateBotFingerprint(form) {
|
||||
if (!form) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const fingerprint = await buildBotFingerprint()
|
||||
const field = form.querySelector('input[name="_bot_fingerprint"]')
|
||||
if (field && fingerprint !== '') {
|
||||
field.value = fingerprint
|
||||
}
|
||||
|
||||
return fingerprint
|
||||
}
|
||||
@@ -16,10 +16,23 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($errors->has('bot'))
|
||||
<div class="rounded-lg bg-red-900/40 border border-red-500/40 px-4 py-3 text-sm text-red-300 mb-4">
|
||||
{{ $errors->first('bot') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@include('auth.partials.social-login')
|
||||
|
||||
<form method="POST" action="{{ route('login') }}" class="space-y-5">
|
||||
<form method="POST" action="{{ route('login') }}" class="space-y-5" data-bot-form>
|
||||
@csrf
|
||||
<input type="text" name="homepage_url" value="" tabindex="-1" autocomplete="off" class="hidden" aria-hidden="true">
|
||||
<input type="hidden" name="_bot_fingerprint" value="">
|
||||
|
||||
@php
|
||||
$captchaProvider = $captcha['provider'] ?? 'turnstile';
|
||||
$captchaSiteKey = $captcha['siteKey'] ?? '';
|
||||
@endphp
|
||||
|
||||
<div>
|
||||
<label class="block text-sm mb-1 text-white/80" for="email">Email</label>
|
||||
@@ -33,6 +46,17 @@
|
||||
<x-input-error :messages="$errors->get('password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
@if(($requiresCaptcha ?? false) && $captchaSiteKey !== '')
|
||||
@if($captchaProvider === 'recaptcha')
|
||||
<div class="g-recaptcha" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
|
||||
@elseif($captchaProvider === 'hcaptcha')
|
||||
<div class="h-captcha" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
|
||||
@else
|
||||
<div class="cf-turnstile" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
|
||||
@endif
|
||||
<x-input-error :messages="$errors->get('captcha')" class="mt-2" />
|
||||
@endif
|
||||
|
||||
<div class="flex items-center justify-between text-sm text-white/60">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" name="remember" class="rounded bg-slate-800 border-white/20" />
|
||||
@@ -51,4 +75,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if(($requiresCaptcha ?? false) && (($captcha['siteKey'] ?? '') !== '') && (($captcha['scriptUrl'] ?? '') !== ''))
|
||||
<script src="{{ $captcha['scriptUrl'] }}" async defer></script>
|
||||
@endif
|
||||
@include('partials.bot-fingerprint-script')
|
||||
@endsection
|
||||
|
||||
@@ -13,9 +13,22 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($errors->has('bot'))
|
||||
<div class="rounded-lg bg-red-900/40 border border-red-500/40 px-4 py-3 text-sm text-red-300 mb-4">
|
||||
{{ $errors->first('bot') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@include('auth.partials.social-login', ['dividerLabel' => 'or register with email'])
|
||||
<form method="POST" action="{{ route('register') }}" class="space-y-5">
|
||||
<form method="POST" action="{{ route('register') }}" class="space-y-5" data-bot-form>
|
||||
@csrf
|
||||
<input type="text" name="homepage_url" value="" tabindex="-1" autocomplete="off" class="hidden" aria-hidden="true">
|
||||
<input type="hidden" name="_bot_fingerprint" value="">
|
||||
|
||||
@php
|
||||
$captchaProvider = $captcha['provider'] ?? 'turnstile';
|
||||
$captchaSiteKey = $captcha['siteKey'] ?? '';
|
||||
@endphp
|
||||
|
||||
<div>
|
||||
<label class="block text-sm mb-1 text-white/80" for="email">Email</label>
|
||||
@@ -23,8 +36,14 @@
|
||||
<x-input-error :messages="$errors->get('email')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
@if(($requiresTurnstile ?? false) && ($turnstileSiteKey ?? '') !== '')
|
||||
<div class="cf-turnstile" data-sitekey="{{ $turnstileSiteKey }}"></div>
|
||||
@if((($requiresCaptcha ?? false) || session('bot_captcha_required')) && $captchaSiteKey !== '')
|
||||
@if($captchaProvider === 'recaptcha')
|
||||
<div class="g-recaptcha" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
|
||||
@elseif($captchaProvider === 'hcaptcha')
|
||||
<div class="h-captcha" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
|
||||
@else
|
||||
<div class="cf-turnstile" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
|
||||
@endif
|
||||
<x-input-error :messages="$errors->get('captcha')" class="mt-2" />
|
||||
@endif
|
||||
|
||||
@@ -35,7 +54,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if(($requiresTurnstile ?? false) && ($turnstileSiteKey ?? '') !== '')
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
@if((($requiresCaptcha ?? false) || session('bot_captcha_required')) && (($captcha['siteKey'] ?? '') !== '') && (($captcha['scriptUrl'] ?? '') !== ''))
|
||||
<script src="{{ $captcha['scriptUrl'] }}" async defer></script>
|
||||
@endif
|
||||
@include('partials.bot-fingerprint-script')
|
||||
@endsection
|
||||
|
||||
55
resources/views/partials/bot-fingerprint-script.blade.php
Normal file
55
resources/views/partials/bot-fingerprint-script.blade.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<script>
|
||||
(() => {
|
||||
const forms = document.querySelectorAll('[data-bot-form]');
|
||||
if (!forms.length || !window.crypto?.subtle) {
|
||||
return;
|
||||
}
|
||||
|
||||
const readWebglVendor = () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
||||
if (!gl) {
|
||||
return 'no-webgl';
|
||||
}
|
||||
|
||||
const extension = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
if (!extension) {
|
||||
return 'webgl-hidden';
|
||||
}
|
||||
|
||||
return [
|
||||
gl.getParameter(extension.UNMASKED_VENDOR_WEBGL),
|
||||
gl.getParameter(extension.UNMASKED_RENDERER_WEBGL),
|
||||
].join(':');
|
||||
} catch {
|
||||
return 'webgl-error';
|
||||
}
|
||||
};
|
||||
|
||||
const fingerprintPayload = [
|
||||
navigator.userAgent || 'unknown-ua',
|
||||
navigator.language || 'unknown-language',
|
||||
navigator.platform || 'unknown-platform',
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone || 'unknown-timezone',
|
||||
`${window.screen?.width || 0}x${window.screen?.height || 0}x${window.devicePixelRatio || 1}`,
|
||||
readWebglVendor(),
|
||||
].join('|');
|
||||
|
||||
const encodeHex = (buffer) => Array.from(new Uint8Array(buffer))
|
||||
.map((part) => part.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(fingerprintPayload))
|
||||
.then((buffer) => {
|
||||
const fingerprint = encodeHex(buffer);
|
||||
forms.forEach((form) => {
|
||||
const input = form.querySelector('input[name="_bot_fingerprint"]');
|
||||
if (input) {
|
||||
input.value = fingerprint;
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
})();
|
||||
</script>
|
||||
Reference in New Issue
Block a user