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:
parent
f696b99569
commit
38f3fb8250
8 changed files with 188 additions and 55 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ class Token(BaseModel):
|
|||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
turnstile_token: str | None = None
|
||||
|
||||
|
||||
class UserUpdateRole(BaseModel):
|
||||
|
|
|
|||
95
backend/tests/test_login_without_turnstile.py
Normal file
95
backend/tests/test_login_without_turnstile.py
Normal 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()
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
68
frontend/src/pages/LoginTurnstile.test.jsx
Normal file
68
frontend/src/pages/LoginTurnstile.test.jsx
Normal 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()
|
||||
})
|
||||
Loading…
Reference in a new issue