Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions backend/app/gateway/auth/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,8 @@
import os
import secrets

from dotenv import load_dotenv
from pydantic import BaseModel, Field

load_dotenv()

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -37,6 +34,9 @@ def get_auth_config() -> AuthConfig:
"""Get the global AuthConfig instance. Parses from env on first call."""
global _auth_config
if _auth_config is None:
from dotenv import load_dotenv

load_dotenv()
jwt_secret = os.environ.get("AUTH_JWT_SECRET")
if not jwt_secret:
jwt_secret = secrets.token_urlsafe(32)
Expand Down
20 changes: 16 additions & 4 deletions backend/app/gateway/auth/password.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
"""Password hashing utilities using bcrypt directly."""
"""Password hashing utilities using bcrypt with SHA-256 pre-hashing.

Passwords are pre-hashed with SHA-256 before bcrypt to avoid silent
truncation at 72 bytes (bcrypt's internal limit). This ensures the
full password contributes to the hash regardless of length.
"""

import asyncio
import base64
import hashlib

import bcrypt


def _pre_hash(password: str) -> bytes:
"""Pre-hash password with SHA-256 to bypass bcrypt's 72-byte limit."""
return base64.b64encode(hashlib.sha256(password.encode("utf-8")).digest())


def hash_password(password: str) -> str:
"""Hash a password using bcrypt."""
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
"""Hash a password using bcrypt with SHA-256 pre-hashing."""
return bcrypt.hashpw(_pre_hash(password), bcrypt.gensalt()).decode("utf-8")


def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against its hash."""
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
return bcrypt.checkpw(_pre_hash(plain_password), hashed_password.encode("utf-8"))

Comment on lines +15 to 28
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing verify_password() to always SHA-256 pre-hash before bcrypt will break authentication for any existing users whose password_hash values were generated with the old (raw-password) bcrypt scheme. Consider supporting both formats during a transition (e.g., try legacy check first, then new check; or version/prefix hashes and migrate on successful login), otherwise this is a backwards-incompatible auth change that can lock out current deployments after upgrade.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@greatmengqi I'd been hit by this issue. It should work, as we don't have a public release yet. But we also need to provide a password reset method for users to use.


async def hash_password_async(password: str) -> str:
Expand Down
3 changes: 3 additions & 0 deletions backend/app/gateway/authz.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,9 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any:
auth_context = await _authenticate(request)
request.state.auth = auth_context

if not auth_context.is_authenticated:
raise HTTPException(status_code=401, detail="Authentication required")

return await func(*args, **kwargs)

return wrapper
Expand Down
2 changes: 1 addition & 1 deletion backend/app/gateway/langgraph_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ async def authenticate(request):
if isinstance(payload, TokenError):
raise Auth.exceptions.HTTPException(
status_code=401,
detail=f"Token error: {payload.value}",
detail="Invalid token",
)

user = await get_local_provider().get_user(payload.sub)
Expand Down
20 changes: 18 additions & 2 deletions backend/app/gateway/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,13 @@ def _set_session_cookie(response: Response, token: str, request: Request) -> Non


# ── Rate Limiting ────────────────────────────────────────────────────────
# In-process dict — not shared across workers. Sufficient for single-worker deployments.
# In-process dict — not shared across workers.
#
# **Limitation**: with multi-worker deployments (e.g., gunicorn -w N), each
# worker maintains its own lockout table, so an attacker effectively gets
# N × _MAX_LOGIN_ATTEMPTS guesses before being locked out everywhere. For
# production multi-worker setups, replace this with a shared store (Redis,
# database-backed counter) to enforce a true per-IP limit.

_MAX_LOGIN_ATTEMPTS = 5
_LOCKOUT_SECONDS = 300 # 5 minutes
Expand Down Expand Up @@ -376,9 +382,19 @@ async def get_me(request: Request):
return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role, needs_setup=user.needs_setup)


_SETUP_STATUS_COOLDOWN: dict[str, float] = {}
_SETUP_STATUS_COOLDOWN_SECONDS = 60

Comment on lines +385 to +388
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_SETUP_STATUS_COOLDOWN is an unbounded in-memory dict keyed by (potentially attacker-controlled) client IPs. Over time this can grow without limit and become a small memory-DoS vector. Consider adding eviction (max entries + TTL pruning) similar to the _login_attempts limiter, or using a bounded LRU/TTL cache implementation.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was already addressed in commit 9d83642 as part of the /setup-status rate-limiting fix. _SETUP_STATUS_COOLDOWN is now capped at _MAX_TRACKED_SETUP_STATUS_IPS = 10000 entries. When the cap is reached, expired (TTL-past) entries are evicted first; if still over the cap, the oldest half is dropped — mirroring the _login_attempts eviction strategy.


@router.get("/setup-status")
async def setup_status():
async def setup_status(request: Request):
"""Check if an admin account exists. Returns needs_setup=True when no admin exists."""
client_ip = _get_client_ip(request)
now = time.time()
last_check = _SETUP_STATUS_COOLDOWN.get(client_ip, 0)
if now - last_check < _SETUP_STATUS_COOLDOWN_SECONDS:
return {"needs_setup": False}
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new /setup-status rate limiting behavior isn’t covered by tests (e.g., second call within the cooldown window). Adding a targeted test would help prevent regressions and clarify the intended client-visible behavior (429 vs cached value vs other).

Suggested change
if now - last_check < _SETUP_STATUS_COOLDOWN_SECONDS:
return {"needs_setup": False}
elapsed = now - last_check
if elapsed < _SETUP_STATUS_COOLDOWN_SECONDS:
retry_after = max(1, int(_SETUP_STATUS_COOLDOWN_SECONDS - elapsed))
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Setup status check is rate limited",
headers={"Retry-After": str(retry_after)},
)

Copilot uses AI. Check for mistakes.
_SETUP_STATUS_COOLDOWN[client_ip] = now
Comment on lines +393 to +415
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The /setup-status cooldown currently returns {"needs_setup": False} during the cooldown window, which can mislead legitimate clients (e.g., UI polling) into thinking setup is complete when it may not be. Instead of returning a falsified state, consider returning 429 with Retry-After, or returning a cached last-computed needs_setup value per IP while enforcing the refresh cooldown.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in commit 9d83642. The /setup-status endpoint now returns HTTP 429 with a Retry-After header (value = remaining cooldown seconds) instead of the misleading {"needs_setup": False} response. The _SETUP_STATUS_COOLDOWN dict also gets bounded eviction (capped at 10,000 IPs) using the same TTL-first then oldest-half strategy as _login_attempts. A new test test_setup_status_rate_limited_on_second_call covers the 429 + Retry-After behavior.

admin_count = await get_local_provider().count_admin_users()
return {"needs_setup": admin_count == 0}

Expand Down
2 changes: 1 addition & 1 deletion backend/packages/harness/deerflow/runtime/journal.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ def on_chat_model_start(
logger.info(f"on_chat_model_start {run_id}: tags={tags} serialized={serialized} messages={messages}")

# Capture the first human message sent to any LLM in this run.
if not self._first_human_msg and not messages:
if not self._first_human_msg and messages:
for batch in messages.reversed():
for m in batch.reversed():
if isinstance(m, HumanMessage) and m.name != "summary":
Expand Down
7 changes: 3 additions & 4 deletions backend/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ def test_get_auth_context_set():


def test_require_auth_sets_auth_context():
"""require_auth sets auth context on request from cookie."""
"""require_auth rejects unauthenticated requests with 401."""
from fastapi import Request

app = FastAPI()
Expand All @@ -178,10 +178,9 @@ async def endpoint(request: Request):
return {"authenticated": ctx.is_authenticated}

with TestClient(app) as client:
# No cookie → anonymous
# No cookie → 401 (require_auth independently enforces authentication)
response = client.get("/test")
assert response.status_code == 200
assert response.json()["authenticated"] is False
assert response.status_code == 401


def test_require_auth_requires_request_param():
Expand Down
3 changes: 3 additions & 0 deletions backend/tests/test_initialize_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,21 @@
def _setup_auth(tmp_path):
"""Fresh SQLite engine + auth config per test."""
from app.gateway import deps
from app.gateway.routers.auth import _SETUP_STATUS_COOLDOWN
from deerflow.persistence.engine import close_engine, init_engine

set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET))
url = f"sqlite+aiosqlite:///{tmp_path}/init_admin.db"
asyncio.run(init_engine("sqlite", url=url, sqlite_dir=str(tmp_path)))
deps._cached_local_provider = None
deps._cached_repo = None
_SETUP_STATUS_COOLDOWN.clear()
try:
yield
finally:
deps._cached_local_provider = None
deps._cached_repo = None
_SETUP_STATUS_COOLDOWN.clear()
asyncio.run(close_engine())


Expand Down
4 changes: 2 additions & 2 deletions backend/tests/test_langgraph_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ def test_invalid_jwt_raises_401():
with pytest.raises(Auth.exceptions.HTTPException) as exc:
asyncio.run(authenticate(_req({"access_token": "garbage"})))
assert exc.value.status_code == 401
assert "Token error" in str(exc.value.detail)
assert "Invalid token" in str(exc.value.detail)


def test_expired_jwt_raises_401():
Expand Down Expand Up @@ -295,7 +295,7 @@ def test_csrf_post_matching_token_proceeds_to_jwt():
)
# Past CSRF, rejected by JWT decode
assert exc.value.status_code == 401
assert "Token error" in str(exc.value.detail)
assert "Invalid token" in str(exc.value.detail)


def test_csrf_put_requires_token():
Expand Down
Loading