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 &&