diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py
index f0d8d4a..ee14075 100644
--- a/backend/app/routers/admin.py
+++ b/backend/app/routers/admin.py
@@ -14,9 +14,9 @@ from app.database import get_db
from app.models.user import User
from app.models.ai_model_config import AIModelConfig
from app.services import ai_service, site_settings, sso_roles, tts_voices
-from app.schemas.auth import UserResponse, UserUpdateRole, UserCreate
+from app.schemas.auth import UserResponse, UserUpdateRole
from app.schemas.admin import AIModelConfigCreate, AIModelConfigResponse, AIModelConfigUpdate
-from app.utils.auth import require_admin, get_current_user, get_password_hash
+from app.utils.auth import require_admin, get_current_user
router = APIRouter()
@@ -96,41 +96,6 @@ def delete_user(
db.commit()
-@router.post("/users", response_model=UserResponse)
-def create_user(
- user_data: UserCreate,
- db: Session = Depends(get_db),
- admin: User = Depends(require_admin),
-):
- """Admin creates a user directly — email is auto-verified."""
- from app.models.email_verification import EmailVerification
- from datetime import datetime
-
- email_normalized = user_data.email.lower().strip()
- if db.query(User).filter(User.email == email_normalized).first():
- raise HTTPException(status_code=400, detail="Email already registered")
-
- user = User(
- email=email_normalized,
- hashed_password=get_password_hash(user_data.password),
- name=user_data.name,
- role="user",
- )
- db.add(user)
- db.flush()
- db.add(EmailVerification(
- user_id=user.id,
- token=f"admin_created_{user.id}",
- expires_at=datetime.utcnow(),
- verified_at=datetime.utcnow(),
- ))
- db.commit()
- db.refresh(user)
- return user
-
-
-# --- AI Model Configuration ---
-
@router.get("/models/available")
def list_available_models(
task: str = Query("extraction"),
diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
index 5b514cd..34c44f9 100644
--- a/backend/app/routers/auth.py
+++ b/backend/app/routers/auth.py
@@ -5,13 +5,13 @@ from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request
from sqlalchemy.orm import Session
-from app.services import captcha, refresh_tokens, site_settings
+from app.services import refresh_tokens, site_settings
from app.database import get_db
from app.models.user import User
from app.models.email_verification import EmailVerification
from app.models.password_reset import PasswordReset
from app.schemas.auth import (
- UserCreate, UserResponse, Token, LoginRequest, LogoutRequest, RefreshRequest,
+ UserResponse, Token, LoginRequest, LogoutRequest, RefreshRequest,
UserUpdateMe, ForgotPasswordRequest, ResetPasswordRequest, SsoExchangeRequest,
)
from app.services import email_service
@@ -104,100 +104,6 @@ def _check_reset_rate_limit(db: Session, email: str):
)
-@router.get("/signup-policy")
-def signup_policy(db: Session = Depends(get_db)):
- """What a would-be member needs, before they are anybody.
-
- Unauthenticated on purpose: the registration form has to know whether to
- ask for a code, and it is asking before it has an account to ask with. It
- says only whether one is needed — never whether a given code is valid,
- which would turn this into somewhere to guess them.
- """
- # The very first account is always allowed, or a new install could lock
- # itself out before an administrator exists to issue a code.
- if db.query(User).count() == 0:
- return {"first_user": True, "registration_open": True}
- # Whether anyone may join at all, so the sign-in page can stop offering a
- # door that is locked. It was offering one: registration can be turned off
- # site-wide, and the only way to find out was to fill the form in and be
- # refused.
- # Single sign-on closes the password door, and this is the one question
- # the sign-up form already asks. Saying so here means the form can decline
- # to draw itself rather than collect a name, an email and a password
- # twice, and then be refused by the POST.
- from app.config import settings as cfg
- sso_only = _get_sso_settings()["sso_only"]
- return {"first_user": False,
- "sso_only": sso_only,
- # So the page can name the provider rather than say "single
- # sign-on" at somebody who only knows it as PedsHub SSO.
- "provider_name": (cfg.OIDC_PROVIDER_NAME
- if cfg.OIDC_PROVIDER_URL and cfg.OIDC_CLIENT_ID else None),
- "registration_open": (not sso_only
- and site_settings.get_flag("registration_enabled"))}
-
-
-@router.post("/register")
-async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
- _refuse_when_sso_only("Accounts are created through SSO on this site.")
- # Failing open: an hCaptcha outage should cost the site a little spam, not
- # every account that would have been created while it lasted.
- await captcha.require_human(user_data.captcha_token, fail_open=True)
-
- # Check if registration is enabled (unless this is the first user - always allow admin creation)
- is_first_user = db.query(User).count() == 0
- if not is_first_user:
- try:
- import redis as redis_lib
- from app.config import settings
- r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True, socket_connect_timeout=1)
- registration_enabled = r.get("settings:registration_enabled")
- # Default to enabled if not set, disabled if explicitly set to "false"
- if registration_enabled == "false":
- raise HTTPException(status_code=403, detail="Registration is currently disabled by administrator")
- except HTTPException:
- raise
- except Exception as e:
- import logging; logging.getLogger(__name__).warning(f"Redis registration check failed (failing open): {e}")
-
- if len(user_data.password) < 8:
- raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
-
- email_normalized = user_data.email.lower().strip()
- existing = db.query(User).filter(User.email == email_normalized).first()
- if existing:
- raise HTTPException(status_code=400, detail="Email already registered")
- user = User(
- email=email_normalized,
- hashed_password=get_password_hash(user_data.password),
- name=user_data.name,
- role="admin" if is_first_user else "user",
- )
- db.add(user)
- db.flush()
-
- # Create email verification record
- token = secrets.token_urlsafe(32)
- verification = EmailVerification(
- user_id=user.id,
- token=token,
- expires_at=datetime.utcnow() + timedelta(hours=24),
- verified_at=datetime.utcnow() if is_first_user else None,
- )
- db.add(verification)
- db.commit()
- db.refresh(user)
-
- if is_first_user:
- # Auto-verified admin — log them in immediately
- access_token = create_access_token(data={"sub": user.email})
- return {"access_token": access_token, "token_type": "bearer"}
-
- # Regular user — must verify email before logging in
- background_tasks.add_task(email_service.send_verification_email, user.email, user.name, token)
- return {"requires_verification": True, "message": "Account created. Please check your email to verify your account before logging in."}
-
-
@router.post("/login", response_model=Token)
async def login(login_data: LoginRequest, db: Session = Depends(get_db), request: Request = None):
# Block password login if SSO-only mode
@@ -562,11 +468,9 @@ def sso_exchange(data: SsoExchangeRequest):
def _refuse_when_sso_only(what: str = "Password login is disabled. Please use SSO.") -> None:
"""No password door while the site is single sign-on.
- Login and the login codes checked this; register, forgot-password and
- reset-password did not. So with SSO-only on, somebody could still be
- issued a password account they could not use — and if the flag were ever
- turned off, that is a password account nobody vetted, sitting in the
- users table waiting.
+ Login checked this; forgot-password and reset-password did not. Accounts
+ are made at the provider now, so what is left to guard is the password
+ somebody already has: resetting it, or setting a new one.
"""
if _get_sso_settings()["sso_only"]:
raise HTTPException(status_code=403, detail=what)
diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py
index eefa40f..716c1eb 100644
--- a/backend/app/schemas/auth.py
+++ b/backend/app/schemas/auth.py
@@ -3,14 +3,6 @@ from datetime import datetime
from pydantic import BaseModel, EmailStr, Field
-class UserCreate(BaseModel):
- email: EmailStr
- password: str
- name: str
- #: Solved hCaptcha challenge. Absent when the site has no secret configured.
- captcha_token: str | None = None
-
-
class UserResponse(BaseModel):
id: int
email: str
diff --git a/backend/tests/api-contract.json b/backend/tests/api-contract.json
index 87ecf7f..be5ad64 100644
--- a/backend/tests/api-contract.json
+++ b/backend/tests/api-contract.json
@@ -868,13 +868,6 @@
"200"
]
},
- "GET /api/v1/auth/signup-policy": {
- "body": false,
- "params": [],
- "responses": [
- "200"
- ]
- },
"GET /api/v1/auth/sso/callback": {
"body": false,
"params": [],
@@ -2027,14 +2020,6 @@
"422"
]
},
- "POST /api/v1/admin/users": {
- "body": true,
- "params": [],
- "responses": [
- "200",
- "422"
- ]
- },
"POST /api/v1/ai/conversations": {
"body": false,
"params": [],
@@ -2219,14 +2204,6 @@
"422"
]
},
- "POST /api/v1/auth/register": {
- "body": true,
- "params": [],
- "responses": [
- "200",
- "422"
- ]
- },
"POST /api/v1/auth/resend-verification": {
"body": true,
"params": [],
diff --git a/backend/tests/test_login_without_captcha.py b/backend/tests/test_login_without_captcha.py
deleted file mode 100644
index a2ac381..0000000
--- a/backend/tests/test_login_without_captcha.py
+++ /dev/null
@@ -1,108 +0,0 @@
-"""Login carries no captcha; 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.services import captcha
-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 LoginWithoutCaptchaTests(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, 'CAP_SECRET_KEY', 'synthetic-configured-key')
- self.key_patch.start()
- # Stops at the network boundary; every routing decision above it is real.
- self.verify_patch = patch.object(captcha, 'verify', 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_cap_configured(self):
- self.assertNotIn('captcha_token', LoginRequest.model_fields)
- self.assertIn('captcha_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(captcha_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_the_challenge(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, 'captcha_token': 'invalid-test-token'})
- self.assertEqual(response.status_code, 400)
- self.assertEqual(response.json()['detail'], 'Bot verification failed — please try again')
- self.verify.assert_awaited_once_with('invalid-test-token', fail_open=True)
-
- def test_a_solved_challenge_lets_registration_through(self):
- self.verify.return_value = True
- with patch.object(auth.email_service, 'send_verification_email'):
- response = self.client.post('/auth/register', json={
- 'email': 'new@example.com', 'password': 'synthetic-password',
- 'name': 'Test', 'captcha_token': 'solved-test-token'})
- self.assertEqual(response.status_code, 200, response.text)
- self.assertTrue(response.json()['requires_verification'])
- self.verify.assert_awaited_once_with('solved-test-token', fail_open=True)
-
-
-if __name__ == '__main__':
- unittest.main()
diff --git a/backend/tests/test_sso_hardening.py b/backend/tests/test_sso_hardening.py
index 8f9c8db..46f81d3 100644
--- a/backend/tests/test_sso_hardening.py
+++ b/backend/tests/test_sso_hardening.py
@@ -21,11 +21,11 @@ from app.utils.auth import get_current_user
class SsoOnlyClosesThePasswordDoors(unittest.TestCase):
- """Login checked the flag. Everything else that makes a password did not.
+ """Login checked the flag. The other password doors did not.
- With SSO-only on, an account could still be registered, and a reset link
- still issued and spent — so the site could accumulate password accounts
- nobody vetted, waiting for the flag to be turned off again.
+ Registration is gone entirely — accounts are made at the provider — so
+ what is left to guard is the password somebody already has: resetting it,
+ or setting a new one while signed in.
"""
def setUp(self):
@@ -53,11 +53,8 @@ class SsoOnlyClosesThePasswordDoors(unittest.TestCase):
def calls(self):
return [
- ("/auth/register", {"email": "new@example.com", "password": "abcdefgh",
- "name": "New"}),
("/auth/forgot-password", {"email": "reader@example.com"}),
("/auth/reset-password", {"token": "x" * 20, "new_password": "abcdefgh"}),
- ("/auth/resend-verification", {"email": "reader@example.com"}),
("/auth/me", {"new_password": "abcdefgh"}),
]
@@ -119,43 +116,3 @@ class OneTimeCodeTests(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
-
-
-class SignupPolicyTests(unittest.TestCase):
- """The form asks this before it draws itself.
-
- Under SSO-only it used to draw the whole password sign-up — name, email,
- password twice, invite code — and only the POST refused it. The page now
- has what it needs to decline.
- """
-
- def setUp(self):
- self.engine = create_engine("sqlite://", connect_args={"check_same_thread": False},
- poolclass=StaticPool)
- Base.metadata.create_all(self.engine)
- self.db = Session(self.engine)
- self.db.add(User(id=1, name="Someone", email="someone@example.com",
- hashed_password=None))
- self.db.commit()
- app = FastAPI()
- app.include_router(auth.router, prefix="/auth")
- app.dependency_overrides[get_db] = lambda: self.db
- self.client = TestClient(app)
-
- def tearDown(self):
- self.client.close()
- self.db.close()
- self.engine.dispose()
-
- def test_sso_only_closes_registration_in_the_policy(self):
- with patch.object(auth, "_get_sso_settings", return_value={"sso_only": True}):
- body = self.client.get("/auth/signup-policy").json()
- self.assertTrue(body["sso_only"])
- self.assertFalse(body["registration_open"])
-
- def test_otherwise_the_switch_decides(self):
- with patch.object(auth, "_get_sso_settings", return_value={"sso_only": False}), \
- patch.object(auth.site_settings, "get_flag", return_value=True):
- body = self.client.get("/auth/signup-policy").json()
- self.assertFalse(body["sso_only"])
- self.assertTrue(body["registration_open"])
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 821ccbb..5e749ca 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -13,7 +13,6 @@ import lazyPage from './utils/lazyPage'
import FirstRunTour from './components/FirstRunTour'
const LoginPage = lazyPage(() => import('./pages/LoginPage'))
-const RegisterPage = lazyPage(() => import('./pages/RegisterPage'))
const DashboardPage = lazyPage(() => import('./pages/DashboardPage'))
const UploadPage = lazyPage(() => import('./pages/UploadPage'))
const DocumentDetailPage = lazyPage(() => import('./pages/DocumentDetailPage'))
@@ -192,7 +191,9 @@ function AppRoutes() {
- We sent a verification link to {email}. Click it to activate your account. -
- -or sign in with email
} + {ssoOnly === false &&or sign in with email
} > )} {/* Login form */} - {isLogin && !registered && !ssoOnly && ( + {ssoOnly === false && ( <> {unverified && !resendSent && (- This site signs in through {providerName}. -
- )} - - {/* Register form */} - {!isLogin && !registered && !ssoOnly && ( - <> - {error &&