diff --git a/src/db/main/user/blocking_queries.rs b/src/db/main/user/blocking_queries.rs index 55d83fa..38847d3 100644 --- a/src/db/main/user/blocking_queries.rs +++ b/src/db/main/user/blocking_queries.rs @@ -21,6 +21,29 @@ pub fn insert(name: &str, password: &str, conn: &Connection) -> Result { .map_err(Into::into) } +pub fn insert_with_npub( + name: &str, + password: &str, + npub: &str, + roles: &[Role], + conn: &Connection, +) -> Result { + let roles_json: Vec = roles.iter().map(|r| r.to_string()).collect(); + conn.query_row( + &format!( + r#" + INSERT INTO {TABLE} ({Name}, {Password}, {Npub}, {Roles}) + VALUES (?1, ?2, ?3, json(?4)) + RETURNING {projection} + "#, + projection = User::projection(), + ), + params![name, password, npub, serde_json::to_string(&roles_json)?], + User::mapper(), + ) + .map_err(Into::into) +} + #[allow(dead_code)] pub fn select_all(conn: &Connection) -> Result> { conn.prepare(&format!( diff --git a/src/db/main/user/queries.rs b/src/db/main/user/queries.rs index bdb8ca4..d834e67 100644 --- a/src/db/main/user/queries.rs +++ b/src/db/main/user/queries.rs @@ -15,6 +15,32 @@ pub async fn insert( .await? } +/// Insert a user with `npub` and `roles` set in a single statement. +/// Errors with a SQLite UNIQUE-violation if another row already has the +/// same `npub` (see migration 101). Used by the NIP-98 sign-in endpoint to +/// make auto-creation race-safe and atomic: a concurrent first-time login +/// for the same pubkey will lose this insert and fall back to +/// `select_by_npub`, and there is no window where a row exists with empty +/// roles. +pub async fn insert_with_npub( + name: impl Into, + password: impl Into, + npub: impl Into, + roles: &[Role], + pool: &Pool, +) -> Result { + let name = name.into(); + let password = password.into(); + let npub = npub.into(); + let roles = roles.to_vec(); + pool.get() + .await? + .interact(move |conn| { + blocking_queries::insert_with_npub(&name, &password, &npub, &roles, conn) + }) + .await? +} + pub async fn select_by_id(id: i64, pool: &Pool) -> Result { pool.get() .await? @@ -30,7 +56,6 @@ pub async fn select_by_name(name: impl Into, pool: &Pool) -> Result, pool: &Pool) -> Result> { let npub = npub.into(); pool.get() diff --git a/src/main.rs b/src/main.rs index a62ff0d..b59a3ff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,14 @@ async fn main() -> Result<()> { let conf = db::main::conf::queries::select(&main_pool).await?; + // Trusted external base URL of this API. Used by the NIP-98 NostrAuth + // extractor to reconstruct the URL the signed event must bind to. + // Per-deployment infrastructure value, so it lives in env, not in Conf + // (which is DB-backed and meant for runtime-tunable values shared across + // deployments). Production must set this to the public URL. + let api_base_url = + env::var("BTCMAP_API_BASE_URL").unwrap_or_else(|_| "http://127.0.0.1:8000".to_string()); + check_areas_without_icon_square(&main_pool).await; service::matrix::init(&main_pool); @@ -45,6 +53,9 @@ async fn main() -> Result<()> { .app_data(Data::new(image_pool.clone())) .app_data(Data::new(log_pool.clone())) .app_data(Data::new(conf.clone())) + .app_data(Data::new(rest::nostr_auth::ApiBaseUrl( + api_base_url.clone(), + ))) .service(og::element::get_element) .service( scope("rpc") @@ -188,6 +199,7 @@ async fn main() -> Result<()> { .service(rest::v4::areas::get_by_id) .service(rest::v4::areas::get), ) + .service(scope("auth").service(rest::v4::nostr::auth_nostr)) .service(scope("dashboard").service(rest::v4::dashboard::get)) .service(scope("top-editors").service(rest::v4::top_editors::get)) .service(scope("communities").service(rest::v4::communities::get_top)) diff --git a/src/rest/nostr_auth.rs b/src/rest/nostr_auth.rs index 4597883..7bae45f 100644 --- a/src/rest/nostr_auth.rs +++ b/src/rest/nostr_auth.rs @@ -1,7 +1,3 @@ -// See the note in `src/service/nip98.rs` — this extractor is infrastructure -// ahead of the endpoints that will consume it in a follow-up PR. -#![allow(dead_code)] - use actix_web::web::Data; use actix_web::{dev::Payload, http::header, FromRequest, HttpRequest}; use std::future::Future; diff --git a/src/rest/v4/mod.rs b/src/rest/v4/mod.rs index 50f5118..72b005e 100644 --- a/src/rest/v4/mod.rs +++ b/src/rest/v4/mod.rs @@ -6,6 +6,7 @@ pub mod dashboard; pub mod element_issues; pub mod events; pub mod invoices; +pub mod nostr; pub mod place_boosts; pub mod place_comments; pub mod places; diff --git a/src/rest/v4/nostr.rs b/src/rest/v4/nostr.rs new file mode 100644 index 0000000..35a3fcd --- /dev/null +++ b/src/rest/v4/nostr.rs @@ -0,0 +1,286 @@ +use crate::db; +use crate::db::main::user::schema::{Role, User}; +use crate::db::main::MainPool; +use crate::rest::error::{RestApiError, RestResult}; +use crate::rest::nostr_auth::NostrAuth; +use actix_web::post; +use actix_web::web::Data; +use actix_web::web::Json; +use names::Generator; +use names::Name; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Serialize, Deserialize)] +pub struct AuthNostrResponse { + pub token: String, + pub username: String, + pub npub: String, +} + +/// `POST /v4/auth/nostr` +/// +/// Exchange a NIP-98 signed event for a BTC Map Bearer token. +/// +/// Auth is via `Authorization: Nostr `. Verification is +/// performed by the [`NostrAuth`](crate::rest::nostr_auth::NostrAuth) +/// extractor against the URL pinned by `ApiBaseUrl` — request headers +/// (`Host`, `X-Forwarded-*`) are not trusted. +/// +/// On success, looks up the user by bech32 npub. If no user is linked to +/// the pubkey, a fresh account is auto-created in a single INSERT +/// (generated name, empty password, role `User`, npub set). A token is +/// minted bound to that user and returned alongside `username` and `npub`. +/// +/// Concurrency: two simultaneous first-time sign-ins for the same pubkey +/// race on `INSERT INTO user(... npub)`. The unique partial index from +/// migration 101 fails the loser with a SQLite ConstraintViolation; this +/// handler then re-selects by npub and uses the winning row, so exactly +/// one fully-initialized user exists. Any other database error is +/// propagated rather than masked as a lost race. +#[post("/nostr")] +pub async fn auth_nostr(auth: NostrAuth, pool: Data) -> RestResult { + let npub = auth.npub.ok_or_else(RestApiError::unauthorized)?; + + let user = match db::main::user::queries::select_by_npub(npub.clone(), &pool) + .await + .map_err(|_| RestApiError::database())? + { + Some(u) => u, + None => create_or_recover(&npub, &pool).await?, + }; + + // Mint with empty token roles so authorization always derives from + // the user's current roles (see rpc::handler — non-empty token roles + // override the user's). Otherwise role revocations on the user would + // not take effect for already-issued Nostr tokens. + let secret = Uuid::new_v4().to_string(); + db::main::access_token::queries::insert(user.id, String::new(), secret.clone(), vec![], &pool) + .await + .map_err(|_| RestApiError::database())?; + + Ok(Json(AuthNostrResponse { + token: secret, + username: user.name, + npub, + })) +} + +/// Auto-create a user for an unknown npub. The insert sets `roles` +/// atomically alongside `npub`. Two distinct UNIQUE failures are possible +/// here, and they're handled differently: +/// +/// * `user.npub` — lost a race against a concurrent first-time login for +/// the same pubkey. Recover by selecting the winning row so both +/// callers agree on a single, fully-initialized user. +/// * `user.name` — the random `Name::Numbered` generator collided with an +/// existing user. Retry with a fresh name a few times. +/// +/// Any other database error (pool, panic, NOT NULL, other UNIQUE indexes, +/// foreign keys, ...) is propagated as a database error rather than +/// silently masked as a lost race. +const NAME_RETRIES: u8 = 5; + +async fn create_or_recover(npub: &str, pool: &MainPool) -> Result { + for _ in 0..NAME_RETRIES { + let name = Generator::with_naming(Name::Numbered) + .next() + .unwrap_or_default(); + + match db::main::user::queries::insert_with_npub( + name, + String::new(), + npub, + &[Role::User], + pool, + ) + .await + { + Ok(user) => return Ok(user), + Err(e) if is_unique_violation_on(&e, "user.npub") => { + return db::main::user::queries::select_by_npub(npub.to_string(), pool) + .await + .map_err(|_| RestApiError::database())? + .ok_or_else(RestApiError::database); + } + Err(e) if is_unique_violation_on(&e, "user.name") => continue, + Err(_) => return Err(RestApiError::database()), + } + } + Err(RestApiError::database()) +} + +/// True iff `err` is a SQLite `ConstraintViolation` whose message names +/// `target` (e.g. `"user.npub"`). SQLite's UNIQUE failure message has the +/// form `UNIQUE constraint failed: .`, so column-level +/// matching is robust enough without parsing extended error codes. +fn is_unique_violation_on(err: &crate::Error, target: &str) -> bool { + matches!( + err, + crate::Error::Rusqlite(rusqlite::Error::SqliteFailure(e, msg)) + if e.code == rusqlite::ErrorCode::ConstraintViolation + && msg.as_deref().is_some_and(|m| m.contains(target)) + ) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::db::main::test::pool; + use crate::rest::nostr_auth::ApiBaseUrl; + use crate::Result; + use actix_web::http::header; + use actix_web::http::StatusCode; + use actix_web::test::TestRequest; + use actix_web::web::scope; + use actix_web::{test, App}; + use base64::engine::general_purpose::STANDARD as BASE64; + use base64::Engine; + use nostr::event::EventBuilder; + use nostr::key::Keys; + use nostr::nips::nip19::ToBech32; + use nostr::{JsonUtil, Kind, Tag, Timestamp}; + + const BASE_URL: &str = "https://api.example.test"; + const ENDPOINT_PATH: &str = "/v4/auth/nostr"; + + fn signed_nip98(keys: &Keys, method: &str) -> String { + let url = format!("{BASE_URL}{ENDPOINT_PATH}"); + let event = EventBuilder::new(Kind::from_u16(27235), "") + .tags(vec![ + Tag::parse(["u", &url]).unwrap(), + Tag::parse(["method", method]).unwrap(), + ]) + .custom_created_at(Timestamp::now()) + .sign_with_keys(keys) + .unwrap(); + BASE64.encode(event.as_json().as_bytes()) + } + + fn build_app( + pool_data: Data, + ) -> App< + impl actix_web::dev::ServiceFactory< + actix_web::dev::ServiceRequest, + Config = (), + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + InitError = (), + >, + > { + App::new() + .app_data(pool_data) + .app_data(Data::new(ApiBaseUrl(BASE_URL.to_string()))) + .service(scope("/v4").service(scope("/auth").service(auth_nostr))) + } + + #[actix_web::test] + async fn missing_header_returns_401() -> Result<()> { + let app = test::init_service(build_app(Data::new(pool()))).await; + let req = TestRequest::post().uri(ENDPOINT_PATH).to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + Ok(()) + } + + #[actix_web::test] + async fn malformed_authorization_returns_401() -> Result<()> { + let app = test::init_service(build_app(Data::new(pool()))).await; + let req = TestRequest::post() + .uri(ENDPOINT_PATH) + .insert_header((header::AUTHORIZATION, "Bearer not-a-nostr-event")) + .to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + Ok(()) + } + + #[actix_web::test] + async fn known_npub_returns_token_for_existing_user() -> Result<()> { + let pool = pool(); + let keys = Keys::generate(); + let npub = keys.public_key().to_bech32().unwrap(); + let user = db::main::user::queries::insert_with_npub( + "preexisting_user", + "", + &npub, + &[Role::User], + &pool, + ) + .await?; + + let app = test::init_service(build_app(Data::new(pool.clone()))).await; + let payload = signed_nip98(&keys, "POST"); + let req = TestRequest::post() + .uri(ENDPOINT_PATH) + .insert_header((header::AUTHORIZATION, format!("Nostr {payload}"))) + .to_request(); + let res: AuthNostrResponse = test::call_and_read_body_json(&app, req).await; + + assert_eq!(res.username, "preexisting_user"); + assert_eq!(res.npub, npub); + // Token must actually be persisted and bound to the existing user + let token = db::main::access_token::queries::select_by_secret(res.token, &pool).await?; + assert_eq!(token.user_id, user.id); + Ok(()) + } + + #[actix_web::test] + async fn unknown_npub_auto_creates_user_and_returns_token() -> Result<()> { + let pool = pool(); + let keys = Keys::generate(); + let npub = keys.public_key().to_bech32().unwrap(); + + let app = test::init_service(build_app(Data::new(pool.clone()))).await; + let payload = signed_nip98(&keys, "POST"); + let req = TestRequest::post() + .uri(ENDPOINT_PATH) + .insert_header((header::AUTHORIZATION, format!("Nostr {payload}"))) + .to_request(); + let res: AuthNostrResponse = test::call_and_read_body_json(&app, req).await; + + assert_eq!(res.npub, npub); + // The user must exist in the DB with the right npub and role User + let user = db::main::user::queries::select_by_npub(npub.clone(), &pool) + .await? + .expect("auto-created user should be findable by npub"); + assert_eq!(user.name, res.username); + assert!(user.roles.contains(&Role::User)); + // Token must be bound to that user + let token = db::main::access_token::queries::select_by_secret(res.token, &pool).await?; + assert_eq!(token.user_id, user.id); + Ok(()) + } + + #[actix_web::test] + async fn unknown_npub_concurrent_inserts_create_exactly_one_user() -> Result<()> { + // Two parallel select_by_npub calls both return None, then both + // attempt insert_with_npub. Migration 101's unique partial index + // fails the loser; create_or_recover then re-selects the winner. + // Net effect: exactly one user row with this npub. + let pool = pool(); + let keys = Keys::generate(); + let npub = keys.public_key().to_bech32().unwrap(); + + let pool_a = pool.clone(); + let pool_b = pool.clone(); + let npub_a = npub.clone(); + let npub_b = npub.clone(); + + let (a, b) = tokio::join!( + create_or_recover(&npub_a, &pool_a), + create_or_recover(&npub_b, &pool_b), + ); + let user_a = a.expect("first call should succeed"); + let user_b = b.expect("second call should succeed (via recover path)"); + + // Both calls must agree on the same row, and roles must be set — + // there is no window where the row exists with empty roles, even + // for the loser branch which selects the winner's row. + assert_eq!(user_a.id, user_b.id); + assert_eq!(user_a.npub, Some(npub.clone())); + assert!(user_a.roles.contains(&Role::User)); + assert!(user_b.roles.contains(&Role::User)); + Ok(()) + } +} diff --git a/src/rest/v4/users.rs b/src/rest/v4/users.rs index 7634cc6..29f4a57 100644 --- a/src/rest/v4/users.rs +++ b/src/rest/v4/users.rs @@ -204,6 +204,14 @@ pub async fn create_token( .await .map_err(|_| RestApiError::unauthorized())?; + // Users provisioned via Nostr (NIP-98) have no password set. Block + // password auth for them explicitly so an empty bearer cannot be + // mistaken for a credential. PHC parsing of an empty hash already + // fails today, but make the intent explicit. + if user.password.is_empty() { + return Err(RestApiError::unauthorized()); + } + let password_hash = PasswordHash::new(&user.password) .map_err(|_| RestApiError::invalid_input("Invalid password hash"))?; diff --git a/src/service/nip98.rs b/src/service/nip98.rs index d3bc96d..b0296a9 100644 --- a/src/service/nip98.rs +++ b/src/service/nip98.rs @@ -20,11 +20,6 @@ //! and replays. See https://github.com/nostr-protocol/nips/blob/master/98.md //! for the spec text. -// Lands in isolation ahead of the endpoints that will consume it. The next -// PR wires `NostrAuth` into `POST /v4/auth/nostr`; until then clippy sees -// these items as dead. -#![allow(dead_code)] - use crate::error::Error; use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine;