From a028dfd5fb70bd6e26c7dbf9e89543c7c006f9a2 Mon Sep 17 00:00:00 2001 From: Ryker_Feng <90562015+18062706139fcz@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:17:15 +0800 Subject: [PATCH] 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 --- README.md | 4 + backend/AGENTS.md | 4 + backend/app/gateway/auth/oidc_state.py | 1 + backend/app/gateway/auth/session_cookie.py | 110 +++++ .../app/gateway/auth/session_cookie_state.py | 6 + backend/app/gateway/csrf_middleware.py | 28 +- backend/app/gateway/routers/auth.py | 48 ++- backend/tests/test_auth_type_system.py | 380 +++++++++++++++++- docs/plans/2026-07-17-remember-login.md | 102 +++++ frontend/AGENTS.md | 2 + frontend/src/app/(auth)/login/page.tsx | 31 +- frontend/src/app/(auth)/setup/page.tsx | 15 + .../auth/remember-session-option.tsx | 32 ++ frontend/src/core/auth/remember-login.ts | 47 +++ frontend/src/core/i18n/locales/en-US.ts | 3 + frontend/src/core/i18n/locales/types.ts | 2 + frontend/src/core/i18n/locales/zh-CN.ts | 3 + .../auth/remember-session-option.test.ts | 32 ++ .../unit/core/auth/remember-login.test.ts | 93 +++++ 19 files changed, 905 insertions(+), 38 deletions(-) create mode 100644 backend/app/gateway/auth/session_cookie.py create mode 100644 backend/app/gateway/auth/session_cookie_state.py create mode 100644 docs/plans/2026-07-17-remember-login.md create mode 100644 frontend/src/components/auth/remember-session-option.tsx create mode 100644 frontend/src/core/auth/remember-login.ts create mode 100644 frontend/tests/unit/components/auth/remember-session-option.test.ts create mode 100644 frontend/tests/unit/core/auth/remember-login.test.ts diff --git a/README.md b/README.md index adfd91f0f..4759c0240 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index bd58d8f50..31cd0b12e 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -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 | diff --git a/backend/app/gateway/auth/oidc_state.py b/backend/app/gateway/auth/oidc_state.py index 96161ca30..7205a7fa5 100644 --- a/backend/app/gateway/auth/oidc_state.py +++ b/backend/app/gateway/auth/oidc_state.py @@ -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") diff --git a/backend/app/gateway/auth/session_cookie.py b/backend/app/gateway/auth/session_cookie.py new file mode 100644 index 000000000..85e487812 --- /dev/null +++ b/backend/app/gateway/auth/session_cookie.py @@ -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 diff --git a/backend/app/gateway/auth/session_cookie_state.py b/backend/app/gateway/auth/session_cookie_state.py new file mode 100644 index 000000000..a669358dc --- /dev/null +++ b/backend/app/gateway/auth/session_cookie_state.py @@ -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" diff --git a/backend/app/gateway/csrf_middleware.py b/backend/app/gateway/csrf_middleware.py index 6a1cc990c..cb0181d68 100644 --- a/backend/app/gateway/csrf_middleware.py +++ b/backend/app/gateway/csrf_middleware.py @@ -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 diff --git a/backend/app/gateway/routers/auth.py b/backend/app/gateway/routers/auth.py index e60a85fed..bf49ed50d 100644 --- a/backend/app/gateway/routers/auth.py +++ b/backend/app/gateway/routers/auth.py @@ -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) diff --git a/backend/tests/test_auth_type_system.py b/backend/tests/test_auth_type_system.py index 9145092b1..d82804350 100644 --- a/backend/tests/test_auth_type_system.py +++ b/backend/tests/test_auth_type_system.py @@ -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] diff --git a/docs/plans/2026-07-17-remember-login.md b/docs/plans/2026-07-17-remember-login.md new file mode 100644 index 000000000..b6446d409 --- /dev/null +++ b/docs/plans/2026-07-17-remember-login.md @@ -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=&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。 diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 02e32d1b8..3abe3fa54 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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 ` before normal chat submission, calling Gateway `GET/PUT/DELETE /api/threads/{thread_id}/goal`. Setting `/goal ` 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. diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx index 3d6c63e55..0e28b58c6 100644 --- a/frontend/src/app/(auth)/login/page.tsx +++ b/frontend/src/app/(auth)/login/page.tsx @@ -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() { /> + + {error &&

{error}

}