From 4e0d1e5a668fdba19aa34857c2ec5986adb9d91b Mon Sep 17 00:00:00 2001 From: zyycn Date: Thu, 11 Jun 2026 22:52:11 +0800 Subject: [PATCH] feat: add admin login sessions Co-authored-by: Codex --- docs/api.md | 33 ++++ docs/implementation-status.md | 2 +- .../plans/2026-06-11-codex-proxy-rs.md | 6 + src/app.rs | 3 +- src/http/admin.rs | 164 ++++++++++++++++- tests/admin_login_route_test.rs | 169 ++++++++++++++++++ 6 files changed, 373 insertions(+), 4 deletions(-) create mode 100644 tests/admin_login_route_test.rs diff --git a/docs/api.md b/docs/api.md index ae9da4cb5..3da4112c0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -34,6 +34,39 @@ Admin JSON uses lower camelCase field names. Every admin response includes an `X Use real HTTP status codes and body-level frontend codes together. Do not return HTTP `200` for failed requests. The body `code` exists for frontend branching; the HTTP status remains the transport truth. +### `POST /admin/login` + +Authenticates only with the configured admin password stored in `admin_users`. Client API keys (`Bearer cpr_...`) are ignored by the admin login flow and cannot create admin sessions. + +Request: + +```json +{ + "password": "admin-password" +} +``` + +Success response: + +```json +{ + "code": 200, + "message": "OK", + "data": { + "expiresAt": "2026-06-11T12:00:00Z" + }, + "requestId": "req_01" +} +``` + +Success also returns: + +```http +Set-Cookie: cpr_admin_session=...; Path=/; HttpOnly; SameSite=Lax; Max-Age=3600 +``` + +Invalid password response uses HTTP `401` and body code `40102`. + Success body: ```json diff --git a/docs/implementation-status.md b/docs/implementation-status.md index e8e90632b..cd535fd7d 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -8,7 +8,7 @@ Update this file before each feature commit. | API contract docs | Completed | `fc47967` | `cargo fmt --check`; `cargo test`; `cargo clippy --all-targets --all-features --locked -- -D warnings` | `/v1/*` OpenAI-compatible body, `/admin/*` frontend envelope, body codes, camelCase, and request ID policy are documented. | | Configuration | Completed | `9ed8579` + `e0680bf` | `cargo test default_config_keeps_only_codex_backend`; `cargo test config_loader_merges_default_local_and_cprs_env_overrides`; full `fmt/test/clippy` before commit | Defines Codex-only config, default/local/env loading, default YAML, and `Arc` state shell. | | SQLite storage | Completed | `ffec8b5` + `b0c10f2` | `cargo test sqlite_schema_creates_accounts_and_event_tables`; `cargo test connect_sqlite_creates_parent_directory_for_file_database`; full `fmt/test/clippy` before commit | Creates SQLite WAL connector, parent directory bootstrap, sqlx-managed schema for accounts/API keys/sessions/cookies/fingerprints/events, and event/account indexes. | -| Admin auth and client API keys | Completed | `24d5799` | `cargo test client_api_key_has_proxy_prefix_and_verifies_against_hash`; `cargo test admin_password_hash_is_not_a_client_api_key`; full `fmt/test/clippy` before commit | Admin passwords use Argon2id, client API keys use `cpr_` prefix plus HMAC-SHA256 with server-side pepper. | +| Admin auth and client API keys | Completed | `24d5799` + admin login route commit | `cargo test client_api_key_has_proxy_prefix_and_verifies_against_hash`; `cargo test admin_password_hash_is_not_a_client_api_key`; `cargo test --test admin_login_route_test`; full `fmt/test/clippy` before commit | Admin passwords use Argon2id, client API keys use `cpr_` prefix plus HMAC-SHA256 with server-side pepper; `/admin/login` issues HttpOnly sessions and rejects client API keys as admin credentials. | | Secret encryption | Completed | `105aa58` | `cargo test secret_box_encrypts_and_decrypts_without_storing_plaintext`; full `fmt/test/clippy` before commit | AES-256-GCM `SecretBox` stores upstream secrets as `v1::` without plaintext. | | Logging and pagination | Completed | `eb0341e` + `fc16c48` + `b0c10f2` | `cargo test event_logs_are_cursor_paginated`; `cargo test admin_logs_are_cursor_paginated_and_include_request_id`; `cargo test rotating_writer_splits_files_when_size_limit_is_reached`; full `fmt/test/clippy` before commit | Adds cursor pagination, `Page` camelCase serialization, admin `/admin/logs` query API, event log repository, runtime tracing initialization, HTTP trace layer, and date/size-based file rotation. | | TLS headers and fingerprint | Completed | `1bdd08c` | `cargo test codex_headers_include_desktop_identity_and_turn_state`; `cargo tree \| rg 'reqwest\|rustls'`; full `fmt/test/clippy` before commit | Adds Codex Desktop fingerprint model, exact identity/request headers, and pinned reqwest/rustls client builder. | diff --git a/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md b/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md index e0c061e15..ebfb9c469 100644 --- a/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md +++ b/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md @@ -2950,6 +2950,12 @@ git commit -m "docs: document codex-proxy-rs scope" --- +## Audit Follow-ups + +- [x] Add `POST /admin/login` so admin login is a password-backed session flow, not a client API key flow. The route issues an HttpOnly `cpr_admin_session` cookie, returns the admin envelope with lower camelCase `expiresAt`, and rejects `Bearer cpr_...` as admin credentials. + +--- + ## Acceptance Criteria - `codex-proxy-rs` builds as a standalone Rust service. diff --git a/src/app.rs b/src/app.rs index a00523e36..8b6884d09 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,7 +7,7 @@ use tower_http::trace::TraceLayer; use crate::{ http::{ - admin::logs, + admin::{login, logs}, health::health, middleware::attach_request_id, v1::{models, responses}, @@ -21,6 +21,7 @@ pub fn build_router(state: AppState) -> Router { .route("/v1/responses", post(responses)) .route("/v1/chat/completions", post(responses)) .route("/v1/models", get(models)) + .route("/admin/login", post(login)) .route("/admin/logs", get(logs)) .with_state(state) .layer(TraceLayer::new_for_http()) diff --git a/src/http/admin.rs b/src/http/admin.rs index a721dfccb..9ce1b2c0c 100644 --- a/src/http/admin.rs +++ b/src/http/admin.rs @@ -1,13 +1,19 @@ use axum::{ extract::{Query, State}, - http::{HeaderMap, StatusCode}, + http::{ + header::{HeaderValue, SET_COOKIE}, + HeaderMap, StatusCode, + }, response::{IntoResponse, Response}, Extension, Json, }; -use chrono::Utc; +use chrono::{Duration, Utc}; use serde::{Deserialize, Serialize}; +use sqlx::Row; +use uuid::Uuid; use crate::{ + auth::admin_session::verify_admin_password, http::middleware::RequestId, pagination::{clamp_limit, Page}, state::AppState, @@ -111,6 +117,116 @@ pub struct LogsQuery { pub limit: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LoginRequest { + pub password: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LoginData { + pub expires_at: String, +} + +pub async fn login( + State(state): State, + Extension(request_id): Extension, + Json(payload): Json, +) -> Response { + let request_id = request_id.as_str().to_string(); + let Some(pool) = state.db() else { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "Database is not initialized", (), request_id), + ) + .into_response(); + }; + + let admin = match load_first_admin(pool).await { + Ok(Some(admin)) => admin, + Ok(None) => { + return AdminResponse::new( + StatusCode::UNAUTHORIZED, + AdminEnvelope::new(40102, "Admin password invalid", (), request_id), + ) + .into_response(); + } + Err(_) => { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "Failed to load admin user", (), request_id), + ) + .into_response(); + } + }; + + // 中文注释:admin 登录只校验管理员密码;客户端 cpr_ API Key 不能参与后台登录。 + match verify_admin_password(&payload.password, &admin.password_hash) { + Ok(true) => {} + Ok(false) => { + return AdminResponse::new( + StatusCode::UNAUTHORIZED, + AdminEnvelope::new(40102, "Admin password invalid", (), request_id), + ) + .into_response(); + } + Err(_) => { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "Failed to verify admin password", (), request_id), + ) + .into_response(); + } + } + + let ttl_minutes = state.config().admin.session_ttl_minutes; + let Ok(ttl_minutes_i64) = i64::try_from(ttl_minutes) else { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "Admin session ttl is invalid", (), request_id), + ) + .into_response(); + }; + let expires_at = Utc::now() + Duration::minutes(ttl_minutes_i64); + let session_id = format!("sess_{}", Uuid::new_v4().simple()); + if create_admin_session(pool, &session_id, &admin.id, expires_at) + .await + .is_err() + { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "Failed to create admin session", (), request_id), + ) + .into_response(); + } + + let Some(cookie) = admin_session_set_cookie(&session_id, ttl_minutes) else { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new( + 50001, + "Failed to create admin session cookie", + (), + request_id, + ), + ) + .into_response(); + }; + let mut response = AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok( + LoginData { + expires_at: expires_at.to_rfc3339(), + }, + request_id, + ), + ) + .into_response(); + response.headers_mut().insert(SET_COOKIE, cookie); + response +} + pub async fn logs( State(state): State, Extension(request_id): Extension, @@ -170,6 +286,50 @@ pub async fn logs( } } +#[derive(Debug)] +struct AdminUserRow { + id: String, + password_hash: String, +} + +async fn load_first_admin(pool: &sqlx::SqlitePool) -> Result, sqlx::Error> { + let row = + sqlx::query("select id, password_hash from admin_users order by created_at asc limit 1") + .fetch_optional(pool) + .await?; + Ok(row.map(|row| AdminUserRow { + id: row.get("id"), + password_hash: row.get("password_hash"), + })) +} + +async fn create_admin_session( + pool: &sqlx::SqlitePool, + session_id: &str, + user_id: &str, + expires_at: chrono::DateTime, +) -> Result<(), sqlx::Error> { + let now = Utc::now().to_rfc3339(); + sqlx::query( + "insert into admin_sessions (id, user_id, expires_at, created_at) values (?, ?, ?, ?)", + ) + .bind(session_id) + .bind(user_id) + .bind(expires_at.to_rfc3339()) + .bind(now) + .execute(pool) + .await?; + Ok(()) +} + +fn admin_session_set_cookie(session_id: &str, ttl_minutes: u64) -> Option { + let max_age = ttl_minutes.checked_mul(60)?; + let cookie = format!( + "cpr_admin_session={session_id}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}" + ); + HeaderValue::from_str(&cookie).ok() +} + async fn validate_admin_session( pool: &sqlx::SqlitePool, headers: &HeaderMap, diff --git a/tests/admin_login_route_test.rs b/tests/admin_login_route_test.rs new file mode 100644 index 000000000..0c69c4a78 --- /dev/null +++ b/tests/admin_login_route_test.rs @@ -0,0 +1,169 @@ +use axum::{ + body::{to_bytes, Body}, + http::{Request, StatusCode}, +}; +use chrono::Utc; +use serde_json::Value; +use tower::ServiceExt; + +use codex_proxy_rs::{ + app::build_router, + auth::admin_session::hash_admin_password, + config::{ + AdminConfig, ApiConfig, AppConfig, AuthConfig, DatabaseConfig, LoggingConfig, + SecurityConfig, ServerConfig, TlsConfig, + }, + state::AppState, + storage::db::connect_sqlite, +}; + +fn test_config(database_url: String) -> AppConfig { + AppConfig { + server: ServerConfig { + host: "127.0.0.1".to_string(), + port: 0, + }, + api: ApiConfig { + base_url: "https://chatgpt.com/backend-api".to_string(), + }, + auth: AuthConfig { + refresh_margin_seconds: 300, + refresh_enabled: true, + refresh_concurrency: 2, + }, + database: DatabaseConfig { url: database_url }, + security: SecurityConfig { + master_key_file: "data/master.key".to_string(), + api_key_pepper_file: "data/api-key-pepper.key".to_string(), + }, + tls: TlsConfig { + force_http11: false, + }, + admin: AdminConfig { + session_ttl_minutes: 60, + }, + logging: LoggingConfig { + directory: "logs".to_string(), + max_file_bytes: 10_485_760, + retention_days: 14, + }, + } +} + +#[tokio::test] +async fn admin_login_issues_http_only_session_cookie() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-login.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_user(&pool, "correct-password").await; + let app = build_router(AppState::with_pool(test_config(url), pool.clone())); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/login") + .header("content-type", "application/json") + .header("x-request-id", "req_login") + .body(Body::from(r#"{"password":"correct-password"}"#)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let cookie = response + .headers() + .get("set-cookie") + .unwrap() + .to_str() + .unwrap() + .to_string(); + assert!(cookie.starts_with("cpr_admin_session=")); + assert!(cookie.contains("HttpOnly")); + assert!(cookie.contains("SameSite=Lax")); + let session_cookie = cookie.split(';').next().unwrap().to_string(); + let body = response_json(response).await; + assert_eq!(body["code"], 200); + assert_eq!(body["requestId"], "req_login"); + assert!(body["data"]["expiresAt"].is_string()); + + let logs_response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/admin/logs") + .header("cookie", session_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(logs_response.status(), StatusCode::OK); +} + +#[tokio::test] +async fn admin_login_rejects_client_api_key_as_password_or_authorization() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-login.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_user(&pool, "correct-password").await; + let app = build_router(AppState::with_pool(test_config(url), pool)); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/login") + .header("content-type", "application/json") + .header("authorization", "Bearer cpr_not_an_admin_session") + .header("x-request-id", "req_login_bad") + .body(Body::from(r#"{"password":"cpr_not_an_admin_password"}"#)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!(response.headers().get("set-cookie").is_none()); + let body = response_json(response).await; + assert_eq!(body["code"], 40102); + assert_eq!(body["requestId"], "req_login_bad"); + + let logs_response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/admin/logs") + .header("authorization", "Bearer cpr_not_an_admin_session") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(logs_response.status(), StatusCode::UNAUTHORIZED); +} + +async fn seed_admin_user(pool: &sqlx::SqlitePool, password: &str) { + let now = Utc::now().to_rfc3339(); + let hash = hash_admin_password(password).unwrap(); + sqlx::query( + "insert into admin_users (id, password_hash, created_at, updated_at) values (?, ?, ?, ?)", + ) + .bind("admin_1") + .bind(hash) + .bind(&now) + .bind(&now) + .execute(pool) + .await + .unwrap(); +} + +async fn response_json(response: axum::response::Response) -> Value { + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + serde_json::from_slice(&bytes).unwrap() +}