Add API tokens, CLI server API and shared core crate
- Personal API tokens (full/read scope, expiry, revoke) and CLI password login - Cargo workspace: codecraft-core (types, providers, permission rules) shared by server and the upcoming terminal client; server re-exports the old paths - CLI server API: /cli/config, /cli/skill, /cli/tool (server MCP tools), /llm/stream model gateway with budgets and usage per user - Theme/language preferences mirrored to the server; personal memory endpoint - Statistics: provider and mode tables stacked; CLI usage labelled - cli/: work in progress (terminal client is being rewritten as a local TUI) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fca1c41087
commit
aa1f8cf016
40 files changed
+2454
-814
No files matched your search
+92
-2
@@ -133,12 +133,57 @@ fn record_failure(app: &App, keys: &[String]) {
|
||||
|
||||
pub fn current_user(app: &App, headers: &HeaderMap) -> Option<User> {
|
||||
let token = token_from_headers(headers)?;
|
||||
app.store.user_by_token(&sha256_hex(&token)).ok().flatten()
|
||||
let hash = sha256_hex(&token);
|
||||
if token.starts_with(API_TOKEN_PREFIX) {
|
||||
return app.store.user_by_api_token(&hash).ok().flatten();
|
||||
}
|
||||
app.store.user_by_token(&hash).ok().flatten()
|
||||
}
|
||||
|
||||
/// Prefix of personal API tokens (CLI, scripts).
|
||||
pub const API_TOKEN_PREFIX: &str = "cc_";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct NewApiToken {
|
||||
name: String,
|
||||
/// Days until it expires; None = never.
|
||||
days: Option<u32>,
|
||||
/// "full" (default) or "read" (GET requests only).
|
||||
scope: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_api_tokens(Extension(user): Extension<User>, State(app): State<Arc<App>>) -> ApiResult {
|
||||
Ok(Json(serde_json::json!(app.store.api_tokens(&user.id)?)))
|
||||
}
|
||||
|
||||
/// Create a token; the plain value is returned once and only stored hashed.
|
||||
pub async fn create_api_token(Extension(user): Extension<User>, State(app): State<Arc<App>>, headers: HeaderMap, Json(b): Json<NewApiToken>) -> ApiResult {
|
||||
let name = b.name.trim();
|
||||
if name.is_empty() || name.len() > 80 {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "the token needs a name (max. 80 characters)".into()));
|
||||
}
|
||||
let token = format!("{API_TOKEN_PREFIX}{}", random_token(32));
|
||||
let prefix: String = token.chars().take(10).collect();
|
||||
let expires = b.days.filter(|d| *d > 0).map(|d| crate::types::now_ms() + d as i64 * 86_400_000);
|
||||
let scope = match b.scope.as_deref() {
|
||||
Some("read") => "read",
|
||||
None | Some("full") => "full",
|
||||
Some(other) => return Err(ApiError(StatusCode::BAD_REQUEST, format!("invalid scope '{other}'"))),
|
||||
};
|
||||
let id = app.store.api_token_add(&user.id, name, &sha256_hex(&token), &prefix, expires, scope)?;
|
||||
app.audit(Some(&user), "api_token.create", format!("{name} ({scope})"), &client_ip(&headers));
|
||||
Ok(Json(serde_json::json!({"id": id, "name": name, "token": token, "prefix": prefix, "expires_at": expires, "scope": scope})))
|
||||
}
|
||||
|
||||
pub async fn delete_api_token(Extension(user): Extension<User>, State(app): State<Arc<App>>, headers: HeaderMap, Path(id): Path<String>) -> ApiResult {
|
||||
let ok = app.store.api_token_delete(&user.id, &id)?;
|
||||
app.audit(Some(&user), "api_token.delete", &id, &client_ip(&headers));
|
||||
Ok(Json(serde_json::json!({"ok": ok})))
|
||||
}
|
||||
|
||||
/// Paths reachable without a login.
|
||||
fn is_public(path: &str) -> bool {
|
||||
["/info", "/health", "/auth/login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"]
|
||||
["/info", "/health", "/auth/login", "/auth/cli-login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"]
|
||||
.iter()
|
||||
.any(|p| path.ends_with(p))
|
||||
}
|
||||
@@ -149,6 +194,17 @@ pub async fn require_user(State(app): State<Arc<App>>, mut req: Request, next: N
|
||||
}
|
||||
match current_user(&app, req.headers()) {
|
||||
Some(user) => {
|
||||
// Read-only API tokens: no state-changing requests.
|
||||
if let Some(token) = token_from_headers(req.headers()).filter(|t| t.starts_with(API_TOKEN_PREFIX)) {
|
||||
let read_only = app.store.api_token_scope(&sha256_hex(&token)).as_deref() == Some("read");
|
||||
if read_only && !matches!(*req.method(), axum::http::Method::GET | axum::http::Method::HEAD) {
|
||||
return ApiError(StatusCode::FORBIDDEN, "this API token is read-only".into()).into_response();
|
||||
}
|
||||
// Tokens cannot manage tokens.
|
||||
if req.uri().path().contains("/account/tokens") {
|
||||
return ApiError(StatusCode::FORBIDDEN, "API tokens are managed in the web UI".into()).into_response();
|
||||
}
|
||||
}
|
||||
req.extensions_mut().insert(user);
|
||||
next.run(req).await
|
||||
}
|
||||
@@ -232,6 +288,40 @@ pub async fn login(State(app): State<Arc<App>>, headers: HeaderMap, Json(body):
|
||||
Ok(([(header::SET_COOKIE, cookie)], Json(json!(user))).into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CliLoginBody {
|
||||
username: String,
|
||||
password: String,
|
||||
/// Shown in the token name ("CLI on <hostname>").
|
||||
hostname: Option<String>,
|
||||
}
|
||||
|
||||
/// Command line login: checks the password (same rate limit as the web login)
|
||||
/// and creates a personal API token for this machine.
|
||||
pub async fn cli_login(State(app): State<Arc<App>>, headers: HeaderMap, Json(body): Json<CliLoginBody>) -> ApiResult {
|
||||
let ip = client_ip(&headers);
|
||||
let keys = [format!("ip:{ip}"), format!("user:{}", body.username.trim().to_lowercase())];
|
||||
if locked(&app, &keys) {
|
||||
return Err(ApiError(StatusCode::TOO_MANY_REQUESTS, "too many failed sign-ins – try again in 15 minutes".into()));
|
||||
}
|
||||
let found = app.store.user_by_name(body.username.trim())?;
|
||||
let hash = found.as_ref().and_then(|(_, h)| h.clone());
|
||||
let ok = verify_password(&body.password, hash.as_deref().unwrap_or("$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHRzb21lc2FsdA$Zm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFy"));
|
||||
let Some((user, _)) = found.filter(|(u, _)| ok && hash.is_some() && !u.disabled) else {
|
||||
record_failure(&app, &keys);
|
||||
app.store.audit(None, body.username.trim(), "login.failed", "cli", &ip);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||||
return Err(ApiError(StatusCode::UNAUTHORIZED, "invalid username or password".into()));
|
||||
};
|
||||
let host: String = body.hostname.unwrap_or_default().chars().filter(|c| c.is_alphanumeric() || "-._".contains(*c)).take(40).collect();
|
||||
let name = if host.is_empty() { "CLI".to_string() } else { format!("CLI on {host}") };
|
||||
let token = format!("{API_TOKEN_PREFIX}{}", random_token(32));
|
||||
let prefix: String = token.chars().take(10).collect();
|
||||
app.store.api_token_add(&user.id, &name, &sha256_hex(&token), &prefix, None, "full")?;
|
||||
app.audit(Some(&user), "login", &format!("cli ({name})"), &ip);
|
||||
Ok(Json(json!({"token": token, "user": user, "token_name": name})))
|
||||
}
|
||||
|
||||
pub async fn logout(State(app): State<Arc<App>>, headers: HeaderMap) -> Response {
|
||||
if let Some(t) = token_from_headers(&headers) {
|
||||
let _ = app.store.delete_token(&sha256_hex(&t));
|
||||
|
||||
Reference in new issue
Block a user