mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
feat: add admin login sessions
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
+33
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user