diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index a368ceb..fcbf20e 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index d86afca..9caa05c 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -30,7 +30,6 @@ class Token(BaseModel): class LoginRequest(BaseModel): email: EmailStr password: str - turnstile_token: str | None = None class UserUpdateRole(BaseModel): diff --git a/backend/tests/test_login_without_turnstile.py b/backend/tests/test_login_without_turnstile.py new file mode 100644 index 0000000..730d7f5 --- /dev/null +++ b/backend/tests/test_login_without_turnstile.py @@ -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() diff --git a/docs/quiz-revamp-progress.md b/docs/quiz-revamp-progress.md index 47be18d..1862eff 100644 --- a/docs/quiz-revamp-progress.md +++ b/docs/quiz-revamp-progress.md @@ -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. diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index b9676d7..986cea5 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -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) diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx index d6fa55f..f507ee1 100644 --- a/frontend/src/pages/LandingPage.jsx +++ b/frontend/src/pages/LandingPage.jsx @@ -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 &&
Verification email sent.
} {error &&
{error}
} -
+
- - setEmail(e.target.value)} required autoFocus /> + + setEmail(e.target.value)} required autoFocus />
- - setPassword(e.target.value)} required /> + + setPassword(e.target.value)} required />
- - diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index 9f23ef3..e255d46 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -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
-} - 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 && ( <> -
+
- - setEmail(e.target.value)} required /> + + setEmail(e.target.value)} required />
- - setPassword(e.target.value)} required /> + + setPassword(e.target.value)} required />
- - diff --git a/frontend/src/pages/LoginTurnstile.test.jsx b/frontend/src/pages/LoginTurnstile.test.jsx new file mode 100644 index 0000000..9cc8f42 --- /dev/null +++ b/frontend/src/pages/LoginTurnstile.test.jsx @@ -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 }) => })) + +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(} />Signed in successfully
} />) +} +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() +})