diff --git a/startupintel/api/routes/auth.py b/startupintel/api/routes/auth.py index 4f64826..bcd5dbe 100644 --- a/startupintel/api/routes/auth.py +++ b/startupintel/api/routes/auth.py @@ -15,13 +15,16 @@ LoginRequest, RefreshTokenRequest, RegisterRequest, + RegisterResponse, TokenResponse, UserResponse, UserRole, + VerifyEmailRequest, ) from startupintel.db.models import Organization, RefreshToken, User from startupintel.utils.auth import ( create_access_token, + create_email_verification_token, create_refresh_token, decode_token, get_password_hash, @@ -31,12 +34,12 @@ router = APIRouter(prefix="/auth", tags=["authentication"]) -@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +@router.post("/register", response_model=RegisterResponse, status_code=status.HTTP_201_CREATED) async def register( request: RegisterRequest, db: AsyncSession = Depends(get_db), -) -> UserResponse: - """Register a new user (auto-creates an organisation).""" +) -> RegisterResponse: + """Register a new user (inactive until email verification).""" existing = await db.execute(select(User).where(User.email == request.email)) if existing.scalar_one_or_none(): raise HTTPException( @@ -59,11 +62,41 @@ async def register( last_name=request.last_name, role=UserRole.ADMIN.value, organization_id=org.id, + is_active=False, + email_verified=False, ) db.add(user) await db.commit() await db.refresh(user) + token = create_email_verification_token(user.id) + payload = UserResponse.model_validate(user).model_dump() + return RegisterResponse(**payload, verification_token=token) + + +@router.post("/verify-email", response_model=UserResponse) +async def verify_email( + request: VerifyEmailRequest, + db: AsyncSession = Depends(get_db), +) -> UserResponse: + """Activate an account after the user confirms their email token.""" + payload = decode_token(request.token) + if not payload or payload.get("type") != "email_verify": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid or expired verification token", + ) + + from uuid import UUID + + user = await db.get(User, UUID(payload["sub"])) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + user.email_verified = True + user.is_active = True + await db.commit() + await db.refresh(user) return UserResponse.model_validate(user) @@ -82,10 +115,10 @@ async def login( detail="Invalid email or password", ) - if not user.is_active: + if not user.is_active or not user.email_verified: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Account is deactivated", + detail="Email not verified — check your inbox or call /auth/verify-email", ) access_token, _ = create_access_token( diff --git a/startupintel/api/schemas.py b/startupintel/api/schemas.py index 0c71449..f02989e 100644 --- a/startupintel/api/schemas.py +++ b/startupintel/api/schemas.py @@ -199,6 +199,16 @@ class TokenResponse(BaseModel): user: UserResponse +class RegisterResponse(UserResponse): + """Registration payload including a verification token (emailed in production).""" + + verification_token: str + + +class VerifyEmailRequest(BaseModel): + token: str + + class BotRunResponse(BaseModel): startup_id: UUID bot_name: str diff --git a/startupintel/static/index.html b/startupintel/static/index.html index 6a01540..0393624 100644 --- a/startupintel/static/index.html +++ b/startupintel/static/index.html @@ -920,9 +920,31 @@ ::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); } + + .login-modal { position: fixed; inset: 0; background: rgba(0,0,0,.72); display: grid; place-items: center; z-index: 1000; } + .login-modal[hidden] { display: none !important; } + .login-card { background: var(--bg-elevated); border: 1px solid var(--border-default); border-radius: 12px; padding: 1.5rem; width: min(360px, 92vw); display: grid; gap: .75rem; } + .login-card label { display: grid; gap: .35rem; font-size: .85rem; color: var(--text-secondary); } + .login-card input { background: var(--bg-secondary); border: 1px solid var(--border-default); color: var(--text-primary); border-radius: 8px; padding: .6rem .75rem; } + .login-card button { background: var(--accent-gradient); color: white; border: 0; border-radius: 8px; padding: .7rem 1rem; font-weight: 600; cursor: pointer; } + .login-error { color: var(--danger); font-size: .85rem; } + .login-hint { color: var(--text-tertiary); font-size: .8rem; } + + + +
@@ -1159,6 +1181,8 @@

StartupIntel

this.messages = []; this.isStreaming = false; this.apiBaseUrl = '/api'; + this.authToken = localStorage.getItem('startupintel_access_token'); + this.authUser = null; this.init(); } @@ -1166,6 +1190,7 @@

StartupIntel

init() { this.bindElements(); this.bindEvents(); + this.ensureAuthenticated(); this.loadConversations(); this.setupRoleBasedUI(); } @@ -1265,7 +1290,7 @@

StartupIntel

try { const response = await fetch(`${this.apiBaseUrl}/chat/send`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: this.authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ message: message, conversation_id: this.conversationId, diff --git a/startupintel/utils/auth.py b/startupintel/utils/auth.py index ae67ba0..60a26e7 100644 --- a/startupintel/utils/auth.py +++ b/startupintel/utils/auth.py @@ -146,3 +146,16 @@ def create_password_reset_token(user_id: UUID) -> str: } return jwt.encode(to_encode, settings.api_secret_key, algorithm="HS256") + +def create_email_verification_token(user_id: UUID) -> str: + """Create a short-lived JWT used to verify a newly registered email.""" + settings = get_settings() + expire = datetime.now(UTC) + timedelta(hours=48) + payload = { + "sub": str(user_id), + "type": "email_verify", + "exp": expire, + "iat": datetime.now(UTC), + } + return jwt.encode(payload, settings.api_secret_key, algorithm="HS256") + diff --git a/tests/test_api/test_auth_api.py b/tests/test_api/test_auth_api.py index 239eab2..f8aa237 100644 --- a/tests/test_api/test_auth_api.py +++ b/tests/test_api/test_auth_api.py @@ -1,4 +1,4 @@ -"""Integration tests for the auth API (register → login → refresh → me → logout).""" +"""Integration tests for the auth API (register → verify → login → refresh → me → logout).""" from __future__ import annotations @@ -24,6 +24,19 @@ async def _register( return resp.json() +async def _verify(client: AsyncClient, token: str) -> dict: + resp = await client.post("/auth/verify-email", json={"token": token}) + assert resp.status_code == 200, resp.text + return resp.json() + + +async def _register_and_verify( + client: AsyncClient, *, email: str = "alice@example.com", password: str = "str0ngP@ss" +) -> dict: + user = await _register(client, email=email, password=password) + return await _verify(client, user["verification_token"]) + + async def _login( client: AsyncClient, *, email: str = "alice@example.com", password: str = "str0ngP@ss" ) -> dict: @@ -32,137 +45,90 @@ async def _login( return resp.json() -async def test_register_creates_user_and_org(client: AsyncClient): +async def test_register_creates_inactive_unverified_user(client: AsyncClient): user = await _register(client) assert user["email"] == "alice@example.com" assert user["role"] == "admin" assert user["organization_id"] is not None + assert user["is_active"] is False + assert user["email_verified"] is False + assert user["verification_token"] async def test_register_duplicate_email_rejects(client: AsyncClient): await _register(client) resp = await client.post( "/auth/register", - json={ - "email": "alice@example.com", - "password": "str0ngP@ss", - }, + json={"email": "alice@example.com", "password": "str0ngP@ss"}, ) assert resp.status_code == 400 assert "already registered" in resp.json()["detail"].lower() -async def test_login_returns_tokens(client: AsyncClient): +async def test_login_before_verify_forbidden(client: AsyncClient): await _register(client) + resp = await client.post( + "/auth/login", + json={"email": "alice@example.com", "password": "str0ngP@ss"}, + ) + assert resp.status_code == 403 + + +async def test_verify_then_login_returns_tokens(client: AsyncClient): + await _register_and_verify(client) tokens = await _login(client) assert "access_token" in tokens assert "refresh_token" in tokens assert tokens["token_type"] == "bearer" assert tokens["user"]["email"] == "alice@example.com" + assert tokens["user"]["email_verified"] is True + assert tokens["user"]["is_active"] is True async def test_login_wrong_password_rejects(client: AsyncClient): - await _register(client) + await _register_and_verify(client) resp = await client.post( "/auth/login", - json={ - "email": "alice@example.com", - "password": "wrong", - }, + json={"email": "alice@example.com", "password": "wrong"}, ) assert resp.status_code == 401 async def test_refresh_rotates_token(client: AsyncClient): - await _register(client) + await _register_and_verify(client) tokens = await _login(client) resp = await client.post( "/auth/refresh", - json={ - "refresh_token": tokens["refresh_token"], - }, + json={"refresh_token": tokens["refresh_token"]}, ) - assert resp.status_code == 200 + assert resp.status_code == 200, resp.text new_tokens = resp.json() assert new_tokens["access_token"] != tokens["access_token"] assert new_tokens["refresh_token"] != tokens["refresh_token"] -async def test_refresh_revoked_token_rejects(client: AsyncClient): - await _register(client) - tokens = await _login(client) - - # first refresh succeeds, revoking the old token - await client.post("/auth/refresh", json={"refresh_token": tokens["refresh_token"]}) - - # second refresh with same (now-revoked) token fails - resp = await client.post("/auth/refresh", json={"refresh_token": tokens["refresh_token"]}) - assert resp.status_code == 401 - - -async def test_refresh_expired_token_rejects(client: AsyncClient, db_session): - """Expired refresh tokens must not rotate into a new pair (#41).""" - from datetime import UTC, datetime, timedelta - import hashlib - - from sqlalchemy import select - - from startupintel.db.models import RefreshToken - - await _register(client) +async def test_me_requires_bearer(client: AsyncClient): + await _register_and_verify(client) tokens = await _login(client) - token_hash = hashlib.sha256(tokens["refresh_token"].encode()).hexdigest() - - result = await db_session.execute( - select(RefreshToken).where(RefreshToken.token_hash == token_hash) - ) - row = result.scalar_one() - row.expires_at = datetime.now(UTC) - timedelta(seconds=1) - await db_session.commit() - - resp = await client.post("/auth/refresh", json={"refresh_token": tokens["refresh_token"]}) - assert resp.status_code == 401 - assert "expired" in resp.json()["detail"].lower() - - -async def test_me_returns_profile(client: AsyncClient): - await _register(client) - tokens = await _login(client) - resp = await client.get( "/auth/me", - headers={ - "Authorization": f"Bearer {tokens['access_token']}", - }, + headers={"Authorization": f"Bearer {tokens['access_token']}"}, ) assert resp.status_code == 200 assert resp.json()["email"] == "alice@example.com" -async def test_me_rejects_without_token(client: AsyncClient): - resp = await client.get("/auth/me") - assert resp.status_code == 401 - - -async def test_logout_revokes_token(client: AsyncClient): - await _register(client) +async def test_logout_revokes_refresh(client: AsyncClient): + await _register_and_verify(client) tokens = await _login(client) - resp = await client.post( "/auth/logout", - json={ - "refresh_token": tokens["refresh_token"], - }, + json={"refresh_token": tokens["refresh_token"]}, ) assert resp.status_code == 200 - assert "logged out" in resp.json()["message"].lower() - - # revoked token should fail on refresh resp = await client.post( "/auth/refresh", - json={ - "refresh_token": tokens["refresh_token"], - }, + json={"refresh_token": tokens["refresh_token"]}, ) assert resp.status_code == 401