From c208369e944630d40df300c8e14bab8336d582d0 Mon Sep 17 00:00:00 2001 From: louis Date: Sun, 5 Jul 2026 04:16:36 +0800 Subject: [PATCH] user_worker: implement SIWE auth handlers (nonce/verify/logout/me) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit 2 of the SIWE user system. Replaces the auth.rs stub: - nonce: uuid v4 stored in NONCES KV under "nonce:{nonce}" with NONCE_TTL_SECS, returned as Json(NonceResp). - verify: parses the EIP-4361 message (400 on parse error); checks domain, version "1", chain id (401); issued-at freshness within SIWE_MAX_AGE_SECS and optional expiration-time (401); single-use nonce via KV get+delete (401 on unknown/expired); recovered EIP-191 signer must match the claimed address (401); upserts the user row in D1 and rejects banned accounts (403); do_init's the UserAccount DO; signs the ue_session cookie and returns the checksummed address. - logout: clears the session cookie. - me: verified session -> DO /status as Json(StatusResp), else 401. All failures return Json(ApiError); internal errors are logged with console_error and mapped to 500, matching gateway_worker idioms. Note: siwe.rs is a stub implemented by a parallel unit, so runtime auth cannot be exercised locally yet — cargo check + existing unit tests are the local verification gate. Co-Authored-By: Claude Fable 5 --- user_worker/src/auth.rs | 214 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 197 insertions(+), 17 deletions(-) diff --git a/user_worker/src/auth.rs b/user_worker/src/auth.rs index 00c6ca7..ca2cb14 100644 --- a/user_worker/src/auth.rs +++ b/user_worker/src/auth.rs @@ -1,13 +1,9 @@ //! UNIT 2: SIWE auth handlers. //! -//! STUB: implement nonce / verify / logout / me. Contracts: //! - GET /user/api/nonce -> 200 Json(NonceResp); nonce stored in KV //! (`NONCE_BINDING`) under "nonce:{nonce}" with `NONCE_TTL_SECS`. //! - POST /user/api/verify Json(VerifyReq) -> 200 Json(VerifyResp) + session -//! cookie | 401 Json(ApiError) | 403 banned. Checks: parse, domain, -//! version "1", chain_id, issued-at freshness (SIWE_MAX_AGE_SECS), -//! expiration_time, single-use nonce, recovered signer == claimed address; -//! then D1 upsert into users, `do_client::do_init`, set `ue_session`. +//! cookie | 400/401 Json(ApiError) | 403 banned. //! - POST /user/api/logout -> clears the cookie. //! - GET /user/api/me -> 200 Json(StatusResp) | 401. @@ -17,31 +13,215 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; +use serde::Deserialize; use tower_cookies::Cookies; -use crate::AppState; -use crate::types::VerifyReq; +use crate::types::{ApiError, NonceResp, VerifyReq, VerifyResp}; +use crate::{AppState, config, do_client, session, siwe}; + +fn api_error(status: StatusCode, msg: impl Into) -> Response { + (status, Json(ApiError { error: msg.into() })).into_response() +} + +fn internal_error() -> Response { + api_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error") +} #[worker::send] -pub async fn nonce(State(_st): State) -> Response { - (StatusCode::NOT_IMPLEMENTED, "not implemented").into_response() +pub async fn nonce(State(st): State) -> Response { + let nonce = uuid::Uuid::new_v4().simple().to_string(); + + let kv = match st.env.kv(crate::NONCE_BINDING) { + Ok(kv) => kv, + Err(e) => { + worker::console_error!("kv binding failed: {e:?}"); + return internal_error(); + } + }; + let put = match kv.put(&format!("nonce:{nonce}"), "1") { + Ok(p) => p, + Err(e) => { + worker::console_error!("nonce put failed: {e:?}"); + return internal_error(); + } + }; + if let Err(e) = put.expiration_ttl(config::NONCE_TTL_SECS).execute().await { + worker::console_error!("nonce store failed: {e:?}"); + return internal_error(); + } + + Json(NonceResp { nonce }).into_response() } #[worker::send] pub async fn verify( - State(_st): State, - _cookies: Cookies, - Json(_req): Json, + State(st): State, + cookies: Cookies, + Json(req): Json, ) -> Response { - (StatusCode::NOT_IMPLEMENTED, "not implemented").into_response() + // 1) Parse the EIP-4361 message. + let msg = match siwe::parse_siwe(&req.message) { + Ok(m) => m, + Err(e) => return api_error(StatusCode::BAD_REQUEST, e.to_string()), + }; + + // 2) Domain / version / chain binding. + if msg.domain != *st.siwe_domain { + return api_error(StatusCode::UNAUTHORIZED, "domain mismatch"); + } + if msg.version != "1" { + return api_error(StatusCode::UNAUTHORIZED, "unsupported version"); + } + if msg.chain_id != st.chain_id { + return api_error(StatusCode::UNAUTHORIZED, "chain id mismatch"); + } + + // 3) Timestamps: issued-at freshness + optional expiration. + let now = session::now_secs(); + let Some(iat) = siwe::parse_rfc3339_epoch(&msg.issued_at) else { + return api_error(StatusCode::UNAUTHORIZED, "invalid issued-at timestamp"); + }; + if now.abs_diff(iat) > config::SIWE_MAX_AGE_SECS { + return api_error(StatusCode::UNAUTHORIZED, "message issued-at out of range"); + } + if let Some(exp_str) = &msg.expiration_time { + let Some(exp) = siwe::parse_rfc3339_epoch(exp_str) else { + return api_error(StatusCode::UNAUTHORIZED, "invalid expiration timestamp"); + }; + if exp <= now { + return api_error(StatusCode::UNAUTHORIZED, "message expired"); + } + } + + // 4) Single-use nonce: must exist in KV, then delete it. + let kv = match st.env.kv(crate::NONCE_BINDING) { + Ok(kv) => kv, + Err(e) => { + worker::console_error!("kv binding failed: {e:?}"); + return internal_error(); + } + }; + let nonce_key = format!("nonce:{}", msg.nonce); + match kv.get(&nonce_key).text().await { + Ok(Some(_)) => {} + Ok(None) => return api_error(StatusCode::UNAUTHORIZED, "unknown or expired nonce"), + Err(e) => { + worker::console_error!("nonce get failed: {e:?}"); + return internal_error(); + } + } + if let Err(e) = kv.delete(&nonce_key).await { + worker::console_error!("nonce delete failed: {e:?}"); + return internal_error(); + } + + // 5) Recover the signer and compare against the claimed address. + let addr = match siwe::recover_address(&req.message, &req.signature) { + Ok(a) => a, + Err(e) => return api_error(StatusCode::UNAUTHORIZED, e.to_string()), + }; + if addr != msg.address.to_lowercase() { + return api_error( + StatusCode::UNAUTHORIZED, + "signature does not match address", + ); + } + + // 6) D1: upsert the user row, then check the ban flag. + let db = match st.env.d1(crate::D1_BINDING) { + Ok(db) => db, + Err(e) => { + worker::console_error!("d1 binding failed: {e:?}"); + return internal_error(); + } + }; + let upsert = db + .prepare( + "INSERT INTO users (address, created_at, last_login) VALUES (?, ?, ?) \ + ON CONFLICT(address) DO UPDATE SET last_login = excluded.last_login", + ) + .bind(&[ + addr.as_str().into(), + (now as f64).into(), + (now as f64).into(), + ]); + match upsert { + Ok(stmt) => { + if let Err(e) = stmt.run().await { + worker::console_error!("user upsert failed: {e:?}"); + return internal_error(); + } + } + Err(e) => { + worker::console_error!("user upsert bind failed: {e:?}"); + return internal_error(); + } + } + + #[derive(Deserialize)] + struct BanRow { + banned: i64, + } + let banned = match db + .prepare("SELECT banned FROM users WHERE address = ?") + .bind(&[addr.as_str().into()]) + { + Ok(stmt) => match stmt.first::(None).await { + Ok(row) => row.is_some_and(|r| r.banned != 0), + Err(e) => { + worker::console_error!("ban check failed: {e:?}"); + return internal_error(); + } + }, + Err(e) => { + worker::console_error!("ban check bind failed: {e:?}"); + return internal_error(); + } + }; + if banned { + return api_error(StatusCode::FORBIDDEN, "account banned"); + } + + // 7) Ensure the Durable Object account exists (free grant on first touch). + if let Err(e) = do_client::do_init(&st.env, &addr).await { + worker::console_error!("do init failed: {e:?}"); + return internal_error(); + } + + // 8) Issue the session cookie. + let claims = session::SessionClaims { + sub: addr.clone(), + iat: now, + exp: now + config::SESSION_TTL_SECS, + }; + let token = session::jwt_sign(&claims, &st.jwt_secret); + cookies.add(session::make_session_cookie( + token, + config::SESSION_TTL_SECS as i64, + )); + + Json(VerifyResp { + address: siwe::to_checksum(&addr), + }) + .into_response() } #[worker::send] -pub async fn logout(_cookies: Cookies) -> Response { - (StatusCode::NOT_IMPLEMENTED, "not implemented").into_response() +pub async fn logout(cookies: Cookies) -> Response { + cookies.add(session::clear_session_cookie()); + StatusCode::OK.into_response() } #[worker::send] -pub async fn me(State(_st): State, _cookies: Cookies) -> Response { - (StatusCode::NOT_IMPLEMENTED, "not implemented").into_response() +pub async fn me(State(st): State, cookies: Cookies) -> Response { + let Some(addr) = session::session_address(&cookies, &st.jwt_secret) else { + return api_error(StatusCode::UNAUTHORIZED, "not signed in"); + }; + match do_client::do_status(&st.env, &addr).await { + Ok(status) => Json(status).into_response(), + Err(e) => { + worker::console_error!("do status failed: {e:?}"); + internal_error() + } + } }