From 05381a9826ac7f30c2ea018c16aa9ae9d2c41b92 Mon Sep 17 00:00:00 2001 From: Krish Date: Sat, 22 Aug 2026 21:06:33 +0530 Subject: [PATCH] feat: add JWT authentication and gate the quiz flow Backend follows the existing layering: security primitives in core, a users repository that is the only thing touching that collection, an auth service holding the logic, and a thin endpoint module that only maps outcomes to status codes. Quiz attribution is the reason this exists. user_id previously arrived in the body of /quiz/generate, so any caller could attribute an attempt to anyone. It is now removed from QuizGenerateRequest entirely and taken from the token subject. All three quiz routes are gated, not just submit as specified. Gating submit alone would have left the forgeable attribution in place, since generate is the only route user_id ever arrived on. A quiz attempt is a record of what a specific person understood, so an unattributed one is not meaningful. Two deliberate choices in the auth surface. Login answers identically for an unknown email and a wrong password, so the endpoint cannot be used to enumerate accounts. Registration returns a token directly rather than requiring a second call. Emails are folded to lowercase on both write and lookup. Passwords over 72 bytes are rejected rather than accepted. bcrypt silently ignores everything past that boundary, and a password whose tail never mattered is worse than a rejected one. A corrupt stored hash is treated as a failed login rather than a 500, so a bad row cannot be used to probe the endpoint. On the dependency: passlib[bcrypt] as specified does not work against current versions. passlib 1.7.4 was last released in 2020 and probes bcrypt.__about__, which 4.1 removed; against bcrypt 5.x its backend self-test fails outright and hashing breaks entirely rather than degrading. bcrypt is pinned below 5 and verified on 4.3.0, and passlib's trapped-exception traceback is silenced so it stops printing on every boot. Dropping passlib for bcrypt directly, or pwdlib, would remove the pin and is contained to core/security.py. pydantic[email] is added for EmailStr validation. TokenResponse carries role so the client knows what it is logged in as without decoding the token. Frontend stores the token in shared/api rather than the auth feature, because the request client needs it and importing a feature from shared would invert the layering. The client attaches the header automatically so no feature has to remember, and clears the token on any 401 so the UI cannot insist it is logged in while every call fails. QuizPage hands control back to App on a 401 rather than showing an error the user cannot act on. Two gaps left open and documented rather than papered over. Email uniqueness is a read-before-write and races under concurrent signup; models/user.py carries the index command that actually fixes it. And there is no ownership check on quiz attempts - a logged-in user can submit against any quiz_id they know. Auth establishes who you are; it does not yet enforce that the quiz is yours, and the anonymous-first profiles on the roadmap should decide that model. Verified live against Atlas with no Gemini quota spent: register 201, duplicate 409, login with an uppercased email 200, wrong password and unknown email both 401 with identical bodies, submit without a token 401, with a forged token 401, and with a valid token 404 for a nonexistent quiz - proving the gate passes a good token through. Stored document holds a $2b$ hash and no plaintext. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PET9qKZXhgjEbZK7MReYQj --- backend/.env.example | 1 + backend/app/api/v1/endpoints/auth.py | 29 ++++ backend/app/api/v1/endpoints/quiz.py | 15 +- backend/app/api/v1/router.py | 3 +- backend/app/core/config.py | 5 + backend/app/core/dependencies.py | 31 ++++ backend/app/core/security.py | 70 +++++++++ backend/app/models/user.py | 23 +++ backend/app/repositories/user_repository.py | 27 ++++ backend/app/schemas/auth.py | 25 ++++ backend/app/schemas/quiz.py | 3 +- backend/app/services/auth_service.py | 54 +++++++ backend/requirements.txt | 5 +- backend/tests/test_auth_api.py | 157 ++++++++++++++++++++ backend/tests/test_quiz_api.py | 6 +- backend/tests/test_security.py | 86 +++++++++++ frontend/src/App.jsx | 36 ++++- frontend/src/features/auth/AuthForm.jsx | 74 +++++++++ frontend/src/features/auth/LoginPage.jsx | 23 +++ frontend/src/features/auth/RegisterPage.jsx | 24 +++ frontend/src/features/auth/api.js | 24 +++ frontend/src/features/quiz/QuizPage.jsx | 5 +- frontend/src/shared/api/client.js | 24 ++- frontend/src/shared/api/token.js | 33 ++++ frontend/src/styles/globals.css | 14 ++ 25 files changed, 784 insertions(+), 13 deletions(-) create mode 100644 backend/app/api/v1/endpoints/auth.py create mode 100644 backend/app/core/dependencies.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/repositories/user_repository.py create mode 100644 backend/app/schemas/auth.py create mode 100644 backend/app/services/auth_service.py create mode 100644 backend/tests/test_auth_api.py create mode 100644 backend/tests/test_security.py create mode 100644 frontend/src/features/auth/AuthForm.jsx create mode 100644 frontend/src/features/auth/LoginPage.jsx create mode 100644 frontend/src/features/auth/RegisterPage.jsx create mode 100644 frontend/src/features/auth/api.js create mode 100644 frontend/src/shared/api/token.js diff --git a/backend/.env.example b/backend/.env.example index 086ccc2..2f93125 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,3 +2,4 @@ MONGO_URI=your_mongodb_atlas_uri GEMINI_API_KEY=your_gemini_api_key GEMINI_MODEL=gemini-3.6-flash GITHUB_TOKEN= +JWT_SECRET=change_me_to_a_long_random_string diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..dba56e6 --- /dev/null +++ b/backend/app/api/v1/endpoints/auth.py @@ -0,0 +1,29 @@ +""" +Thin HTTP layer for authentication. No business logic here — only +request/response translation and HTTP error mapping. Logic lives in +app/services/auth_service.py. +""" +from fastapi import APIRouter, HTTPException + +from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse +from app.services import auth_service + +router = APIRouter() + + +@router.post("/register", response_model=TokenResponse, status_code=201) +async def register(req: RegisterRequest): + try: + return await auth_service.register_user(req.email, req.password, req.role) + except ValueError: + # 409 rather than 400: the request was well-formed, the address is taken. + raise HTTPException(409, "That email is already registered.") + + +@router.post("/login", response_model=TokenResponse) +async def login(req: LoginRequest): + try: + return await auth_service.authenticate_user(req.email, req.password) + except LookupError: + # Deliberately identical for unknown email and wrong password. + raise HTTPException(401, "Incorrect email or password.") diff --git a/backend/app/api/v1/endpoints/quiz.py b/backend/app/api/v1/endpoints/quiz.py index aa60504..0f63cd6 100644 --- a/backend/app/api/v1/endpoints/quiz.py +++ b/backend/app/api/v1/endpoints/quiz.py @@ -3,11 +3,16 @@ request/response translation and HTTP error mapping. Logic lives in app/services/quiz_service.py. +Every route here requires an access token. The quiz is a record of what a +specific person understood, so an unattributed attempt is not meaningful. + Flow: generate -> submit (opens follow-up) -> followup (final grade). Grading deliberately happens only after the follow-up, so a candidate cannot bank a score and walk away from the round they cannot pass. """ -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException + +from app.core.dependencies import get_current_user from app.schemas.quiz import ( FollowUpRequest, @@ -23,15 +28,15 @@ @router.post("/generate", response_model=QuizGenerateResponse) -async def generate(req: QuizGenerateRequest): +async def generate(req: QuizGenerateRequest, user: dict = Depends(get_current_user)): try: - return await quiz_service.create_quiz(req.repo_url, req.user_id) + return await quiz_service.create_quiz(req.repo_url, user["user_id"]) except ValueError: raise HTTPException(400, "Couldn't read source files from that repo — make sure it's public.") @router.post("/submit", response_model=QuizSubmitResponse) -async def submit(req: QuizSubmitRequest): +async def submit(req: QuizSubmitRequest, user: dict = Depends(get_current_user)): """Records answers and returns the adaptive follow-up. Does not grade.""" try: return await quiz_service.start_followup( @@ -44,7 +49,7 @@ async def submit(req: QuizSubmitRequest): @router.post("/followup", response_model=QuizResultResponse) -async def followup(req: FollowUpRequest): +async def followup(req: FollowUpRequest, user: dict = Depends(get_current_user)): """Grades the original answers together with the follow-up defence.""" try: result = await quiz_service.grade_quiz(req.quiz_id, req.answer, req.seconds_left) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index fafdd63..ffa5d62 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -1,8 +1,9 @@ """Aggregates every v1 route. main.py only ever imports this one router.""" from fastapi import APIRouter -from app.api.v1.endpoints import jobs, quiz +from app.api.v1.endpoints import auth, jobs, quiz api_router = APIRouter() +api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(quiz.router, prefix="/quiz", tags=["quiz"]) api_router.include_router(jobs.router, prefix="/jobs", tags=["jobs"]) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 6f785c6..62207e3 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -11,6 +11,11 @@ class Settings(BaseSettings): gemini_api_key: str = "" gemini_model: str = "gemini-3.6-flash" # verify current model name in Google AI Studio github_token: str = "" # optional, raises GitHub API rate limits from 60/hr to 5000/hr + # Signing key for access tokens. The default is a placeholder so the app still + # boots in dev and CI; anything issuing real tokens must override it. + jwt_secret: str = "dev-only-insecure-secret-change-me" + jwt_algorithm: str = "HS256" + jwt_expire_minutes: int = 60 * 12 class Config: env_file = ".env" diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py new file mode 100644 index 0000000..7c41e3e --- /dev/null +++ b/backend/app/core/dependencies.py @@ -0,0 +1,31 @@ +""" +FastAPI dependencies shared across endpoints. + +Kept out of security.py so that module stays importable by tests and scripts +without dragging in FastAPI request handling. +""" +from fastapi import Depends, HTTPException +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from app.core.security import decode_access_token + +# auto_error=False so a missing header reaches us as None and we can answer with a +# consistent 401 rather than letting the shape of the failure vary. +_bearer = HTTPBearer(auto_error=False) + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), +) -> dict: + """Resolves the caller from the Authorization header, or 401s.""" + if credentials is None or not credentials.credentials: + raise HTTPException(401, "Not authenticated") + try: + claims = decode_access_token(credentials.credentials) + except ValueError: + raise HTTPException(401, "Invalid or expired token") + + user_id = claims.get("sub") + if not user_id: + raise HTTPException(401, "Invalid or expired token") + return {"user_id": user_id, "role": claims.get("role")} diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..d7f10ad --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,70 @@ +""" +Password hashing and access tokens. + +Nothing else in the app should import passlib or jwt directly — go through here so +the algorithm, expiry, and claim shape live in one place. +""" +import logging +from datetime import datetime, timedelta, timezone +from typing import Optional + +import jwt +from passlib.context import CryptContext + +from app.core.config import settings + +# bcrypt is pinned below 5.x in requirements: passlib 1.7.4's bcrypt backend fails +# its own self-test against bcrypt 5, which breaks hashing entirely rather than +# just warning. +_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto") + +# passlib probes bcrypt.__about__, which 4.1+ removed. It catches the failure and +# carries on, but logs the traceback on every import. Silence the cosmetic noise +# without hiding anything that actually matters. +logging.getLogger("passlib.handlers.bcrypt").setLevel(logging.CRITICAL) + +# bcrypt silently uses only the first 72 bytes. Rejecting longer input is better +# than accepting a password whose tail never mattered. +MAX_PASSWORD_BYTES = 72 + + +def hash_password(password: str) -> str: + if len(password.encode("utf-8")) > MAX_PASSWORD_BYTES: + raise ValueError("password_too_long") + return _pwd.hash(password) + + +def verify_password(password: str, hashed: str) -> bool: + if len(password.encode("utf-8")) > MAX_PASSWORD_BYTES: + return False + try: + return _pwd.verify(password, hashed) + except ValueError: + # Malformed or truncated hash in storage — treat as a failed login rather + # than a 500, so a bad row cannot be used to probe the endpoint. + return False + + +def create_access_token(user_id: str, role: str, expires_minutes: Optional[int] = None) -> str: + now = datetime.now(timezone.utc) + minutes = settings.jwt_expire_minutes if expires_minutes is None else expires_minutes + payload = { + "sub": user_id, + "role": role, + "iat": now, + "exp": now + timedelta(minutes=minutes), + } + return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm) + + +def decode_access_token(token: str) -> dict: + """ + Returns the claims, or raises ValueError for anything untrustworthy. + + Callers get one failure mode rather than jwt's several, so no endpoint can + accidentally treat an expired token differently from a forged one. + """ + try: + return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]) + except jwt.PyJWTError as exc: + raise ValueError("invalid_token") from exc diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..396d438 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,23 @@ +""" +Documents this app's shape, MongoDB doesn't enforce it. Kept here so anyone +(human or agentic IDE) touching user_repository.py knows exactly what a users +document looks like. + +users collection: +{ + "_id": str (uuid4), + "email": str, # stored lowercased; unique, see note below + "hashed_password": str, # bcrypt, never the plaintext + "role": "candidate" | "employer", + "created_at": datetime (UTC), +} + +The password is never stored or logged in any other form, and no query in +user_repository.py returns it except the one login needs. + +Uniqueness note: email uniqueness is currently enforced by a read-before-write in +auth_service.register_user, which is racy under concurrent signups. A unique index +on `email` is the real fix: + + db.users.create_index("email", unique=True) +""" diff --git a/backend/app/repositories/user_repository.py b/backend/app/repositories/user_repository.py new file mode 100644 index 0000000..a57ff58 --- /dev/null +++ b/backend/app/repositories/user_repository.py @@ -0,0 +1,27 @@ +""" +Only file allowed to query the users collection directly. +Services call these functions instead of touching Mongo themselves — +swap storage engines later by editing only this file. +""" +from typing import Optional + +from app.db.mongodb import get_collection + +collection = get_collection("users") + + +def _normalise_email(email: str) -> str: + """Emails are matched case-insensitively, so they are stored folded.""" + return email.strip().lower() + + +async def create_user(doc: dict) -> None: + await collection.insert_one(doc) + + +async def get_user_by_email(email: str) -> Optional[dict]: + return await collection.find_one({"email": _normalise_email(email)}) + + +async def get_user_by_id(user_id: str) -> Optional[dict]: + return await collection.find_one({"_id": user_id}) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..e0df7fd --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,25 @@ +"""Request/response DTOs for the auth endpoints — what crosses the wire.""" +from typing import Literal + +from pydantic import BaseModel, EmailStr, Field + +Role = Literal["candidate", "employer"] + + +class RegisterRequest(BaseModel): + email: EmailStr + # bcrypt only considers the first 72 bytes, so longer input is rejected rather + # than silently truncated at the boundary. + password: str = Field(min_length=8, max_length=72) + role: Role = "candidate" + + +class LoginRequest(BaseModel): + email: EmailStr + password: str + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + role: Role diff --git a/backend/app/schemas/quiz.py b/backend/app/schemas/quiz.py index b85b7a2..eddfb58 100644 --- a/backend/app/schemas/quiz.py +++ b/backend/app/schemas/quiz.py @@ -10,7 +10,8 @@ class QuizGenerateRequest(BaseModel): repo_url: str - user_id: Optional[str] = None + # user_id is deliberately absent: it comes from the access token, so a caller + # cannot attribute a quiz attempt to somebody else by editing the body. class QuizQuestion(BaseModel): diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..ce7b870 --- /dev/null +++ b/backend/app/services/auth_service.py @@ -0,0 +1,54 @@ +""" +Business logic for authentication. + +Endpoints stay thin: they translate these outcomes into status codes and never +inspect password material themselves. +""" +import uuid +from datetime import datetime, timezone + +from app.core.security import create_access_token, hash_password, verify_password +from app.repositories import user_repository + + +async def register_user(email: str, password: str, role: str) -> dict: + """Creates the account and returns a token, so signup does not need a second round trip.""" + normalised = email.strip().lower() + + if await user_repository.get_user_by_email(normalised): + raise ValueError("email_taken") + + user_id = str(uuid.uuid4()) + await user_repository.create_user( + { + "_id": user_id, + "email": normalised, + "hashed_password": hash_password(password), + "role": role, + "created_at": datetime.now(timezone.utc), + } + ) + return { + "access_token": create_access_token(user_id, role), + "token_type": "bearer", + "role": role, + } + + +async def authenticate_user(email: str, password: str) -> dict: + """ + Verifies credentials and issues a token. + + Raises the same LookupError whether the email is unknown or the password is + wrong, so the endpoint cannot become an oracle for which accounts exist. + """ + user = await user_repository.get_user_by_email(email) + if not user or not verify_password(password, user.get("hashed_password", "")): + raise LookupError("bad_credentials") + + role = user.get("role", "candidate") + return { + "access_token": create_access_token(user["_id"], role), + "token_type": "bearer", + "role": role, + } diff --git a/backend/requirements.txt b/backend/requirements.txt index bbcec87..d4a3db1 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,7 +3,10 @@ uvicorn[standard] motor httpx google-generativeai -pydantic +pydantic[email] pydantic-settings +passlib[bcrypt] +bcrypt<5 # passlib 1.7.4's bcrypt backend fails its self-test against bcrypt 5.x +pyjwt pytest pytest-asyncio diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py new file mode 100644 index 0000000..a88394f --- /dev/null +++ b/backend/tests/test_auth_api.py @@ -0,0 +1,157 @@ +""" +Auth endpoints and the token gate on the quiz flow. + +The gate is the point of the feature: a quiz attempt records what a specific +person understood, so an unattributed or forgeable attribution is worthless. +""" +from unittest.mock import AsyncMock + +import pytest +from fastapi.testclient import TestClient + +from app.core.security import create_access_token, hash_password +from app.main import app +from app.services import auth_service, quiz_service + +client = TestClient(app) + + +@pytest.fixture +def no_such_user(monkeypatch): + monkeypatch.setattr(auth_service.user_repository, "get_user_by_email", AsyncMock(return_value=None)) + created = AsyncMock() + monkeypatch.setattr(auth_service.user_repository, "create_user", created) + return created + + +@pytest.fixture +def existing_user(monkeypatch): + user = {"_id": "user-1", "email": "a@b.com", + "hashed_password": hash_password("hunter2hunter2"), "role": "candidate"} + monkeypatch.setattr(auth_service.user_repository, "get_user_by_email", AsyncMock(return_value=user)) + return user + + +def auth_header(user_id="user-1", role="candidate"): + return {"Authorization": f"Bearer {create_access_token(user_id, role)}"} + + +# --- register -------------------------------------------------------------- + +def test_register_creates_a_user_and_returns_a_token(no_such_user): + resp = client.post("/api/v1/auth/register", + json={"email": "New@Example.com", "password": "hunter2hunter2", "role": "employer"}) + assert resp.status_code == 201 + body = resp.json() + assert body["token_type"] == "bearer" and body["role"] == "employer" + assert body["access_token"] + + doc = no_such_user.call_args.args[0] + assert doc["email"] == "new@example.com", "email must be stored folded" + assert doc["role"] == "employer" + assert "password" not in doc + assert doc["hashed_password"] != "hunter2hunter2" + + +def test_register_rejects_a_duplicate_email(existing_user): + resp = client.post("/api/v1/auth/register", + json={"email": "a@b.com", "password": "hunter2hunter2"}) + assert resp.status_code == 409 + + +def test_register_defaults_to_candidate(no_such_user): + client.post("/api/v1/auth/register", json={"email": "c@d.com", "password": "hunter2hunter2"}) + assert no_such_user.call_args.args[0]["role"] == "candidate" + + +@pytest.mark.parametrize("payload", [ + {"email": "not-an-email", "password": "hunter2hunter2"}, + {"email": "a@b.com", "password": "short"}, + {"email": "a@b.com", "password": "x" * 73}, + {"email": "a@b.com", "password": "hunter2hunter2", "role": "admin"}, + {"password": "hunter2hunter2"}, +]) +def test_register_validation(payload): + assert client.post("/api/v1/auth/register", json=payload).status_code == 422 + + +# --- login ----------------------------------------------------------------- + +def test_login_returns_a_usable_token(existing_user): + resp = client.post("/api/v1/auth/login", json={"email": "a@b.com", "password": "hunter2hunter2"}) + assert resp.status_code == 200 + from app.core.security import decode_access_token + assert decode_access_token(resp.json()["access_token"])["sub"] == "user-1" + + +def test_login_with_wrong_password_is_401(existing_user): + resp = client.post("/api/v1/auth/login", json={"email": "a@b.com", "password": "wrongwrongwrong"}) + assert resp.status_code == 401 + + +def test_login_does_not_reveal_whether_the_account_exists(monkeypatch, existing_user): + wrong_password = client.post("/api/v1/auth/login", + json={"email": "a@b.com", "password": "wrongwrongwrong"}) + monkeypatch.setattr(auth_service.user_repository, "get_user_by_email", AsyncMock(return_value=None)) + unknown_email = client.post("/api/v1/auth/login", + json={"email": "nobody@b.com", "password": "wrongwrongwrong"}) + + assert wrong_password.status_code == unknown_email.status_code == 401 + assert wrong_password.json() == unknown_email.json(), "responses must be indistinguishable" + + +# --- the gate -------------------------------------------------------------- + +@pytest.mark.parametrize("path,payload", [ + ("/api/v1/quiz/generate", {"repo_url": "https://github.com/o/r"}), + ("/api/v1/quiz/submit", {"quiz_id": "q", "answers": [{"question_id": "a", "answer": "b"}]}), + ("/api/v1/quiz/followup", {"quiz_id": "q", "answer": "b"}), +]) +def test_quiz_endpoints_reject_requests_with_no_token(path, payload): + assert client.post(path, json=payload).status_code == 401 + + +@pytest.mark.parametrize("header", [ + {"Authorization": "Bearer garbage"}, + {"Authorization": "Bearer "}, + {"Authorization": "token abc"}, + {"Authorization": ""}, +]) +def test_quiz_endpoints_reject_bad_authorization_headers(header): + resp = client.post("/api/v1/quiz/submit", + json={"quiz_id": "q", "answers": [{"question_id": "a", "answer": "b"}]}, + headers=header) + assert resp.status_code == 401 + + +def test_expired_token_is_rejected_by_the_gate(): + stale = create_access_token("user-1", "candidate", expires_minutes=-1) + resp = client.post("/api/v1/quiz/submit", + json={"quiz_id": "q", "answers": [{"question_id": "a", "answer": "b"}]}, + headers={"Authorization": f"Bearer {stale}"}) + assert resp.status_code == 401 + + +def test_submit_succeeds_with_a_valid_token(monkeypatch): + monkeypatch.setattr(quiz_service, "start_followup", AsyncMock(return_value={ + "quiz_id": "q", "time_limit_seconds": 75, + "followup": {"id": "f1", "question": "q?", "targets_question_id": "a"}, + })) + resp = client.post("/api/v1/quiz/submit", + json={"quiz_id": "q", "answers": [{"question_id": "a", "answer": "b"}]}, + headers=auth_header()) + assert resp.status_code == 200 + + +def test_generate_attributes_the_quiz_to_the_token_not_the_body(monkeypatch, questions, complexity): + """The whole point of the gate: user_id must not be forgeable from the body.""" + spy = AsyncMock(return_value={"quiz_id": "q", "repo_url": "r", "questions": questions, + "complexity": complexity, "time_limit_seconds": 75}) + monkeypatch.setattr(quiz_service, "create_quiz", spy) + + resp = client.post("/api/v1/quiz/generate", + json={"repo_url": "https://github.com/o/r", "user_id": "somebody-else"}, + headers=auth_header(user_id="real-user")) + + assert resp.status_code == 200 + assert spy.call_args.args[1] == "real-user" diff --git a/backend/tests/test_quiz_api.py b/backend/tests/test_quiz_api.py index 2777bbb..304876c 100644 --- a/backend/tests/test_quiz_api.py +++ b/backend/tests/test_quiz_api.py @@ -10,10 +10,14 @@ import pytest from fastapi.testclient import TestClient +from app.core.security import create_access_token from app.main import app from app.services import quiz_service -client = TestClient(app) +# Every quiz route is behind the auth gate, so the client here is authenticated by +# default. Rejection of missing or bad tokens is covered in test_auth_api.py. +AUTH = {"Authorization": f"Bearer {create_access_token('test-user', 'candidate')}"} +client = TestClient(app, headers=AUTH) def test_root_ok(): diff --git a/backend/tests/test_security.py b/backend/tests/test_security.py new file mode 100644 index 0000000..306e64a --- /dev/null +++ b/backend/tests/test_security.py @@ -0,0 +1,86 @@ +""" +Password hashing and access tokens. + +The bcrypt pin matters here: passlib 1.7.4's bcrypt backend fails its self-test +against bcrypt 5.x, which breaks hashing outright rather than degrading. If this +file starts failing after a dependency bump, check bcrypt's version first. +""" +import pytest + +from app.core import security + + +def test_hash_is_not_the_password_and_is_salted(): + a = security.hash_password("correct horse") + b = security.hash_password("correct horse") + assert "correct horse" not in a + assert a != b, "identical passwords must not produce identical hashes" + + +def test_verify_accepts_the_right_password_and_rejects_others(): + hashed = security.hash_password("s3cret-password") + assert security.verify_password("s3cret-password", hashed) + assert not security.verify_password("s3cret-passwore", hashed) + assert not security.verify_password("", hashed) + + +def test_overlong_password_is_rejected_not_silently_truncated(): + """bcrypt only reads 72 bytes; accepting more would ignore the tail.""" + with pytest.raises(ValueError): + security.hash_password("x" * 73) + + +def test_verify_rejects_overlong_input(): + hashed = security.hash_password("x" * 72) + assert not security.verify_password("x" * 73, hashed) + + +def test_verify_survives_a_corrupt_stored_hash(): + """A bad row is a failed login, not a 500 that leaks which rows are bad.""" + assert not security.verify_password("anything", "not-a-bcrypt-hash") + assert not security.verify_password("anything", "") + + +def test_token_round_trips_subject_and_role(): + claims = security.decode_access_token(security.create_access_token("user-1", "employer")) + assert claims["sub"] == "user-1" + assert claims["role"] == "employer" + + +def test_expired_token_is_rejected(): + stale = security.create_access_token("user-1", "candidate", expires_minutes=-1) + with pytest.raises(ValueError): + security.decode_access_token(stale) + + +def test_tampered_token_is_rejected(): + token = security.create_access_token("user-1", "candidate") + head, payload, sig = token.split(".") + forged = f"{head}.{payload}.{sig[:-4]}AAAA" + with pytest.raises(ValueError): + security.decode_access_token(forged) + + +def test_token_signed_with_another_secret_is_rejected(monkeypatch): + token = security.create_access_token("user-1", "candidate") + monkeypatch.setattr(security.settings, "jwt_secret", "a-different-secret") + with pytest.raises(ValueError): + security.decode_access_token(token) + + +def test_unsigned_token_is_rejected(): + """alg=none must never be honoured.""" + import base64, json + + def seg(d): + return base64.urlsafe_b64encode(json.dumps(d).encode()).rstrip(b"=").decode() + + forged = f'{seg({"alg": "none", "typ": "JWT"})}.{seg({"sub": "admin"})}.' + with pytest.raises(ValueError): + security.decode_access_token(forged) + + +def test_garbage_is_rejected(): + for junk in ["", "not.a.token", "Bearer abc"]: + with pytest.raises(ValueError): + security.decode_access_token(junk) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 70cd44f..61f029d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,40 @@ +import { useState } from "react"; import QuizPage from "./features/quiz/QuizPage"; +import LoginPage from "./features/auth/LoginPage"; +import RegisterPage from "./features/auth/RegisterPage"; +import { isLoggedIn, logout } from "./features/auth/api"; // Swap for a router (react-router) once the jobs/community pages exist. export default function App() { - return ; + // Seeded from storage so a reload does not log you out. + const [authed, setAuthed] = useState(isLoggedIn); + const [showRegister, setShowRegister] = useState(false); + + if (!authed) { + const Page = showRegister ? RegisterPage : LoginPage; + return ( +
+

OneStop

+

Log in to take a repo quiz.

+ setAuthed(true)} onSwitch={() => setShowRegister((v) => !v)} /> +
+ ); + } + + return ( + <> +
+ +
+ setAuthed(false)} /> + + ); } diff --git a/frontend/src/features/auth/AuthForm.jsx b/frontend/src/features/auth/AuthForm.jsx new file mode 100644 index 0000000..7616558 --- /dev/null +++ b/frontend/src/features/auth/AuthForm.jsx @@ -0,0 +1,74 @@ +import { useState } from "react"; + +/** + * Shared form body for login and register. + * + * Deliberately plain — this gates the quiz flow, it is not a UI milestone. + */ +export default function AuthForm({ title, submitLabel, onSubmit, showRole, footer }) { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [role, setRole] = useState("candidate"); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + async function handleSubmit(e) { + e.preventDefault(); + setBusy(true); + setError(""); + try { + await onSubmit({ email, password, role }); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + } + + return ( +
+

{title}

+ + + + + + {showRole && ( + + )} + + {error &&

{error}

} + + + + {footer} +
+ ); +} diff --git a/frontend/src/features/auth/LoginPage.jsx b/frontend/src/features/auth/LoginPage.jsx new file mode 100644 index 0000000..6993271 --- /dev/null +++ b/frontend/src/features/auth/LoginPage.jsx @@ -0,0 +1,23 @@ +import AuthForm from "./AuthForm"; +import { login } from "./api"; + +export default function LoginPage({ onAuthed, onSwitch }) { + return ( + { + await login(email, password); + onAuthed(); + }} + footer={ +

+ No account?{" "} + +

+ } + /> + ); +} diff --git a/frontend/src/features/auth/RegisterPage.jsx b/frontend/src/features/auth/RegisterPage.jsx new file mode 100644 index 0000000..1f72a58 --- /dev/null +++ b/frontend/src/features/auth/RegisterPage.jsx @@ -0,0 +1,24 @@ +import AuthForm from "./AuthForm"; +import { register } from "./api"; + +export default function RegisterPage({ onAuthed, onSwitch }) { + return ( + { + await register(email, password, role); + onAuthed(); + }} + footer={ +

+ Already registered?{" "} + +

+ } + /> + ); +} diff --git a/frontend/src/features/auth/api.js b/frontend/src/features/auth/api.js new file mode 100644 index 0000000..95e1dc3 --- /dev/null +++ b/frontend/src/features/auth/api.js @@ -0,0 +1,24 @@ +import request from "../../shared/api/client"; +import { clearToken, getToken, setToken } from "../../shared/api/token"; + +async function authenticate(path, body) { + const data = await request(path, { method: "POST", body: JSON.stringify(body) }); + setToken(data.access_token); + return data; +} + +export function register(email, password, role = "candidate") { + return authenticate("/auth/register", { email, password, role }); +} + +export function login(email, password) { + return authenticate("/auth/login", { email, password }); +} + +export function logout() { + clearToken(); +} + +export function isLoggedIn() { + return Boolean(getToken()); +} diff --git a/frontend/src/features/quiz/QuizPage.jsx b/frontend/src/features/quiz/QuizPage.jsx index 1518e15..386185f 100644 --- a/frontend/src/features/quiz/QuizPage.jsx +++ b/frontend/src/features/quiz/QuizPage.jsx @@ -4,7 +4,7 @@ import QuestionCard from "./components/QuestionCard"; import ScoreResult from "./components/ScoreResult"; import { generateQuiz, submitQuiz, submitFollowUp } from "./api"; -export default function QuizPage() { +export default function QuizPage({ onUnauthorized }) { const [repoUrl, setRepoUrl] = useState(""); const [quiz, setQuiz] = useState(null); const [answers, setAnswers] = useState({}); @@ -44,6 +44,7 @@ export default function QuizPage() { setQuiz(data); setAnswers({}); } catch (e) { + if (e.status === 401) return onUnauthorized?.(); setError(e.message); } finally { setLoading(false); @@ -66,6 +67,7 @@ export default function QuizPage() { })); setFollowup(await submitQuiz(quiz.quiz_id, payload)); } catch (e) { + if (e.status === 401) return onUnauthorized?.(); setError(e.message); sent.current.answers = false; } finally { @@ -84,6 +86,7 @@ export default function QuizPage() { await submitFollowUp(quiz.quiz_id, followupAnswer, timeLeft.current[id] ?? null) ); } catch (e) { + if (e.status === 401) return onUnauthorized?.(); setError(e.message); sent.current.followup = false; } finally { diff --git a/frontend/src/shared/api/client.js b/frontend/src/shared/api/client.js index 98ec0d1..db99846 100644 --- a/frontend/src/shared/api/client.js +++ b/frontend/src/shared/api/client.js @@ -1,16 +1,36 @@ // Only file that should call fetch() directly against the backend. // Feature api.js files (e.g. features/quiz/api.js) call this instead // of using fetch inline. +import { clearToken, getToken } from "./token"; + const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:8000/api/v1"; +export class ApiError extends Error { + constructor(message, status) { + super(message); + this.status = status; + } +} + export default async function request(path, options = {}) { + const token = getToken(); + const res = await fetch(`${API_BASE}${path}`, { - headers: { "Content-Type": "application/json" }, ...options, + headers: { + "Content-Type": "application/json", + // Attached automatically so no feature has to remember to do it. + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(options.headers || {}), + }, }); + if (!res.ok) { + // An expired or rejected token should drop the session rather than leave the + // UI insisting it is logged in while every call 401s. + if (res.status === 401) clearToken(); const body = await res.json().catch(() => ({})); - throw new Error(body.detail || "Request failed"); + throw new ApiError(body.detail || "Request failed", res.status); } return res.json(); } diff --git a/frontend/src/shared/api/token.js b/frontend/src/shared/api/token.js new file mode 100644 index 0000000..362d10a --- /dev/null +++ b/frontend/src/shared/api/token.js @@ -0,0 +1,33 @@ +// Single source of truth for the access token. +// +// Lives in shared/api rather than features/auth because the request client needs +// it too, and importing a feature from shared would invert the layering. +// +// localStorage is readable by any script on the origin, so this is only as safe as +// the app is free of XSS. An httpOnly cookie would be stronger; it needs CSRF +// handling and a same-site story that this stage does not have yet. +const KEY = "onestop_token"; + +export function getToken() { + try { + return localStorage.getItem(KEY); + } catch { + return null; // private mode / storage disabled + } +} + +export function setToken(token) { + try { + localStorage.setItem(KEY, token); + } catch { + /* non-fatal: the session just will not survive a reload */ + } +} + +export function clearToken() { + try { + localStorage.removeItem(KEY); + } catch { + /* nothing to do */ + } +} diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 8e65b84..803bede 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -61,3 +61,17 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } .question .timer.urgent { color: #ff6b6b; font-weight: 600; } .question.locked { opacity: 0.65; } .question textarea:disabled { cursor: not-allowed; } + +.topbar { max-width: 720px; margin: 0 auto; padding: 16px 20px 0; text-align: right; } +.linkish { + background: none; border: none; color: #5b8cff; + padding: 0; font-weight: 600; cursor: pointer; text-decoration: underline; +} +.auth { display: flex; flex-direction: column; gap: 14px; max-width: 360px; } +.auth h2 { margin: 0; } +.auth label { display: flex; flex-direction: column; gap: 6px; font-size: 14px; color: #9a9aa5; } +.auth select { + padding: 10px 12px; border-radius: 8px; border: 1px solid #2a2d36; + background: #171922; color: #e8e8ea; font-size: 14px; +} +.auth .switch { margin: 0; font-size: 14px; color: #9a9aa5; }