mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-20 09:45:47 +00:00
feat(auth): add keep me signed in login option (#4255)
* feat(auth): add keep me signed in login option * fix(auth): address remember-login review feedback
This commit is contained in:
@@ -270,6 +270,10 @@ section, when present, overrides the first two for backward compatibility.
|
||||
|
||||
The unified nginx endpoint is same-origin by default and does not emit browser CORS headers. If you run a split-origin or port-forwarded browser client, set `GATEWAY_CORS_ORIGINS` to comma-separated exact origins such as `http://localhost:3000`; the Gateway then applies the CORS allowlist and matching CSRF origin checks.
|
||||
|
||||
Browser login uses `HttpOnly` session cookies. The login page offers a "keep me signed in" option that extends the browser session when the request is HTTPS (including trusted `X-Forwarded-Proto: https`) or localhost HTTP. The localhost exception uses the direct request `Host` and ignores forwarded host headers. Public HTTP deployments, including many temporary sandbox URLs, fall back to session cookies by default. DeerFlow never stores the password in browser storage; the UI may remember only the email address.
|
||||
|
||||
DeerFlow still uses `Forwarded` / `X-Forwarded-*` headers to recover the browser-facing scheme and origin behind a proxy. The bundled nginx sets `X-Forwarded-Proto`, but preserves an upstream HTTPS value and does not overwrite every forwarded header. Configure the outer trusted proxy to replace or strip client-supplied forwarding headers before traffic reaches DeerFlow.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The Gateway still owns active run tasks in process, so production defaults to a single Gateway worker (`GATEWAY_WORKERS=1`). The Redis stream bridge (`stream_bridge.type: redis`) shares SSE delivery and `Last-Event-ID` replay across workers, with a rolling retained-buffer TTL (`stream_ttl_seconds`) as a cleanup safety net. Malformed reconnect IDs live-tail new events instead of replaying the retained buffer. It does not make run cancellation, request de-duplication, or IM channel state fully cross-worker by itself; use single-worker Gateway or explicit sticky routing/ownership before raising `GATEWAY_WORKERS`.
|
||||
|
||||
|
||||
@@ -317,6 +317,10 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
|
||||
|
||||
CORS is same-origin by default when requests enter through nginx on port 2026. Split-origin or port-forwarded browser clients must opt in with `GATEWAY_CORS_ORIGINS` (comma-separated exact origins); Gateway `CORSMiddleware` and `CSRFMiddleware` both read that variable so browser CORS and auth-origin checks stay aligned.
|
||||
|
||||
Browser auth sessions are owned by `app.gateway.auth.session_cookie`. Login accepts a `remember_me` form flag, but the Gateway never stores passwords. `SessionCookiePolicy` persists the `HttpOnly access_token` cookie only for HTTPS/trusted-forwarded HTTPS, direct-host localhost HTTP, or explicit operator opt-in for insecure persistence; public HTTP sandbox URLs degrade to session cookies. Session-creating handlers stamp the final `max_age` on `request.state`, and CSRF cookie creation mirrors that value so the double-submit cookie pair expires together, including explicit re-issue after password changes and OIDC callbacks. A small `HttpOnly` preference cookie preserves the user's remember choice across token re-issue paths. Logout clears all auth cookies and suppresses CSRF re-issue on the logout response.
|
||||
|
||||
Localhost persistence deliberately reads the direct request `Host` and ignores `Forwarded` / `X-Forwarded-Host`. Scheme and auth-origin reconstruction still consume forwarding headers. The bundled nginx sets `X-Forwarded-Proto`, but preserves an upstream HTTPS value and does not overwrite every forwarded header, so the outer trusted proxy must replace or strip client-supplied forwarding headers before traffic reaches DeerFlow.
|
||||
|
||||
**Routers**:
|
||||
|
||||
| Router | Endpoints |
|
||||
|
||||
@@ -32,6 +32,7 @@ class OIDCStatePayload(BaseModel):
|
||||
nonce: str | None = Field(default=None, description="OIDC nonce, verified against the ID token nonce claim")
|
||||
code_verifier: str | None = Field(default=None, description="PKCE code verifier, sent during token exchange")
|
||||
next_path: str = Field(default="/workspace", description="Redirect target after successful auth")
|
||||
remember_me: bool = Field(default=True, description="Whether the resulting DeerFlow session should be persistent")
|
||||
issued_at: float = Field(default_factory=time.time, description="Unix timestamp of cookie creation")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Browser session cookie policy for Gateway authentication."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from ipaddress import ip_address
|
||||
|
||||
from fastapi import Request, Response
|
||||
|
||||
from app.gateway.auth.config import get_auth_config
|
||||
from app.gateway.auth.session_cookie_state import (
|
||||
SESSION_COOKIE_ISSUED_STATE_ATTR,
|
||||
SESSION_COOKIE_MAX_AGE_STATE_ATTR,
|
||||
SESSION_COOKIE_SECURE_STATE_ATTR,
|
||||
)
|
||||
from app.gateway.csrf_middleware import is_secure_request
|
||||
|
||||
ACCESS_TOKEN_COOKIE_NAME = "access_token"
|
||||
SESSION_PERSISTENCE_COOKIE_NAME = "deerflow_session_persistent"
|
||||
ALLOW_INSECURE_PERSISTENT_COOKIE_ENV = "DEER_FLOW_AUTH_ALLOW_INSECURE_PERSISTENT_COOKIE"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionCookiePolicy:
|
||||
"""Resolved cookie settings for a session-creating auth response."""
|
||||
|
||||
secure: bool
|
||||
max_age: int | None
|
||||
reason: str
|
||||
|
||||
|
||||
def _env_flag_enabled(name: str) -> bool:
|
||||
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _request_hostname(request: Request) -> str:
|
||||
"""Return the direct request host without trusting forwarded host headers."""
|
||||
if request.url.hostname:
|
||||
return request.url.hostname.lower()
|
||||
return ""
|
||||
|
||||
|
||||
def is_local_browser_origin(request: Request) -> bool:
|
||||
"""Return True for loopback browser origins where HTTP persistence is acceptable."""
|
||||
host = _request_hostname(request)
|
||||
if host == "localhost" or host.endswith(".localhost"):
|
||||
return True
|
||||
try:
|
||||
return ip_address(host).is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _remember_me_from_cookie(request: Request, *, default: bool) -> bool:
|
||||
value = request.cookies.get(SESSION_PERSISTENCE_COOKIE_NAME)
|
||||
if value == "1":
|
||||
return True
|
||||
if value == "0":
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def resolve_session_cookie_policy(request: Request, *, remember_me: bool | None = None, default_remember_me: bool = True) -> SessionCookiePolicy:
|
||||
"""Resolve session cookie settings from user intent and deployment context."""
|
||||
remember = _remember_me_from_cookie(request, default=default_remember_me) if remember_me is None else remember_me
|
||||
secure = is_secure_request(request)
|
||||
lifetime_seconds = get_auth_config().token_expiry_days * 24 * 3600
|
||||
|
||||
if not remember:
|
||||
return SessionCookiePolicy(secure=secure, max_age=None, reason="session_requested")
|
||||
|
||||
if secure:
|
||||
return SessionCookiePolicy(secure=True, max_age=lifetime_seconds, reason="secure_persistent")
|
||||
|
||||
if is_local_browser_origin(request):
|
||||
return SessionCookiePolicy(secure=False, max_age=lifetime_seconds, reason="localhost_persistent")
|
||||
|
||||
if _env_flag_enabled(ALLOW_INSECURE_PERSISTENT_COOKIE_ENV):
|
||||
return SessionCookiePolicy(secure=False, max_age=lifetime_seconds, reason="operator_insecure_persistent")
|
||||
|
||||
return SessionCookiePolicy(secure=False, max_age=None, reason="public_http_session")
|
||||
|
||||
|
||||
def set_session_cookie(response: Response, request: Request, token: str, *, remember_me: bool | None = None, default_remember_me: bool = True) -> SessionCookiePolicy:
|
||||
"""Set the HttpOnly access-token cookie and stamp its lifetime on request state."""
|
||||
resolved_remember_me = _remember_me_from_cookie(request, default=default_remember_me) if remember_me is None else remember_me
|
||||
policy = resolve_session_cookie_policy(request, remember_me=resolved_remember_me, default_remember_me=default_remember_me)
|
||||
response.set_cookie(
|
||||
key=ACCESS_TOKEN_COOKIE_NAME,
|
||||
value=token,
|
||||
httponly=True,
|
||||
secure=policy.secure,
|
||||
samesite="lax",
|
||||
max_age=policy.max_age,
|
||||
)
|
||||
response.set_cookie(
|
||||
key=SESSION_PERSISTENCE_COOKIE_NAME,
|
||||
value="1" if resolved_remember_me else "0",
|
||||
httponly=True,
|
||||
secure=policy.secure,
|
||||
samesite="lax",
|
||||
max_age=policy.max_age,
|
||||
)
|
||||
setattr(request.state, SESSION_COOKIE_MAX_AGE_STATE_ATTR, policy.max_age)
|
||||
setattr(request.state, SESSION_COOKIE_SECURE_STATE_ATTR, policy.secure)
|
||||
setattr(request.state, SESSION_COOKIE_ISSUED_STATE_ATTR, True)
|
||||
logger.debug("Resolved auth session cookie policy: reason=%s secure=%s max_age=%s", policy.reason, policy.secure, policy.max_age)
|
||||
return policy
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Request-state keys shared by session and CSRF cookie handling."""
|
||||
|
||||
SESSION_COOKIE_ISSUED_STATE_ATTR = "deerflow_session_cookie_issued"
|
||||
SESSION_COOKIE_MAX_AGE_STATE_ATTR = "deerflow_session_cookie_max_age"
|
||||
SESSION_COOKIE_SECURE_STATE_ATTR = "deerflow_session_cookie_secure"
|
||||
SKIP_AUTH_CSRF_COOKIE_STATE_ATTR = "deerflow_skip_auth_csrf_cookie"
|
||||
@@ -15,6 +15,7 @@ from starlette.responses import JSONResponse
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from app.gateway.auth.config import get_auth_config
|
||||
from app.gateway.auth.session_cookie_state import SESSION_COOKIE_ISSUED_STATE_ATTR, SESSION_COOKIE_MAX_AGE_STATE_ATTR, SESSION_COOKIE_SECURE_STATE_ATTR, SKIP_AUTH_CSRF_COOKIE_STATE_ATTR
|
||||
from app.gateway.auth_disabled import is_auth_disabled
|
||||
|
||||
CSRF_COOKIE_NAME = "csrf_token"
|
||||
@@ -181,6 +182,20 @@ def is_allowed_auth_origin(request: Request) -> bool:
|
||||
return normalized_origin in _configured_cors_origins() or (request_origin is not None and normalized_origin == request_origin)
|
||||
|
||||
|
||||
def auth_csrf_cookie_settings(request: Request) -> tuple[bool, int | None]:
|
||||
"""Return ``(secure, max_age)`` for auth-created CSRF cookies."""
|
||||
session_cookie_issued = getattr(request.state, SESSION_COOKIE_ISSUED_STATE_ATTR, False)
|
||||
if session_cookie_issued:
|
||||
return (
|
||||
bool(getattr(request.state, SESSION_COOKIE_SECURE_STATE_ATTR, is_secure_request(request))),
|
||||
getattr(request.state, SESSION_COOKIE_MAX_AGE_STATE_ATTR, None),
|
||||
)
|
||||
|
||||
secure = is_secure_request(request)
|
||||
max_age = get_auth_config().token_expiry_days * 24 * 3600 if secure else None
|
||||
return secure, max_age
|
||||
|
||||
|
||||
class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware that implements CSRF protection using Double Submit Cookie pattern."""
|
||||
|
||||
@@ -214,23 +229,26 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
# For auth endpoints that set up session, also set CSRF cookie
|
||||
if _is_auth and request.method == "POST":
|
||||
# For auth endpoints that set up session, also set CSRF cookie.
|
||||
# Session-creating handlers may stamp the final access-token max_age on
|
||||
# request.state; mirroring it here keeps the double-submit cookie pair
|
||||
# from diverging across HTTPS, localhost, and sandbox deployments.
|
||||
if _is_auth and request.method == "POST" and not getattr(request.state, SKIP_AUTH_CSRF_COOKIE_STATE_ATTR, False):
|
||||
# Generate a new CSRF token for the session
|
||||
csrf_token = generate_csrf_token()
|
||||
is_https = is_secure_request(request)
|
||||
secure, max_age = auth_csrf_cookie_settings(request)
|
||||
response.set_cookie(
|
||||
key=CSRF_COOKIE_NAME,
|
||||
value=csrf_token,
|
||||
httponly=False, # Must be JS-readable for Double Submit Cookie pattern
|
||||
secure=is_https,
|
||||
secure=secure,
|
||||
samesite="strict",
|
||||
# Match the access_token cookie's lifetime (auth.py::_set_session_cookie)
|
||||
# so the double-submit pair never diverges. A session-only csrf_token is
|
||||
# evicted when iOS Safari terminates a home-screen PWA while the persistent
|
||||
# access_token survives — leaving the user "logged in" but unable to make
|
||||
# any state-changing request (403 "CSRF token missing").
|
||||
max_age=get_auth_config().token_expiry_days * 24 * 3600 if is_https else None,
|
||||
max_age=max_age,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@@ -9,7 +9,7 @@ import time
|
||||
import urllib.parse
|
||||
from ipaddress import ip_address, ip_network
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
from starlette.responses import RedirectResponse
|
||||
@@ -31,8 +31,10 @@ from app.gateway.auth.oidc_state import (
|
||||
get_state_cookie,
|
||||
set_state_cookie,
|
||||
)
|
||||
from app.gateway.auth.session_cookie import ACCESS_TOKEN_COOKIE_NAME, SESSION_PERSISTENCE_COOKIE_NAME, set_session_cookie
|
||||
from app.gateway.auth.session_cookie_state import SKIP_AUTH_CSRF_COOKIE_STATE_ATTR
|
||||
from app.gateway.auth.user_provisioning import get_or_provision_oidc_user
|
||||
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, _request_origin, generate_csrf_token, is_secure_request
|
||||
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, _request_origin, auth_csrf_cookie_settings, generate_csrf_token, is_secure_request
|
||||
from app.gateway.deps import get_current_user_from_request, get_local_provider
|
||||
from deerflow.config.auth_config import OIDCProviderConfig
|
||||
|
||||
@@ -126,6 +128,7 @@ class RegisterRequest(BaseModel):
|
||||
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=8)
|
||||
remember_me: bool = True
|
||||
|
||||
_strong_password = field_validator("password")(classmethod(lambda cls, v: _validate_strong_password(v)))
|
||||
|
||||
@@ -136,6 +139,7 @@ class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str = Field(..., min_length=8)
|
||||
new_email: EmailStr | None = None
|
||||
remember_me: bool | None = None
|
||||
|
||||
_strong_password = field_validator("new_password")(classmethod(lambda cls, v: _validate_strong_password(v)))
|
||||
|
||||
@@ -149,18 +153,9 @@ class MessageResponse(BaseModel):
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, token: str, request: Request) -> None:
|
||||
def _set_session_cookie(response: Response, token: str, request: Request, *, remember_me: bool | None = None) -> None:
|
||||
"""Set the access_token HttpOnly cookie on the response."""
|
||||
config = get_auth_config()
|
||||
is_https = is_secure_request(request)
|
||||
response.set_cookie(
|
||||
key="access_token",
|
||||
value=token,
|
||||
httponly=True,
|
||||
secure=is_https,
|
||||
samesite="lax",
|
||||
max_age=config.token_expiry_days * 24 * 3600 if is_https else None,
|
||||
)
|
||||
set_session_cookie(response, request, token, remember_me=remember_me)
|
||||
|
||||
|
||||
# ── Rate Limiting ────────────────────────────────────────────────────────
|
||||
@@ -295,6 +290,7 @@ async def login_local(
|
||||
request: Request,
|
||||
response: Response,
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
remember_me: bool = Form(default=True),
|
||||
):
|
||||
"""Local email/password login."""
|
||||
client_ip = _get_client_ip(request)
|
||||
@@ -311,7 +307,7 @@ async def login_local(
|
||||
|
||||
_record_login_success(client_ip)
|
||||
token = create_access_token(str(user.id), token_version=user.token_version)
|
||||
_set_session_cookie(response, token, request)
|
||||
_set_session_cookie(response, token, request, remember_me=remember_me)
|
||||
|
||||
return LoginResponse(
|
||||
expires_in=get_auth_config().token_expiry_days * 24 * 3600,
|
||||
@@ -335,7 +331,7 @@ async def register(request: Request, response: Response, body: RegisterRequest):
|
||||
)
|
||||
|
||||
token = create_access_token(str(user.id), token_version=user.token_version)
|
||||
_set_session_cookie(response, token, request)
|
||||
_set_session_cookie(response, token, request, remember_me=body.remember_me)
|
||||
|
||||
return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role, oauth_provider=user.oauth_provider)
|
||||
|
||||
@@ -343,7 +339,11 @@ async def register(request: Request, response: Response, body: RegisterRequest):
|
||||
@router.post("/logout", response_model=MessageResponse)
|
||||
async def logout(request: Request, response: Response):
|
||||
"""Logout current user by clearing the cookie."""
|
||||
response.delete_cookie(key="access_token", secure=is_secure_request(request), samesite="lax")
|
||||
is_https = is_secure_request(request)
|
||||
response.delete_cookie(key=ACCESS_TOKEN_COOKIE_NAME, secure=is_https, samesite="lax")
|
||||
response.delete_cookie(key=CSRF_COOKIE_NAME, secure=is_https, samesite="strict")
|
||||
response.delete_cookie(key=SESSION_PERSISTENCE_COOKIE_NAME, secure=is_https, samesite="lax")
|
||||
setattr(request.state, SKIP_AUTH_CSRF_COOKIE_STATE_ATTR, True)
|
||||
return MessageResponse(message="Successfully logged out")
|
||||
|
||||
|
||||
@@ -398,7 +398,8 @@ async def change_password(request: Request, response: Response, body: ChangePass
|
||||
|
||||
# Re-issue cookie with new token_version
|
||||
token = create_access_token(str(user.id), token_version=user.token_version)
|
||||
_set_session_cookie(response, token, request)
|
||||
_set_session_cookie(response, token, request, remember_me=body.remember_me)
|
||||
_set_csrf_cookie(response, request)
|
||||
|
||||
return MessageResponse(message="Password changed successfully")
|
||||
|
||||
@@ -489,6 +490,7 @@ class InitializeAdminRequest(BaseModel):
|
||||
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=8)
|
||||
remember_me: bool = True
|
||||
|
||||
_strong_password = field_validator("password")(classmethod(lambda cls, v: _validate_strong_password(v)))
|
||||
|
||||
@@ -525,7 +527,7 @@ async def initialize_admin(request: Request, response: Response, body: Initializ
|
||||
)
|
||||
|
||||
token = create_access_token(str(user.id), token_version=user.token_version)
|
||||
_set_session_cookie(response, token, request)
|
||||
_set_session_cookie(response, token, request, remember_me=body.remember_me)
|
||||
|
||||
return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role, oauth_provider=user.oauth_provider)
|
||||
|
||||
@@ -552,17 +554,17 @@ async def close_oidc_service() -> None:
|
||||
def _set_csrf_cookie(response: Response, request: Request) -> None:
|
||||
"""Set the CSRF double-submit cookie (needed for GET-based OIDC callback)."""
|
||||
csrf_token = generate_csrf_token()
|
||||
is_https = is_secure_request(request)
|
||||
secure, max_age = auth_csrf_cookie_settings(request)
|
||||
response.set_cookie(
|
||||
key=CSRF_COOKIE_NAME,
|
||||
value=csrf_token,
|
||||
httponly=False, # Must be JS-readable for Double Submit Cookie pattern
|
||||
secure=is_https,
|
||||
secure=secure,
|
||||
samesite="strict",
|
||||
# Persist for the same lifetime as the access_token (see _set_session_cookie)
|
||||
# so the double-submit pair is evicted together, never leaving a logged-in
|
||||
# session whose csrf_token was dropped (e.g. iOS Safari PWA termination).
|
||||
max_age=get_auth_config().token_expiry_days * 24 * 3600 if is_https else None,
|
||||
max_age=max_age,
|
||||
)
|
||||
|
||||
|
||||
@@ -617,6 +619,7 @@ async def oauth_login(
|
||||
request: Request,
|
||||
provider: str,
|
||||
next: str | None = None, # noqa: A002 (shadowing built-in is intentional — this is the query param name)
|
||||
remember_me: bool = True,
|
||||
):
|
||||
"""Initiate OIDC login flow.
|
||||
|
||||
@@ -682,6 +685,7 @@ async def oauth_login(
|
||||
nonce=nonce_value,
|
||||
code_verifier=code_verifier,
|
||||
next_path=redirect_path,
|
||||
remember_me=remember_me,
|
||||
)
|
||||
redirect_response = RedirectResponse(url=auth_url, status_code=status.HTTP_302_FOUND)
|
||||
set_state_cookie(redirect_response, request, state_payload)
|
||||
@@ -797,7 +801,7 @@ async def oauth_callback(
|
||||
redirect_response = RedirectResponse(url=callback_redirect, status_code=status.HTTP_302_FOUND)
|
||||
|
||||
# Set session cookie (reuse existing helper)
|
||||
_set_session_cookie(redirect_response, token, request)
|
||||
_set_session_cookie(redirect_response, token, request, remember_me=state_payload.remember_me)
|
||||
|
||||
# Set CSRF cookie (callback is a GET, so CSRF middleware won't set it)
|
||||
_set_csrf_cookie(redirect_response, request)
|
||||
|
||||
@@ -634,6 +634,131 @@ def _get_set_cookie_headers(resp) -> list[str]:
|
||||
return [v for k, v in resp.headers.multi_items() if k.lower() == "set-cookie"]
|
||||
|
||||
|
||||
def _get_response_set_cookie_headers(resp) -> list[str]:
|
||||
return [v.decode("latin-1") for k, v in resp.raw_headers if k.lower() == b"set-cookie"]
|
||||
|
||||
|
||||
def _make_request_scope(*, scheme: str = "http", host: str = "example.test", headers: dict[str, str] | None = None) -> dict:
|
||||
raw_headers = [(b"host", host.encode("ascii"))]
|
||||
for key, value in (headers or {}).items():
|
||||
raw_headers.append((key.lower().encode("ascii"), value.encode("ascii")))
|
||||
return {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/login/local",
|
||||
"headers": raw_headers,
|
||||
"scheme": scheme,
|
||||
"server": (host.split(":", 1)[0], 80 if scheme == "http" else 443),
|
||||
"query_string": b"",
|
||||
}
|
||||
|
||||
|
||||
def test_session_cookie_policy_persists_on_https():
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.gateway.auth.session_cookie import resolve_session_cookie_policy
|
||||
|
||||
_setup_config()
|
||||
request = Request(_make_request_scope(scheme="http", host="internal:8000", headers={"x-forwarded-proto": "https", "x-forwarded-host": "deerflow.example"}))
|
||||
|
||||
policy = resolve_session_cookie_policy(request, remember_me=True)
|
||||
|
||||
assert policy.secure is True
|
||||
assert policy.max_age == 7 * 24 * 3600
|
||||
|
||||
|
||||
def test_session_cookie_policy_persists_on_localhost_http():
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.gateway.auth.session_cookie import resolve_session_cookie_policy
|
||||
|
||||
_setup_config()
|
||||
request = Request(_make_request_scope(scheme="http", host="localhost:2026"))
|
||||
|
||||
policy = resolve_session_cookie_policy(request, remember_me=True)
|
||||
|
||||
assert policy.secure is False
|
||||
assert policy.max_age == 7 * 24 * 3600
|
||||
|
||||
|
||||
def test_session_cookie_policy_persists_on_ipv4_loopback_range():
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.gateway.auth.session_cookie import resolve_session_cookie_policy
|
||||
|
||||
_setup_config()
|
||||
request = Request(_make_request_scope(scheme="http", host="127.1.2.3:2026"))
|
||||
|
||||
policy = resolve_session_cookie_policy(request, remember_me=True)
|
||||
|
||||
assert policy.secure is False
|
||||
assert policy.max_age == 7 * 24 * 3600
|
||||
|
||||
|
||||
def test_session_cookie_policy_degrades_public_http_to_session_cookie():
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.gateway.auth.session_cookie import resolve_session_cookie_policy
|
||||
|
||||
_setup_config()
|
||||
request = Request(_make_request_scope(scheme="http", host="sandbox.example"))
|
||||
|
||||
policy = resolve_session_cookie_policy(request, remember_me=True)
|
||||
|
||||
assert policy.secure is False
|
||||
assert policy.max_age is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"spoofed_headers",
|
||||
[
|
||||
{"x-forwarded-host": "localhost:2026"},
|
||||
{"forwarded": 'for=192.0.2.1;host="localhost:2026";proto=http'},
|
||||
],
|
||||
)
|
||||
def test_session_cookie_policy_ignores_forwarded_localhost_on_public_http(spoofed_headers):
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.gateway.auth.session_cookie import resolve_session_cookie_policy
|
||||
|
||||
_setup_config()
|
||||
request = Request(_make_request_scope(scheme="http", host="sandbox.example", headers=spoofed_headers))
|
||||
|
||||
policy = resolve_session_cookie_policy(request, remember_me=True)
|
||||
|
||||
assert policy.secure is False
|
||||
assert policy.max_age is None
|
||||
|
||||
|
||||
def test_session_cookie_policy_remember_me_false_is_session_cookie():
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.gateway.auth.session_cookie import resolve_session_cookie_policy
|
||||
|
||||
_setup_config()
|
||||
request = Request(_make_request_scope(scheme="http", host="localhost:2026"))
|
||||
|
||||
policy = resolve_session_cookie_policy(request, remember_me=False)
|
||||
|
||||
assert policy.secure is False
|
||||
assert policy.max_age is None
|
||||
|
||||
|
||||
def test_session_cookie_policy_allows_operator_opt_in_for_public_http(monkeypatch):
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.gateway.auth.session_cookie import ALLOW_INSECURE_PERSISTENT_COOKIE_ENV, resolve_session_cookie_policy
|
||||
|
||||
_setup_config()
|
||||
monkeypatch.setenv(ALLOW_INSECURE_PERSISTENT_COOKIE_ENV, "1")
|
||||
request = Request(_make_request_scope(scheme="http", host="sandbox.example"))
|
||||
|
||||
policy = resolve_session_cookie_policy(request, remember_me=True)
|
||||
|
||||
assert policy.secure is False
|
||||
assert policy.max_age == 7 * 24 * 3600
|
||||
|
||||
|
||||
def test_register_http_cookie_httponly_true_secure_false():
|
||||
"""HTTP register → access_token cookie is httponly=True, secure=False, no max_age."""
|
||||
_setup_config()
|
||||
@@ -666,6 +791,28 @@ def test_register_https_cookie_httponly_true_secure_true():
|
||||
assert "max-age" in cookie_header.lower()
|
||||
|
||||
|
||||
def test_register_remember_me_false_keeps_access_and_csrf_session_only():
|
||||
_setup_config()
|
||||
client = TestClient(_make_auth_app(), base_url="https://deerflow.example")
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": _unique_email("register-session"), "password": "Tr0ub4dor3a", "remember_me": False},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
set_cookies = _get_set_cookie_headers(resp)
|
||||
access_cookies = [h for h in set_cookies if "access_token=" in h]
|
||||
csrf_cookies = [h for h in set_cookies if "csrf_token=" in h]
|
||||
preference_cookies = [h for h in set_cookies if "deerflow_session_persistent=" in h]
|
||||
assert access_cookies and csrf_cookies and preference_cookies
|
||||
assert "secure" in access_cookies[0].lower()
|
||||
assert "secure" in csrf_cookies[0].lower()
|
||||
assert "max-age" not in access_cookies[0].lower()
|
||||
assert "max-age" not in csrf_cookies[0].lower()
|
||||
assert "deerflow_session_persistent=0" in preference_cookies[0].lower()
|
||||
|
||||
|
||||
def test_login_https_sets_secure_cookie():
|
||||
"""HTTPS login → access_token cookie has secure flag."""
|
||||
_setup_config()
|
||||
@@ -684,6 +831,187 @@ def test_login_https_sets_secure_cookie():
|
||||
assert "secure" in cookie_header.lower()
|
||||
|
||||
|
||||
def test_login_remember_me_false_keeps_access_and_csrf_session_only():
|
||||
"""remember_me=false should make both access_token and csrf_token session cookies."""
|
||||
_setup_config()
|
||||
client = TestClient(_make_auth_app(), base_url="http://localhost:2026")
|
||||
email = _unique_email("remember-false")
|
||||
client.post("/api/v1/auth/register", json={"email": email, "password": "Tr0ub4dor3a"})
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/auth/login/local",
|
||||
data={"username": email, "password": "Tr0ub4dor3a", "remember_me": "false"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
set_cookies = _get_set_cookie_headers(resp)
|
||||
access_cookies = [h for h in set_cookies if "access_token=" in h]
|
||||
csrf_cookies = [h for h in set_cookies if "csrf_token=" in h]
|
||||
assert access_cookies, "access_token cookie not set on login"
|
||||
assert csrf_cookies, "csrf_token cookie not set on login"
|
||||
assert "max-age" not in access_cookies[0].lower()
|
||||
assert "max-age" not in csrf_cookies[0].lower()
|
||||
|
||||
|
||||
def test_login_remember_me_false_over_https_keeps_csrf_session_only():
|
||||
_setup_config()
|
||||
client = TestClient(_make_auth_app(), base_url="https://deerflow.example")
|
||||
email = _unique_email("remember-false-https")
|
||||
client.post("/api/v1/auth/register", json={"email": email, "password": "Tr0ub4dor3a"})
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/auth/login/local",
|
||||
data={"username": email, "password": "Tr0ub4dor3a", "remember_me": "false"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
set_cookies = _get_set_cookie_headers(resp)
|
||||
access_cookies = [h for h in set_cookies if "access_token=" in h]
|
||||
csrf_cookies = [h for h in set_cookies if "csrf_token=" in h]
|
||||
assert access_cookies and csrf_cookies
|
||||
assert "secure" in access_cookies[0].lower()
|
||||
assert "secure" in csrf_cookies[0].lower()
|
||||
assert "max-age" not in access_cookies[0].lower()
|
||||
assert "max-age" not in csrf_cookies[0].lower()
|
||||
|
||||
|
||||
def test_login_failure_uses_csrf_fallback_cookie_lifetime_on_https():
|
||||
_setup_config()
|
||||
client = TestClient(_make_auth_app(), base_url="https://deerflow.example")
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/auth/login/local",
|
||||
data={"username": "missing@example.com", "password": "wrong", "remember_me": "false"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 401
|
||||
csrf_cookies = [h for h in _get_set_cookie_headers(resp) if "csrf_token=" in h]
|
||||
assert csrf_cookies
|
||||
assert "secure" in csrf_cookies[0].lower()
|
||||
assert "max-age=604800" in csrf_cookies[0].lower()
|
||||
|
||||
|
||||
def test_login_remember_me_true_keeps_access_and_csrf_max_age_in_lockstep_on_localhost():
|
||||
"""localhost HTTP can persist, but access_token and csrf_token must share the same max_age."""
|
||||
_setup_config()
|
||||
client = TestClient(_make_auth_app(), base_url="http://localhost:2026")
|
||||
email = _unique_email("remember-true")
|
||||
client.post("/api/v1/auth/register", json={"email": email, "password": "Tr0ub4dor3a"})
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/auth/login/local",
|
||||
data={"username": email, "password": "Tr0ub4dor3a", "remember_me": "true"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
set_cookies = _get_set_cookie_headers(resp)
|
||||
access_cookies = [h for h in set_cookies if "access_token=" in h]
|
||||
csrf_cookies = [h for h in set_cookies if "csrf_token=" in h]
|
||||
assert access_cookies and csrf_cookies
|
||||
assert "max-age=604800" in access_cookies[0].lower()
|
||||
assert "max-age=604800" in csrf_cookies[0].lower()
|
||||
|
||||
|
||||
def test_change_password_preserves_session_only_preference():
|
||||
_setup_config()
|
||||
client = TestClient(_make_auth_app(), base_url="https://deerflow.example")
|
||||
email = _unique_email("change-password-session")
|
||||
client.post("/api/v1/auth/register", json={"email": email, "password": "Tr0ub4dor3a"})
|
||||
client.post(
|
||||
"/api/v1/auth/login/local",
|
||||
data={"username": email, "password": "Tr0ub4dor3a", "remember_me": "false"},
|
||||
)
|
||||
csrf_token = client.cookies.get("csrf_token")
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/auth/change-password",
|
||||
json={"current_password": "Tr0ub4dor3a", "new_password": "An0therStrongPwd!"},
|
||||
headers={"X-CSRF-Token": csrf_token},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
set_cookies = _get_set_cookie_headers(resp)
|
||||
access_cookies = [h for h in set_cookies if "access_token=" in h]
|
||||
preference_cookies = [h for h in set_cookies if "deerflow_session_persistent=" in h]
|
||||
assert access_cookies and preference_cookies
|
||||
assert "max-age" not in access_cookies[0].lower()
|
||||
assert "deerflow_session_persistent=0" in preference_cookies[0].lower()
|
||||
|
||||
|
||||
def test_change_password_reissues_access_and_csrf_in_lockstep_when_preference_changes():
|
||||
_setup_config()
|
||||
client = TestClient(_make_auth_app(), base_url="https://deerflow.example")
|
||||
email = _unique_email("change-password-persistent")
|
||||
client.post("/api/v1/auth/register", json={"email": email, "password": "Tr0ub4dor3a"})
|
||||
client.post(
|
||||
"/api/v1/auth/login/local",
|
||||
data={"username": email, "password": "Tr0ub4dor3a", "remember_me": "false"},
|
||||
)
|
||||
csrf_token = client.cookies.get("csrf_token")
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/auth/change-password",
|
||||
json={
|
||||
"current_password": "Tr0ub4dor3a",
|
||||
"new_password": "An0therStrongPwd!",
|
||||
"remember_me": True,
|
||||
},
|
||||
headers={"X-CSRF-Token": csrf_token},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
set_cookies = _get_set_cookie_headers(resp)
|
||||
access_cookies = [h.lower() for h in set_cookies if "access_token=" in h]
|
||||
csrf_cookies = [h.lower() for h in set_cookies if "csrf_token=" in h]
|
||||
assert access_cookies and csrf_cookies
|
||||
assert "secure" in access_cookies[0]
|
||||
assert "secure" in csrf_cookies[0]
|
||||
assert "max-age=604800" in access_cookies[0]
|
||||
assert "max-age=604800" in csrf_cookies[0]
|
||||
|
||||
|
||||
def test_initialize_remember_me_false_keeps_access_and_csrf_session_only():
|
||||
_setup_config()
|
||||
client = TestClient(_make_auth_app(), base_url="https://deerflow.example")
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/auth/initialize",
|
||||
json={"email": _unique_email("init-session"), "password": "Tr0ub4dor3a", "remember_me": False},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
set_cookies = _get_set_cookie_headers(resp)
|
||||
access_cookies = [h for h in set_cookies if "access_token=" in h]
|
||||
csrf_cookies = [h for h in set_cookies if "csrf_token=" in h]
|
||||
preference_cookies = [h for h in set_cookies if "deerflow_session_persistent=" in h]
|
||||
assert access_cookies and csrf_cookies and preference_cookies
|
||||
assert "secure" in access_cookies[0].lower()
|
||||
assert "secure" in csrf_cookies[0].lower()
|
||||
assert "max-age" not in access_cookies[0].lower()
|
||||
assert "max-age" not in csrf_cookies[0].lower()
|
||||
assert "deerflow_session_persistent=0" in preference_cookies[0].lower()
|
||||
|
||||
|
||||
def test_logout_clears_access_and_csrf_without_reissuing_csrf():
|
||||
_setup_config()
|
||||
client = TestClient(_make_auth_app(), base_url="https://deerflow.example")
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": _unique_email("logout-clear"), "password": "Tr0ub4dor3a"},
|
||||
)
|
||||
|
||||
resp = client.post("/api/v1/auth/logout")
|
||||
|
||||
assert resp.status_code == 200
|
||||
set_cookies = _get_set_cookie_headers(resp)
|
||||
access_cookies = [h for h in set_cookies if "access_token=" in h]
|
||||
csrf_cookies = [h for h in set_cookies if "csrf_token=" in h]
|
||||
preference_cookies = [h for h in set_cookies if "deerflow_session_persistent=" in h]
|
||||
assert access_cookies and "max-age=0" in access_cookies[0].lower()
|
||||
assert csrf_cookies and "max-age=0" in csrf_cookies[0].lower()
|
||||
assert preference_cookies and "max-age=0" in preference_cookies[0].lower()
|
||||
|
||||
|
||||
def test_csrf_cookie_secure_on_https():
|
||||
"""HTTPS register → csrf_token cookie has secure flag but NOT httponly."""
|
||||
_setup_config()
|
||||
@@ -764,8 +1092,8 @@ def test_csrf_cookie_session_only_on_http():
|
||||
assert "max-age" not in csrf_cookies[0].lower()
|
||||
|
||||
|
||||
def test_oidc_callback_csrf_cookie_persistent_on_https():
|
||||
"""The OIDC-callback CSRF cookie helper is persistent over HTTPS too.
|
||||
def test_oidc_callback_access_and_csrf_cookie_lifetime_match_on_https():
|
||||
"""The OIDC-callback cookie helpers keep access/csrf attributes in lockstep.
|
||||
|
||||
``routers.auth._set_csrf_cookie`` is the second place a csrf_token cookie
|
||||
is minted (GET OIDC callback, which CSRFMiddleware does not cover). It has
|
||||
@@ -775,7 +1103,7 @@ def test_oidc_callback_csrf_cookie_persistent_on_https():
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.gateway.routers.auth import _set_csrf_cookie
|
||||
from app.gateway.routers.auth import _set_csrf_cookie, _set_session_cookie
|
||||
|
||||
_setup_config()
|
||||
scope = {
|
||||
@@ -788,8 +1116,44 @@ def test_oidc_callback_csrf_cookie_persistent_on_https():
|
||||
"query_string": b"",
|
||||
}
|
||||
response = Response()
|
||||
_set_csrf_cookie(response, Request(scope))
|
||||
set_cookie = response.headers.get("set-cookie", "").lower()
|
||||
assert "csrf_token=" in set_cookie
|
||||
assert "secure" in set_cookie
|
||||
assert "max-age" in set_cookie
|
||||
request = Request(scope)
|
||||
_set_session_cookie(response, "token", request, remember_me=True)
|
||||
_set_csrf_cookie(response, request)
|
||||
set_cookies = [h.lower() for h in _get_response_set_cookie_headers(response)]
|
||||
access_cookies = [h for h in set_cookies if "access_token=" in h]
|
||||
csrf_cookies = [h for h in set_cookies if "csrf_token=" in h]
|
||||
assert access_cookies and csrf_cookies
|
||||
assert "secure" in access_cookies[0]
|
||||
assert "secure" in csrf_cookies[0]
|
||||
assert "max-age=604800" in access_cookies[0]
|
||||
assert "max-age=604800" in csrf_cookies[0]
|
||||
|
||||
|
||||
def test_oidc_callback_access_and_csrf_cookie_stay_session_only():
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.gateway.routers.auth import _set_csrf_cookie, _set_session_cookie
|
||||
|
||||
_setup_config()
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/callback/example",
|
||||
"headers": [(b"x-forwarded-proto", b"https")],
|
||||
"scheme": "http",
|
||||
"server": ("internal", 8000),
|
||||
"query_string": b"",
|
||||
}
|
||||
response = Response()
|
||||
request = Request(scope)
|
||||
_set_session_cookie(response, "token", request, remember_me=False)
|
||||
_set_csrf_cookie(response, request)
|
||||
set_cookies = [h.lower() for h in _get_response_set_cookie_headers(response)]
|
||||
access_cookies = [h for h in set_cookies if "access_token=" in h]
|
||||
csrf_cookies = [h for h in set_cookies if "csrf_token=" in h]
|
||||
assert access_cookies and csrf_cookies
|
||||
assert "secure" in access_cookies[0]
|
||||
assert "secure" in csrf_cookies[0]
|
||||
assert "max-age" not in access_cookies[0]
|
||||
assert "max-age" not in csrf_cookies[0]
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# 管理页面保持登录方案
|
||||
|
||||
## 方案结论
|
||||
|
||||
实现“保持登录”,不实现“记住密码”。密码只随登录表单提交一次,不写入浏览器存储、日志、运行上下文或沙盒环境。
|
||||
|
||||
本次改造沿用现有认证链路:
|
||||
|
||||
`LoginPage -> /api/v1/auth/login/local -> LocalAuthProvider -> JWT -> HttpOnly access_token cookie -> AuthProvider / fetchWithAuth / AuthMiddleware`
|
||||
|
||||
新增点只挂在原链路上:
|
||||
|
||||
- 前端登录页新增“保持登录”勾选项。
|
||||
- 登录表单新增 `remember_me` 字段。
|
||||
- Gateway 新增统一会话 cookie 策略。
|
||||
- CSRF cookie 使用与 access cookie 相同的最终生命周期。
|
||||
|
||||
## 背景与目标
|
||||
|
||||
Issue #4194 的用户诉求是“不要每次登录都填写账号密码”。现有 Gateway 已使用 `HttpOnly access_token` cookie 和双提交 `csrf_token` cookie。问题不应通过保存密码解决,而应通过更明确的会话持久化策略解决。
|
||||
|
||||
目标:
|
||||
|
||||
- 提升再次打开页面时的登录体验。
|
||||
- 保持密码不落盘。
|
||||
- 覆盖本地、HTTPS 反代、远程沙盒等部署形态。
|
||||
- 避免 `access_token` 和 `csrf_token` 生命周期不一致导致“已登录但 POST 403”。
|
||||
|
||||
## 链路设计
|
||||
|
||||
### 前端
|
||||
|
||||
登录页新增两个状态:
|
||||
|
||||
- `rememberMe`:是否保持登录,默认 `true`。
|
||||
- remembered email:仅在用户选择保持登录时保存邮箱到 `localStorage`,便于下次预填。
|
||||
|
||||
登录成功后:
|
||||
|
||||
- `rememberMe=true`:保存邮箱和偏好。
|
||||
- `rememberMe=false`:清除已保存邮箱和偏好。
|
||||
|
||||
前端不保存密码,不保存 token。
|
||||
|
||||
### Gateway
|
||||
|
||||
登录接口接收 `remember_me`:
|
||||
|
||||
```text
|
||||
username=<email>&password=<password>&remember_me=true|false
|
||||
```
|
||||
|
||||
`SessionCookiePolicy` 统一决策:
|
||||
|
||||
| 场景 | 策略 |
|
||||
| ---- | ---- |
|
||||
| `remember_me=false` | session cookie |
|
||||
| HTTPS 或可信反代后的 HTTPS | `Secure + Max-Age` |
|
||||
| `localhost` / loopback HTTP | 允许 `Max-Age`,便于本地开发 |
|
||||
| 公网 HTTP / 临时沙盒 HTTP | 降级为 session cookie |
|
||||
| 显式运维开关允许公网 HTTP 持久化 | 非默认,需环境变量显式开启 |
|
||||
|
||||
Gateway 在设置 `access_token` 后,把最终 `max_age` 写入 `request.state`。CSRF middleware 设置 `csrf_token` 时读取同一个值,确保两枚 cookie 同寿命。
|
||||
|
||||
Gateway 还会写入一个 `HttpOnly` 的会话偏好 cookie,用于改密、管理员初始化、OIDC callback 等重新签发 session 的路径。这样用户在登录时取消“保持登录”后,后续重新签发 token 不会被静默升级成持久 cookie。
|
||||
|
||||
### 沙盒边界
|
||||
|
||||
认证 cookie 只属于浏览器和 Gateway:
|
||||
|
||||
- 不传入 agent runtime context。
|
||||
- 不注入 sandbox env。
|
||||
- 不写入 checkpoint。
|
||||
- 不影响 IM channel 内部鉴权链路。
|
||||
|
||||
远程沙盒如果每次使用不同公网域名,浏览器 cookie 无法跨域复用,这是浏览器隔离规则,不在本 issue 内解决。
|
||||
|
||||
### 非目标
|
||||
|
||||
本次不处理跨站 iframe 持久登录。该场景需要 `SameSite=None; Secure`、明确嵌入 allowlist 和额外 CSRF/点击劫持评估,应作为单独方案。
|
||||
|
||||
## 验证计划
|
||||
|
||||
- 后端 cookie policy 单测:
|
||||
- HTTPS 持久化。
|
||||
- `localhost` HTTP 持久化。
|
||||
- 公网 HTTP 降级 session。
|
||||
- `remember_me=false` 降级 session。
|
||||
- 运维开关允许公网 HTTP 持久化。
|
||||
- 后端 API 契约测试:
|
||||
- 登录表单携带 `remember_me=false` 时 `access_token` 无 `Max-Age`。
|
||||
- `access_token` 和 `csrf_token` 的 `Max-Age` 一致。
|
||||
- 失败登录、改密、初始化、OIDC helper 路径保留正确 cookie 生命周期。
|
||||
- logout 清理 `access_token` 和 `csrf_token`。
|
||||
- 前端单测:
|
||||
- 只保存邮箱和偏好。
|
||||
- 关闭保持登录会清理邮箱。
|
||||
- localStorage 异常时不阻塞登录页。
|
||||
- 手工验证:
|
||||
- `localhost:2026` 登录后重开浏览器仍可访问工作区。
|
||||
- HTTPS 反代下 `Set-Cookie` 带 `Secure` 和 `Max-Age`。
|
||||
- 公网 HTTP 地址不产生持久 cookie。
|
||||
@@ -70,6 +70,8 @@ The frontend is a stateful chat application. Users create **threads** (conversat
|
||||
5. TanStack Query manages server state; localStorage stores user settings
|
||||
6. Components subscribe to thread state and render updates
|
||||
|
||||
Auth UI note: the login page's "keep me signed in" option submits only `remember_me` to the Gateway and may persist only the email address through `core/auth/remember-login.ts`. Passwords and tokens must never be stored in frontend storage; the `HttpOnly access_token` and readable `csrf_token` cookies remain Gateway-owned.
|
||||
|
||||
`/goal` and `/compact` are built-in composer commands, not skill activations. `src/components/workspace/input-box.tsx` intercepts `/goal`, `/goal clear`, and `/goal <condition>` before normal chat submission, calling Gateway `GET/PUT/DELETE /api/threads/{thread_id}/goal`. Setting `/goal <condition>` also submits the condition text as the next user task so the agent starts running immediately; status and clear do not start a run. Goal and compact requests are tied to the current `threadId` with an `AbortController`, so switching threads or unmounting the composer aborts in-flight requests and stale responses cannot update the new thread's composer state. The chat pages render `GoalStatus` above the composer from `AgentThreadState.goal`, with local optimistic state until the next stream `values` update arrives. `/compact` calls `POST /api/threads/{thread_id}/compact` to summarize older active context while leaving the full visible chat history intact; it is skipped on new/empty threads and blocked server-side while a run is in flight.
|
||||
|
||||
Human input requests are a structured message protocol layered on normal chat history. The backend writes request payloads to `ToolMessage.artifact.human_input`, `src/core/messages/human-input.ts` owns the runtime validators/types, and `src/components/workspace/messages/human-input-card.tsx` renders the reusable card. `MessageList` owns answered/latest/pending state for visible cards, but derives answered responses from raw `thread.messages` because replies are hidden; pending cards clear when the hidden reply appears, when dispatch is dropped, or when a new `thread.error` reports an async stream failure. Page-level submit callbacks must send a normal human message and put `hide_from_ui: true` plus the response payload in the fourth `sendMessage(..., options)` argument as `options.additionalKwargs`; the third argument remains run context such as `{ agent_name }`. Composer entry points should disable normal bottom input while `hasOpenHumanInputRequest(...)` is true so users answer through the card and preserve response metadata.
|
||||
|
||||
@@ -5,10 +5,15 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { RememberSessionOption } from "@/components/auth/remember-session-option";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FlickeringGrid } from "@/components/ui/flickering-grid";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import {
|
||||
loadRememberLoginPreference,
|
||||
saveRememberLoginPreference,
|
||||
} from "@/core/auth/remember-login";
|
||||
import {
|
||||
canCreateRegularAccount,
|
||||
fetchSetupStatus,
|
||||
@@ -59,6 +64,7 @@ export default function LoginPage() {
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [rememberMe, setRememberMe] = useState(true);
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
const [ssoProviders, setSsoProviders] = useState<
|
||||
{ id: string; display_name: string; type: string }[]
|
||||
@@ -99,6 +105,14 @@ export default function LoginPage() {
|
||||
}
|
||||
}, [isAuthenticated, redirectPath, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const preference = loadRememberLoginPreference();
|
||||
setRememberMe(preference.rememberMe);
|
||||
if (preference.email) {
|
||||
setEmail(preference.email);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch setup state and SSO providers
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -159,8 +173,12 @@ export default function LoginPage() {
|
||||
? "/api/v1/auth/login/local"
|
||||
: "/api/v1/auth/register";
|
||||
const body = isLogin
|
||||
? `username=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`
|
||||
: JSON.stringify({ email, password });
|
||||
? new URLSearchParams({
|
||||
password,
|
||||
remember_me: String(rememberMe),
|
||||
username: email,
|
||||
})
|
||||
: JSON.stringify({ email, password, remember_me: rememberMe });
|
||||
|
||||
const headers: HeadersInit = isLogin
|
||||
? { "Content-Type": "application/x-www-form-urlencoded" }
|
||||
@@ -185,6 +203,8 @@ export default function LoginPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
saveRememberLoginPreference({ email, rememberMe });
|
||||
|
||||
// Both login and register set a cookie — redirect to workspace
|
||||
router.push(redirectPath);
|
||||
} catch {
|
||||
@@ -258,6 +278,11 @@ export default function LoginPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RememberSessionOption
|
||||
checked={rememberMe}
|
||||
onCheckedChange={setRememberMe}
|
||||
/>
|
||||
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
@@ -296,7 +321,7 @@ export default function LoginPage() {
|
||||
className="w-full"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
window.location.href = `/api/v1/auth/oauth/${provider.id}?next=${encodeURIComponent(redirectPath)}`;
|
||||
window.location.href = `/api/v1/auth/oauth/${provider.id}?next=${encodeURIComponent(redirectPath)}&remember_me=${String(rememberMe)}`;
|
||||
}}
|
||||
>
|
||||
{t.login.continueWith(provider.display_name)}
|
||||
|
||||
@@ -4,11 +4,13 @@ import { useRouter } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { RememberSessionOption } from "@/components/auth/remember-session-option";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FlickeringGrid } from "@/components/ui/flickering-grid";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { getCsrfHeaders } from "@/core/api/fetcher";
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import { loadRememberLoginPreference } from "@/core/auth/remember-login";
|
||||
import {
|
||||
fetchSetupStatus,
|
||||
isSystemAlreadyInitializedError,
|
||||
@@ -29,6 +31,9 @@ export default function SetupPage() {
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(
|
||||
() => loadRememberLoginPreference().rememberMe,
|
||||
);
|
||||
|
||||
// --- Change-password mode only ---
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
@@ -82,6 +87,7 @@ export default function SetupPage() {
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password: newPassword,
|
||||
remember_me: rememberMe,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -131,6 +137,7 @@ export default function SetupPage() {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
new_email: email || undefined,
|
||||
remember_me: rememberMe,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -221,6 +228,10 @@ export default function SetupPage() {
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<RememberSessionOption
|
||||
checked={rememberMe}
|
||||
onCheckedChange={setRememberMe}
|
||||
/>
|
||||
{error && <p className="ms-1 text-sm text-red-500">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Creating account…" : "Create Admin Account"}
|
||||
@@ -283,6 +294,10 @@ export default function SetupPage() {
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
<RememberSessionOption
|
||||
checked={rememberMe}
|
||||
onCheckedChange={setRememberMe}
|
||||
/>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Setting up…" : "Complete Setup"}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
interface RememberSessionOptionProps {
|
||||
checked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export function RememberSessionOption({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
}: RememberSessionOptionProps) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<label className="text-muted-foreground flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => onCheckedChange(event.currentTarget.checked)}
|
||||
className="border-input mt-1 h-4 w-4 rounded"
|
||||
/>
|
||||
<span>
|
||||
<span className="text-foreground block font-medium">
|
||||
{t.login.rememberMe}
|
||||
</span>
|
||||
<span>{t.login.rememberMeDescription}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
const REMEMBER_LOGIN_KEY = "deerflow.auth.remember_login";
|
||||
const REMEMBERED_EMAIL_KEY = "deerflow.auth.remembered_email";
|
||||
|
||||
export interface RememberLoginPreference {
|
||||
email: string;
|
||||
rememberMe: boolean;
|
||||
}
|
||||
|
||||
function getStorage(): Storage | null {
|
||||
if (typeof globalThis.localStorage === "undefined") return null;
|
||||
return globalThis.localStorage;
|
||||
}
|
||||
|
||||
export function loadRememberLoginPreference(): RememberLoginPreference {
|
||||
try {
|
||||
const storage = getStorage();
|
||||
if (!storage) {
|
||||
return { email: "", rememberMe: true };
|
||||
}
|
||||
const rememberValue = storage.getItem(REMEMBER_LOGIN_KEY);
|
||||
const rememberMe = rememberValue !== "0";
|
||||
return {
|
||||
email: rememberMe ? (storage.getItem(REMEMBERED_EMAIL_KEY) ?? "") : "",
|
||||
rememberMe,
|
||||
};
|
||||
} catch {
|
||||
return { email: "", rememberMe: true };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveRememberLoginPreference({
|
||||
email,
|
||||
rememberMe,
|
||||
}: RememberLoginPreference): void {
|
||||
try {
|
||||
const storage = getStorage();
|
||||
if (!storage) return;
|
||||
storage.setItem(REMEMBER_LOGIN_KEY, rememberMe ? "1" : "0");
|
||||
if (rememberMe) {
|
||||
storage.setItem(REMEMBERED_EMAIL_KEY, email);
|
||||
} else {
|
||||
storage.removeItem(REMEMBERED_EMAIL_KEY);
|
||||
}
|
||||
} catch {
|
||||
// Login must not depend on localStorage availability.
|
||||
}
|
||||
}
|
||||
@@ -828,6 +828,9 @@ export const enUS: Translations = {
|
||||
emailPlaceholder: "you@example.com",
|
||||
password: "Password",
|
||||
passwordPlaceholder: "•••••••",
|
||||
rememberMe: "Keep me signed in",
|
||||
rememberMeDescription:
|
||||
"Keep this browser session when possible. DeerFlow stores only your email, never your password.",
|
||||
pleaseWait: "Please wait...",
|
||||
signIn: "Sign In",
|
||||
createAccount: "Create Account",
|
||||
|
||||
@@ -702,6 +702,8 @@ export interface Translations {
|
||||
emailPlaceholder: string;
|
||||
password: string;
|
||||
passwordPlaceholder: string;
|
||||
rememberMe: string;
|
||||
rememberMeDescription: string;
|
||||
pleaseWait: string;
|
||||
signIn: string;
|
||||
createAccount: string;
|
||||
|
||||
@@ -797,6 +797,9 @@ export const zhCN: Translations = {
|
||||
emailPlaceholder: "you@example.com",
|
||||
password: "密码",
|
||||
passwordPlaceholder: "•••••••",
|
||||
rememberMe: "保持登录",
|
||||
rememberMeDescription:
|
||||
"下次打开 DeerFlow 时尽量保持当前会话,仅保存邮箱,不保存密码。",
|
||||
pleaseWait: "请稍候...",
|
||||
signIn: "登录",
|
||||
createAccount: "创建账号",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, test } from "@rstest/core";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { RememberSessionOption } from "@/components/auth/remember-session-option";
|
||||
import { I18nContext } from "@/core/i18n/context";
|
||||
|
||||
describe("RememberSessionOption", () => {
|
||||
test("uses the active locale for setup and login copy", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(
|
||||
I18nContext.Provider,
|
||||
{
|
||||
value: {
|
||||
locale: "zh-CN",
|
||||
setLocale: () => undefined,
|
||||
},
|
||||
},
|
||||
createElement(RememberSessionOption, {
|
||||
checked: true,
|
||||
onCheckedChange: () => undefined,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(markup).toContain("保持登录");
|
||||
expect(markup).toContain(
|
||||
"下次打开 DeerFlow 时尽量保持当前会话,仅保存邮箱,不保存密码。",
|
||||
);
|
||||
expect(markup).not.toContain("Keep me signed in");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { afterEach, describe, expect, rs, test } from "@rstest/core";
|
||||
|
||||
import {
|
||||
loadRememberLoginPreference,
|
||||
saveRememberLoginPreference,
|
||||
} from "@/core/auth/remember-login";
|
||||
|
||||
function makeStorage(initial: Record<string, string> = {}) {
|
||||
const values = new Map(Object.entries(initial));
|
||||
return {
|
||||
getItem: rs.fn((key: string) => values.get(key) ?? null),
|
||||
setItem: rs.fn((key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
}),
|
||||
removeItem: rs.fn((key: string) => {
|
||||
values.delete(key);
|
||||
}),
|
||||
values,
|
||||
};
|
||||
}
|
||||
|
||||
describe("remember login helpers", () => {
|
||||
afterEach(() => {
|
||||
rs.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("loads default keep-signed-in preference without a saved email", () => {
|
||||
const storage = makeStorage();
|
||||
rs.stubGlobal("localStorage", storage);
|
||||
|
||||
expect(loadRememberLoginPreference()).toEqual({
|
||||
email: "",
|
||||
rememberMe: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("saves only email and preference when enabled", () => {
|
||||
const storage = makeStorage();
|
||||
rs.stubGlobal("localStorage", storage);
|
||||
|
||||
saveRememberLoginPreference({
|
||||
email: "admin@example.com",
|
||||
rememberMe: true,
|
||||
});
|
||||
|
||||
expect(storage.values.get("deerflow.auth.remember_login")).toBe("1");
|
||||
expect(storage.values.get("deerflow.auth.remembered_email")).toBe(
|
||||
"admin@example.com",
|
||||
);
|
||||
expect([...storage.values.values()]).not.toContain("password");
|
||||
});
|
||||
|
||||
test("clears saved email when disabled", () => {
|
||||
const storage = makeStorage({
|
||||
"deerflow.auth.remember_login": "1",
|
||||
"deerflow.auth.remembered_email": "admin@example.com",
|
||||
});
|
||||
rs.stubGlobal("localStorage", storage);
|
||||
|
||||
saveRememberLoginPreference({
|
||||
email: "admin@example.com",
|
||||
rememberMe: false,
|
||||
});
|
||||
|
||||
expect(storage.values.get("deerflow.auth.remember_login")).toBe("0");
|
||||
expect(storage.values.has("deerflow.auth.remembered_email")).toBe(false);
|
||||
});
|
||||
|
||||
test("falls back safely when localStorage is unavailable", () => {
|
||||
rs.stubGlobal("localStorage", {
|
||||
getItem: () => {
|
||||
throw new Error("blocked");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error("blocked");
|
||||
},
|
||||
removeItem: () => {
|
||||
throw new Error("blocked");
|
||||
},
|
||||
});
|
||||
|
||||
expect(loadRememberLoginPreference()).toEqual({
|
||||
email: "",
|
||||
rememberMe: true,
|
||||
});
|
||||
expect(() =>
|
||||
saveRememberLoginPreference({
|
||||
email: "admin@example.com",
|
||||
rememberMe: true,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user