feat: add admin login sessions

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-11 22:52:11 +08:00
co-authored by Codex
parent a84f456c45
commit 4e0d1e5a66
6 changed files with 373 additions and 4 deletions
+33
View File
@@ -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
+1 -1
View File
@@ -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<AppServices>` 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:<nonce>:<ciphertext>` 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<T>` 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. |
@@ -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.
+2 -1
View File
@@ -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())
+162 -2
View File
@@ -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<u32>,
}
#[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<AppState>,
Extension(request_id): Extension<RequestId>,
Json(payload): Json<LoginRequest>,
) -> 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<AppState>,
Extension(request_id): Extension<RequestId>,
@@ -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<Option<AdminUserRow>, 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<Utc>,
) -> 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<HeaderValue> {
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,
+169
View File
@@ -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()
}