Skip to content

Commit d64da07

Browse files
authored
Merge pull request #96 from Ximik/basicauth
Spaced RPC user/password protection
2 parents 646c312 + 3e56d94 commit d64da07

File tree

11 files changed

+272
-64
lines changed

11 files changed

+272
-64
lines changed

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ clap = { version = "4.5.6", features = ["derive", "env"] }
2727
log = "0.4.21"
2828
serde = { version = "1.0.200", features = ["derive"] }
2929
hex = "0.4.3"
30+
rand = "0.8"
3031
jsonrpsee = { version = "0.22.5", features = ["server", "http-client", "macros"] }
3132
directories = "5.0.1"
3233
env_logger = "0.11.3"
@@ -40,6 +41,7 @@ tabled = "0.17.0"
4041
colored = "3.0.0"
4142
domain = {version = "0.10.3", default-features = false, features = ["zonefile"]}
4243
tower = "0.4.13"
44+
hyper = "0.14.28"
4345

4446
[dev-dependencies]
4547
assert_cmd = "2.0.16"

client/src/app.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,12 @@ impl App {
6969
let rpc_server = RpcServerImpl::new(async_chain_state.clone(), wallet_manager);
7070

7171
let bind = spaced.bind.clone();
72+
let auth_token = spaced.auth_token.clone();
7273
let shutdown = self.shutdown.clone();
7374

7475
self.services.spawn(async move {
7576
rpc_server
76-
.listen(bind, shutdown)
77+
.listen(bind, auth_token, shutdown)
7778
.await
7879
.map_err(|e| anyhow!("RPC Server error: {}", e))
7980
});

client/src/auth.rs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
use base64::Engine;
2+
use hyper::{http::HeaderValue, Body, HeaderMap, Request, Response, StatusCode};
3+
use jsonrpsee::{
4+
core::ClientError,
5+
http_client::{HttpClient, HttpClientBuilder},
6+
};
7+
use std::{
8+
error::Error,
9+
future::Future,
10+
pin::Pin,
11+
sync::Arc,
12+
task::{Context, Poll},
13+
};
14+
use tower::{Layer, Service};
15+
16+
#[derive(Debug, Clone)]
17+
pub(crate) struct BasicAuthLayer {
18+
token: String,
19+
}
20+
21+
impl BasicAuthLayer {
22+
pub fn new(token: String) -> Self {
23+
Self { token }
24+
}
25+
}
26+
27+
impl<S> Layer<S> for BasicAuthLayer {
28+
type Service = BasicAuth<S>;
29+
30+
fn layer(&self, inner: S) -> Self::Service {
31+
BasicAuth::new(inner, self.token.clone())
32+
}
33+
}
34+
35+
#[derive(Debug, Clone)]
36+
pub(crate) struct BasicAuth<S> {
37+
inner: S,
38+
token: Arc<str>,
39+
}
40+
41+
impl<S> BasicAuth<S> {
42+
pub fn new(inner: S, token: String) -> Self {
43+
Self {
44+
inner,
45+
token: Arc::from(token.as_str()),
46+
}
47+
}
48+
49+
fn check_auth(&self, headers: &HeaderMap) -> bool {
50+
headers
51+
.get("authorization")
52+
.and_then(|h| h.to_str().ok())
53+
.and_then(|s| s.strip_prefix("Basic "))
54+
.map_or(false, |token| token == self.token.as_ref())
55+
}
56+
57+
fn unauthorized_response() -> Response<Body> {
58+
Response::builder()
59+
.status(StatusCode::UNAUTHORIZED)
60+
.header("WWW-Authenticate", "Basic realm=\"Protected\"")
61+
.body(Body::from("Unauthorized"))
62+
.expect("Failed to build unauthorized response")
63+
}
64+
}
65+
66+
impl<S> Service<Request<Body>> for BasicAuth<S>
67+
where
68+
S: Service<Request<Body>, Response = Response<Body>>,
69+
S::Response: 'static,
70+
S::Error: Into<Box<dyn Error + Send + Sync>> + 'static,
71+
S::Future: Send + 'static,
72+
{
73+
type Response = S::Response;
74+
type Error = Box<dyn Error + Send + Sync + 'static>;
75+
type Future =
76+
Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
77+
78+
#[inline]
79+
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
80+
self.inner.poll_ready(cx).map_err(Into::into)
81+
}
82+
83+
fn call(&mut self, req: Request<Body>) -> Self::Future {
84+
if !self.check_auth(req.headers()) {
85+
let response = Self::unauthorized_response();
86+
return Box::pin(async move { Ok(response) });
87+
}
88+
89+
let fut = self.inner.call(req);
90+
let res_fut = async move { fut.await.map_err(|err| err.into()) };
91+
Box::pin(res_fut)
92+
}
93+
}
94+
95+
pub fn auth_cookie(user: &str, password: &str) -> String {
96+
format!("{user}:{password}")
97+
}
98+
99+
pub fn auth_token_from_cookie(cookie: &str) -> String {
100+
base64::prelude::BASE64_STANDARD.encode(cookie)
101+
}
102+
103+
pub fn auth_token_from_creds(user: &str, password: &str) -> String {
104+
base64::prelude::BASE64_STANDARD.encode(auth_cookie(user, password))
105+
}
106+
107+
pub fn http_client_with_auth(url: &str, auth_token: &str) -> Result<HttpClient, ClientError> {
108+
let mut headers = hyper::http::HeaderMap::new();
109+
headers.insert(
110+
"Authorization",
111+
HeaderValue::from_str(&format!("Basic {auth_token}")).unwrap(),
112+
);
113+
HttpClientBuilder::default().set_headers(headers).build(url)
114+
}

client/src/bin/space-cli.rs

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,17 @@ use domain::{
1616
};
1717
use jsonrpsee::{
1818
core::{client::Error, ClientError},
19-
http_client::{HttpClient, HttpClientBuilder},
19+
http_client::HttpClient,
2020
};
2121
use serde::{Deserialize, Serialize};
2222
use spaces_client::{
23-
config::{default_spaces_rpc_port, ExtendedNetwork},
23+
auth::{auth_token_from_cookie, auth_token_from_creds, http_client_with_auth},
24+
config::{default_cookie_path, default_spaces_rpc_port, ExtendedNetwork},
2425
deserialize_base64,
2526
format::{
2627
print_error_rpc_response, print_list_bidouts, print_list_spaces_response,
27-
print_list_transactions, print_list_unspent, print_server_info,
28-
print_list_wallets, print_wallet_balance_response, print_wallet_info, print_wallet_response,
29-
Format,
28+
print_list_transactions, print_list_unspent, print_list_wallets, print_server_info,
29+
print_wallet_balance_response, print_wallet_info, print_wallet_response, Format,
3030
},
3131
rpc::{
3232
BidParams, ExecuteParams, OpenParams, RegisterParams, RpcClient, RpcWalletRequest,
@@ -53,7 +53,16 @@ pub struct Args {
5353
output_format: Format,
5454
/// Spaced RPC URL [default: based on specified chain]
5555
#[arg(long)]
56-
spaced_rpc_url: Option<String>,
56+
rpc_url: Option<String>,
57+
/// Spaced RPC cookie file path
58+
#[arg(long, env = "SPACED_RPC_COOKIE")]
59+
rpc_cookie: Option<PathBuf>,
60+
/// Spaced RPC user
61+
#[arg(long, requires = "rpc_password", env = "SPACED_RPC_USER")]
62+
rpc_user: Option<String>,
63+
/// Spaced RPC password
64+
#[arg(long, env = "SPACED_RPC_PASSWORD")]
65+
rpc_password: Option<String>,
5766
/// Specify wallet to use
5867
#[arg(long, short, global = true, default_value = "default")]
5968
wallet: String,
@@ -383,11 +392,31 @@ struct Base64Bytes(
383392
impl SpaceCli {
384393
async fn configure() -> anyhow::Result<(Self, Args)> {
385394
let mut args = Args::parse();
386-
if args.spaced_rpc_url.is_none() {
387-
args.spaced_rpc_url = Some(default_spaced_rpc_url(&args.chain));
395+
if args.rpc_url.is_none() {
396+
args.rpc_url = Some(default_rpc_url(&args.chain));
388397
}
389398

390-
let client = HttpClientBuilder::default().build(args.spaced_rpc_url.clone().unwrap())?;
399+
let auth_token = if args.rpc_user.is_some() {
400+
auth_token_from_creds(
401+
args.rpc_user.as_ref().unwrap(),
402+
args.rpc_password.as_ref().unwrap(),
403+
)
404+
} else {
405+
let cookie_path = match &args.rpc_cookie {
406+
Some(path) => path,
407+
None => &default_cookie_path(&args.chain),
408+
};
409+
let cookie = fs::read_to_string(cookie_path).map_err(|e| {
410+
anyhow!(
411+
"Failed to read cookie file '{}': {}",
412+
cookie_path.display(),
413+
e
414+
)
415+
})?;
416+
auth_token_from_cookie(&cookie)
417+
};
418+
let client = http_client_with_auth(args.rpc_url.as_ref().unwrap(), &auth_token)?;
419+
391420
Ok((
392421
Self {
393422
wallet: args.wallet.clone(),
@@ -396,7 +425,7 @@ impl SpaceCli {
396425
force: args.force,
397426
skip_tx_check: args.skip_tx_check,
398427
network: args.chain,
399-
rpc_url: args.spaced_rpc_url.clone().unwrap(),
428+
rpc_url: args.rpc_url.clone().unwrap(),
400429
client,
401430
},
402431
args,
@@ -930,7 +959,7 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client
930959
Ok(())
931960
}
932961

933-
fn default_spaced_rpc_url(chain: &ExtendedNetwork) -> String {
962+
fn default_rpc_url(chain: &ExtendedNetwork) -> String {
934963
format!("http://127.0.0.1:{}", default_spaces_rpc_port(chain))
935964
}
936965

client/src/config.rs

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,23 @@ use std::{
55
path::PathBuf,
66
};
77

8-
use clap::{
9-
ArgGroup, Parser, ValueEnum,
10-
};
8+
use anyhow::anyhow;
9+
use clap::{ArgGroup, Parser, ValueEnum};
1110
use directories::ProjectDirs;
1211
use jsonrpsee::core::Serialize;
1312
use log::error;
13+
use rand::{
14+
distributions::Alphanumeric,
15+
{thread_rng, Rng},
16+
};
1417
use serde::Deserialize;
1518
use spaces_protocol::bitcoin::Network;
1619

1720
use crate::{
21+
auth::{auth_token_from_cookie, auth_token_from_creds},
1822
source::{BitcoinRpc, BitcoinRpcAuth},
19-
store::{LiveStore, Store},
2023
spaces::Spaced,
24+
store::{LiveStore, Store},
2125
};
2226

2327
const RPC_OPTIONS: &str = "RPC Server Options";
@@ -58,6 +62,12 @@ pub struct Args {
5862
/// Bitcoin RPC password
5963
#[arg(long, env = "SPACED_BITCOIN_RPC_PASSWORD")]
6064
bitcoin_rpc_password: Option<String>,
65+
/// Spaced RPC user
66+
#[arg(long, requires = "rpc_password", env = "SPACED_RPC_USER")]
67+
rpc_user: Option<String>,
68+
/// Spaced RPC password
69+
#[arg(long, env = "SPACED_RPC_PASSWORD")]
70+
rpc_password: Option<String>,
6171
/// Bind to given address to listen for JSON-RPC connections.
6272
/// This option can be specified multiple times (default: 127.0.0.1 and ::1 i.e., localhost)
6373
#[arg(long, help_heading = Some(RPC_OPTIONS), default_values = ["127.0.0.1", "::1"], env = "SPACED_RPC_BIND")]
@@ -102,7 +112,7 @@ impl Args {
102112
/// Configures spaced node by processing command line arguments
103113
/// and configuration files
104114
pub async fn configure(args: Vec<String>) -> anyhow::Result<Spaced> {
105-
let mut args = Args::try_parse_from(args)?;
115+
let mut args = Args::try_parse_from(args)?;
106116
let default_dirs = get_default_node_dirs();
107117

108118
if args.bitcoin_rpc_url.is_none() {
@@ -117,6 +127,7 @@ impl Args {
117127
Some(data_dir) => data_dir,
118128
}
119129
.join(args.chain.to_string());
130+
fs::create_dir_all(data_dir.clone())?;
120131

121132
let default_port = args.rpc_port.unwrap();
122133
let rpc_bind_addresses: Vec<SocketAddr> = args
@@ -132,6 +143,31 @@ impl Args {
132143
})
133144
.collect();
134145

146+
let auth_token = if args.rpc_user.is_some() {
147+
auth_token_from_creds(
148+
args.rpc_user.as_ref().unwrap(),
149+
args.rpc_password.as_ref().unwrap(),
150+
)
151+
} else {
152+
let cookie = format!(
153+
"__cookie__:{}",
154+
thread_rng()
155+
.sample_iter(&Alphanumeric)
156+
.take(64)
157+
.map(char::from)
158+
.collect::<String>()
159+
);
160+
let cookie_path = data_dir.join(".cookie");
161+
fs::write(&cookie_path, &cookie).map_err(|e| {
162+
anyhow!(
163+
"Failed to write cookie file '{}': {}",
164+
cookie_path.display(),
165+
e
166+
)
167+
})?;
168+
auth_token_from_cookie(&cookie)
169+
};
170+
135171
let bitcoin_rpc_auth = if let Some(cookie) = args.bitcoin_rpc_cookie {
136172
let cookie = std::fs::read_to_string(cookie)?;
137173
BitcoinRpcAuth::Cookie(cookie)
@@ -144,13 +180,11 @@ impl Args {
144180
let rpc = BitcoinRpc::new(
145181
&args.bitcoin_rpc_url.expect("bitcoin rpc url"),
146182
bitcoin_rpc_auth,
147-
!args.bitcoin_rpc_light
183+
!args.bitcoin_rpc_light,
148184
);
149185

150186
let genesis = Spaced::genesis(args.chain);
151187

152-
fs::create_dir_all(data_dir.clone())?;
153-
154188
let proto_db_path = data_dir.join("protocol.sdb");
155189
let initial_sync = !proto_db_path.exists();
156190

@@ -196,13 +230,14 @@ impl Args {
196230
rpc,
197231
data_dir,
198232
bind: rpc_bind_addresses,
233+
auth_token,
199234
chain,
200235
block_index,
201236
block_index_full: args.block_index_full,
202237
num_workers: args.jobs as usize,
203238
anchors_path,
204239
synced: false,
205-
cbf: args.bitcoin_rpc_light
240+
cbf: args.bitcoin_rpc_light,
206241
})
207242
}
208243
}
@@ -214,6 +249,13 @@ fn get_default_node_dirs() -> ProjectDirs {
214249
})
215250
}
216251

252+
pub fn default_cookie_path(network: &ExtendedNetwork) -> PathBuf {
253+
get_default_node_dirs()
254+
.data_dir()
255+
.join(network.to_string())
256+
.join(".cookie")
257+
}
258+
217259
// from clap utilities
218260
pub fn safe_exit(code: i32) -> ! {
219261
use std::io::Write;

0 commit comments

Comments
 (0)