fix: remove Turnstile from quiz password login

Remove the challenge from standalone and landing login plus backend verification; retain registration/contact protection and existing password, email verification, SSO and login rate-limit checks. Verified 18 backend tests in deployed image and 17 frontend tests plus build.
This commit is contained in:
Daniel 2026-09-07 02:54:12 +02:00
parent f696b99569
commit 38f3fb8250
8 changed files with 188 additions and 55 deletions

View file

@ -150,14 +150,6 @@ async def login(login_data: LoginRequest, db: Session = Depends(get_db), request
if sso_settings["sso_only"]:
raise HTTPException(status_code=403, detail="Password login is disabled. Please use SSO.")
# Verify Turnstile if configured
from app.config import settings as cfg
if cfg.TURNSTILE_SECRET_KEY:
if not login_data.turnstile_token:
raise HTTPException(status_code=400, detail="Bot verification required")
if not await _verify_turnstile(login_data.turnstile_token):
raise HTTPException(status_code=400, detail="Bot verification failed — please try again")
if request:
client_ip = request.client.host if request.client else "unknown"
_check_login_rate_limit(client_ip)

View file

@ -30,7 +30,6 @@ class Token(BaseModel):
class LoginRequest(BaseModel):
email: EmailStr
password: str
turnstile_token: str | None = None
class UserUpdateRole(BaseModel):

View file

@ -0,0 +1,95 @@
"""Login-only Turnstile removal; real auth routes, disposable DB, mocked external boundaries."""
import sys
import unittest
from datetime import datetime, timedelta
from types import ModuleType
from unittest.mock import AsyncMock, patch
import test_quiz_builder as fixtures
from app.config import settings
from app.models.email_verification import EmailVerification
from app.routers import auth
from app.schemas.auth import LoginRequest, UserCreate
from app.utils.auth import get_password_hash
class MemoryRedis:
def __init__(self): self.values = {}
def get(self, key): return self.values.get(key)
def incr(self, key):
self.values[key] = int(self.values.get(key, 0)) + 1
return self.values[key]
def expire(self, *args): return True
class LoginWithoutTurnstileTests(unittest.TestCase):
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.bank.owner.email = 'owner@example.com'
self.bank.owner.hashed_password = get_password_hash('synthetic-password')
self.bank.db.commit()
self.client = self.bank.client
self.client.app.include_router(auth.router, prefix='/auth')
self.redis = MemoryRedis()
module = ModuleType('redis')
module.from_url = lambda *args, **kwargs: self.redis
self.module_patch = patch.dict(sys.modules, {'redis': module})
self.module_patch.start()
self.key_patch = patch.object(settings, 'TURNSTILE_SECRET_KEY', 'synthetic-configured-key')
self.key_patch.start()
self.verify_patch = patch.object(auth, '_verify_turnstile', new_callable=AsyncMock)
self.verify = self.verify_patch.start()
self.verify.return_value = False
def tearDown(self):
self.verify_patch.stop()
self.key_patch.stop()
self.module_patch.stop()
self.bank.tearDown()
def login(self, **overrides):
return self.client.post('/auth/login', json={'email': 'owner@example.com', 'password': 'synthetic-password', **overrides})
def test_valid_login_needs_no_token_even_with_turnstile_configured(self):
self.assertNotIn('turnstile_token', LoginRequest.model_fields)
self.assertIn('turnstile_token', UserCreate.model_fields)
response = self.login()
self.assertEqual(response.status_code, 200, response.text)
self.assertTrue(response.json()['access_token'])
# Old clients sending an extra token remain compatible; it is not verified.
self.assertEqual(self.login(turnstile_token='obsolete-client-field').status_code, 200)
self.verify.assert_not_awaited()
def test_password_email_verification_and_sso_rules_remain(self):
self.assertEqual(self.login(password='wrong').status_code, 401)
self.bank.db.add(EmailVerification(user_id=self.bank.owner.id, token='test-token',
expires_at=datetime.utcnow() + timedelta(hours=1), verified_at=None))
self.bank.db.commit()
self.assertEqual(self.login().status_code, 403)
self.redis.values['settings:sso_only'] = 'true'
response = self.login()
self.assertEqual(response.status_code, 403)
self.assertIn('Please use SSO', response.json()['detail'])
self.verify.assert_not_awaited()
def test_login_rate_limit_still_rejects_the_eleventh_attempt(self):
for _ in range(10):
self.assertEqual(self.login(email='missing@example.com', password='wrong').status_code, 401)
response = self.login(email='missing@example.com', password='wrong')
self.assertEqual(response.status_code, 429)
self.assertIn('Too many login attempts', response.json()['detail'])
self.verify.assert_not_awaited()
def test_registration_still_requires_and_verifies_turnstile(self):
payload = {'email': 'new@example.com', 'password': 'synthetic-password', 'name': 'Test'}
response = self.client.post('/auth/register', json=payload)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()['detail'], 'Bot verification required')
response = self.client.post('/auth/register', json={**payload, 'turnstile_token': 'invalid-test-token'})
self.assertEqual(response.status_code, 400)
self.verify.assert_awaited_once_with('invalid-test-token')
if __name__ == '__main__':
unittest.main()

View file

@ -31,6 +31,14 @@ This milestone is source work on the feature branch, **not a production deployme
Older tutor-context and question-image delivery authorization gaps identified by review remain a release blocker and have their own tracked privacy task. Already-downloaded offline content cannot be recalled by server revocation. No new AI provider calls or production database/service changes were performed.
## Login-only Turnstile removal
User-requested removal covers both `/login` and the landing-page sign-in modal, the shared login client payload, and the backend password-login handler/schema. Registration and contact Turnstile are unchanged; no keys/configuration were removed.
Verification: 18 backend tests passed in the exact deployed image, including login with a configured Turnstile secret but no challenge token, incorrect-password rejection, email verification, SSO-only mode, the eleventh-request rate limit and retained registration verification. All 17 frontend tests and the production build passed, including both login entry points with a configured site key and retained registration/contact widgets. The first backend run used reserved `.test` email addresses; only fixtures were corrected to `example.com`, not validation rules.
This source change is committed/pushed with the feature work; it has not been deployed to production.
## Next
Continue with the Orthobullets-inspired runner/results UI, question navigation and study tools; then article/subsection reading, linked flashcards, educator AI authoring and moderated comments. Complete related-content privacy work and end-to-end desktop/mobile validation before deployment.

View file

@ -19,8 +19,8 @@ export function AuthProvider({ children }) {
}
}, [])
const login = async (email, password, turnstileToken) => {
const res = await api.post('/auth/login', { email, password, turnstile_token: turnstileToken || null })
const login = async (email, password) => {
const res = await api.post('/auth/login', { email, password })
localStorage.setItem('token', res.data.access_token)
const me = await api.get('/auth/me')
setUser(me.data)

View file

@ -192,7 +192,7 @@ function AuthModal({ mode, onClose, onSwitch }) {
setError(''); setUnverified(false)
setLoading(true)
try {
await login(email, password, turnstileToken)
await login(email, password)
onClose()
navigate('/')
} catch (err) {
@ -288,17 +288,16 @@ function AuthModal({ mode, onClose, onSwitch }) {
)}
{resendSent && <div className="alert alert-success" style={{ marginBottom: 14 }}>Verification email sent.</div>}
{error && <div className="alert alert-error" style={{ marginBottom: 14 }}>{error}</div>}
<form onSubmit={handleLogin}>
<form aria-label="Sign in" onSubmit={handleLogin}>
<div className="form-group">
<label>Email</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
<label htmlFor="modal-login-email">Email</label>
<input id="modal-login-email" type="email" autoComplete="username" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
</div>
<div className="form-group">
<label>Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
<label htmlFor="modal-login-password">Password</label>
<input id="modal-login-password" type="password" autoComplete="current-password" value={password} onChange={e => setPassword(e.target.value)} required />
</div>
<TurnstileWidget onVerify={setTurnstileToken} />
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading || (TURNSTILE_SITE_KEY && !turnstileToken)}>
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
{loading ? 'Signing in…' : 'Sign In'}
</button>
</form>

View file

@ -1,34 +1,8 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
const TURNSTILE_SITE_KEY = window.__APP_CONFIG__?.TURNSTILE_SITE_KEY || ''
function TurnstileWidget({ onVerify }) {
const ref = useRef(null)
const widgetId = useRef(null)
useEffect(() => {
if (!TURNSTILE_SITE_KEY) return
if (!document.getElementById('cf-turnstile-script')) {
const s = document.createElement('script')
s.id = 'cf-turnstile-script'
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
s.async = true
document.head.appendChild(s)
}
let timer
const tryRender = () => {
if (!window.turnstile || !ref.current) { timer = setTimeout(tryRender, 200); return }
widgetId.current = window.turnstile.render(ref.current, { sitekey: TURNSTILE_SITE_KEY, callback: onVerify })
}
tryRender()
return () => { clearTimeout(timer); if (widgetId.current != null) try { window.turnstile.remove(widgetId.current) } catch {} }
}, [])
if (!TURNSTILE_SITE_KEY) return null
return <div ref={ref} style={{ margin: '8px 0' }} />
}
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
@ -37,7 +11,6 @@ export default function LoginPage() {
const [unverified, setUnverified] = useState(false)
const [resendSent, setResendSent] = useState(false)
const [resending, setResending] = useState(false)
const [turnstileToken, setTurnstileToken] = useState('')
const [ssoConfig, setSsoConfig] = useState(null)
const { login } = useAuth()
const navigate = useNavigate()
@ -54,7 +27,7 @@ export default function LoginPage() {
setUnverified(false)
setLoading(true)
try {
await login(email, password, turnstileToken)
await login(email, password)
navigate('/')
} catch (err) {
if (err.response?.status === 403) {
@ -118,17 +91,16 @@ export default function LoginPage() {
{!ssoConfig?.sso_only && (
<>
<form onSubmit={handleSubmit}>
<form aria-label="Sign in" onSubmit={handleSubmit}>
<div className="form-group">
<label>Email</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required />
<label htmlFor="login-email">Email</label>
<input id="login-email" type="email" autoComplete="username" value={email} onChange={e => setEmail(e.target.value)} required />
</div>
<div className="form-group">
<label>Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
<label htmlFor="login-password">Password</label>
<input id="login-password" type="password" autoComplete="current-password" value={password} onChange={e => setPassword(e.target.value)} required />
</div>
<TurnstileWidget onVerify={setTurnstileToken} />
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading || (TURNSTILE_SITE_KEY && !turnstileToken)}>
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>

View file

@ -0,0 +1,68 @@
import { act, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { beforeEach, afterEach, expect, it, vi } from 'vitest'
vi.hoisted(() => { window.__APP_CONFIG__ = { TURNSTILE_SITE_KEY: 'configured-test-site-key' } })
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
vi.mock('../components/Navbar', () => ({ default: ({ onSignIn, onRegister }) => <nav><button onClick={onSignIn}>Open login</button><button onClick={onRegister}>Open registration</button></nav> }))
import LoginPage from './LoginPage'
import LandingPage from './LandingPage'
import { AuthProvider } from '../context/AuthContext'
import api from '../api/client'
beforeEach(() => {
vi.resetAllMocks()
localStorage.clear()
window.turnstile = { render: vi.fn().mockReturnValue('synthetic-widget'), remove: vi.fn() }
api.get.mockImplementation(url => Promise.resolve({ data: url === '/auth/sso/config' ? { sso_enabled: false } : { id: 1, name: 'Test', role: 'user' } }))
api.post.mockResolvedValue({ data: { access_token: 'synthetic-login-token' } })
})
afterEach(() => { document.getElementById('cf-turnstile-script')?.remove() })
function mount(Component) {
render(<MemoryRouter initialEntries={['/entry']}><AuthProvider><Routes><Route path="/entry" element={<Component />} /><Route path="/" element={<div>Signed in successfully</div>} /></Routes></AuthProvider></MemoryRouter>)
}
async function submitLogin() {
const form = screen.getByRole('form', { name: 'Sign in' })
expect(within(form).getByRole('button', { name: 'Sign In', exact: true })).toBeEnabled()
await userEvent.type(within(form).getByLabelText('Email'), 'owner@example.test')
await userEvent.type(within(form).getByLabelText('Password'), 'synthetic-password')
await userEvent.click(within(form).getByRole('button', { name: 'Sign In', exact: true }))
expect(await screen.findByText('Signed in successfully')).toBeInTheDocument()
expect(api.post).toHaveBeenCalledWith('/auth/login', { email: 'owner@example.test', password: 'synthetic-password' })
}
it('logs in on the standalone page without rendering, loading or submitting Turnstile', async () => {
mount(LoginPage)
await submitLogin()
expect(window.turnstile.render).not.toHaveBeenCalled()
expect(document.getElementById('cf-turnstile-script')).not.toBeInTheDocument()
})
it('removes the landing login challenge while preserving the contact widget', async () => {
mount(LandingPage)
expect(window.turnstile.render).toHaveBeenCalledTimes(1)
await userEvent.click(screen.getByRole('button', { name: 'Open login' }))
expect(window.turnstile.render).toHaveBeenCalledTimes(1)
await submitLogin()
})
it('keeps registration protected on the landing page', async () => {
mount(LandingPage)
await userEvent.click(screen.getByRole('button', { name: 'Open registration' }))
expect(window.turnstile.render).toHaveBeenCalledTimes(2)
const signup = screen.getByRole('button', { name: 'Sign Up', exact: true })
expect(signup).toBeDisabled()
act(() => window.turnstile.render.mock.calls[1][1].callback('synthetic-valid-token'))
expect(signup).toBeEnabled()
})
it('retains SSO-only mode on the standalone login page', async () => {
api.get.mockResolvedValue({ data: { sso_enabled: true, sso_only: true, provider_name: 'Test SSO' } })
mount(LoginPage)
expect(await screen.findByRole('link', { name: 'Sign in with Test SSO' })).toHaveAttribute('href', '/api/auth/sso/login')
expect(screen.queryByRole('form', { name: 'Sign in' })).not.toBeInTheDocument()
expect(window.turnstile.render).not.toHaveBeenCalled()
})