Browse Source

Merge pull request #89 from comit-network/maker-auth

no-contract-setup-message
Thomas Eizinger 3 years ago
committed by GitHub
parent
commit
9325070974
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 12
      Cargo.lock
  2. 3
      Cargo.toml
  3. 4
      daemon/Cargo.toml
  4. 84
      daemon/src/auth.rs
  5. 8
      daemon/src/maker.rs
  6. 120
      daemon/src/routes_maker.rs
  7. 20
      daemon/src/seed.rs
  8. 1
      frontend/package.json
  9. 5
      frontend/src/Maker.tsx
  10. 7
      frontend/src/Taker.tsx
  11. 9
      frontend/yarn.lock

12
Cargo.lock

@ -445,9 +445,11 @@ dependencies = [
"cfd_protocol",
"clap",
"futures",
"hex",
"hkdf",
"rand 0.6.5",
"rocket",
"rocket-basicauth",
"rocket_db_pools",
"rust-embed",
"rust_decimal",
@ -1712,6 +1714,16 @@ dependencies = [
"yansi",
]
[[package]]
name = "rocket-basicauth"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79128c0f55b7bc6785c13816d71af4baee156bd615b09468800edaaa7da56a08"
dependencies = [
"base64 0.13.0",
"rocket",
]
[[package]]
name = "rocket_codegen"
version = "0.5.0-rc.1"

3
Cargo.toml

@ -1,3 +1,6 @@
[workspace]
members = ["cfd_protocol", "daemon"]
resolver = "2"
[patch.crates-io]
rocket = { git = "https://github.com/SergioBenitez/Rocket" } # Need to patch rocket dependency of `rocket_basicauth` until there is an official release.

4
daemon/Cargo.toml

@ -10,9 +10,11 @@ bdk = { git = "https://github.com/bitcoindevkit/bdk/" }
cfd_protocol = { path = "../cfd_protocol" }
clap = "3.0.0-beta.4"
futures = { version = "0.3", default-features = false }
hex = "0.4"
hkdf = "0.11"
rand = "0.6"
rocket = { git = "https://github.com/SergioBenitez/Rocket", features = ["json"] }
rocket = { version = "0.5.0-rc.1", features = ["json"] }
rocket-basicauth = { version = "2", default-features = false }
rocket_db_pools = { git = "https://github.com/SergioBenitez/Rocket", features = ["sqlx_sqlite"] }
rust-embed = "6.2"
rust_decimal = { version = "1.16", features = ["serde-float", "serde-arbitrary-precision"] }

84
daemon/src/auth.rs

@ -0,0 +1,84 @@
use hex::FromHexError;
use rocket::http::Status;
use rocket::outcome::{try_outcome, IntoOutcome};
use rocket::request::{FromRequest, Outcome};
use rocket::{Request, State};
use rocket_basicauth::{BasicAuth, BasicAuthError};
use std::fmt;
use std::str::FromStr;
/// A request guard that can be included in handler definitions to enforce authentication.
pub struct Authenticated {}
#[derive(Debug)]
pub enum Error {
UnknownUser(String),
BadPassword,
InvalidEncoding(FromHexError),
BadBasicAuthHeader(BasicAuthError),
/// The auth password was not configured in Rocket's state.
MissingPassword,
NoAuthHeader,
}
#[derive(PartialEq)]
pub struct Password([u8; 32]);
impl From<[u8; 32]> for Password {
fn from(bytes: [u8; 32]) -> Self {
Self(bytes)
}
}
impl fmt::Display for Password {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", hex::encode(self.0))
}
}
impl FromStr for Password {
type Err = FromHexError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut bytes = [0u8; 32];
hex::decode_to_slice(s, &mut bytes)?;
Ok(Self(bytes))
}
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for Authenticated {
type Error = Error;
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let basic_auth = try_outcome!(req
.guard::<BasicAuth>()
.await
.map_failure(|(status, error)| (status, Error::BadBasicAuthHeader(error)))
.forward_then(|()| Outcome::Failure((Status::Unauthorized, Error::NoAuthHeader))));
let password = try_outcome!(req
.guard::<&'r State<Password>>()
.await
.map_failure(|(status, _)| (status, Error::MissingPassword)));
if basic_auth.username != "maker" {
return Outcome::Failure((
Status::Unauthorized,
Error::UnknownUser(basic_auth.username),
));
}
if &try_outcome!(basic_auth
.password
.parse::<Password>()
.map_err(Error::InvalidEncoding)
.into_outcome(Status::BadRequest))
!= password.inner()
{
return Outcome::Failure((Status::Unauthorized, Error::BadPassword));
}
Outcome::Success(Authenticated {})
}
}

8
daemon/src/maker.rs

@ -14,6 +14,7 @@ use std::time::Duration;
use tokio::sync::{mpsc, watch};
use tracing_subscriber::filter::LevelFilter;
mod auth;
mod db;
mod keypair;
mod logger;
@ -86,6 +87,10 @@ async fn main() -> Result<()> {
.await?;
let wallet_info = wallet.sync().await.unwrap();
let auth_password = seed.derive_auth_password::<auth::Password>();
println!("Auth password: {}", auth_password);
let oracle = schnorrsig::KeyPair::new(SECP256K1, &mut rand::thread_rng()); // TODO: Fetch oracle public key from oracle.
let (cfd_feed_sender, cfd_feed_receiver) = watch::channel::<Vec<Cfd>>(vec![]);
@ -105,6 +110,7 @@ async fn main() -> Result<()> {
.manage(cfd_feed_receiver)
.manage(order_feed_receiver)
.manage(wallet_feed_receiver)
.manage(auth_password)
.attach(Db::init())
.attach(AdHoc::try_on_ignite(
"SQL migrations",
@ -171,10 +177,12 @@ async fn main() -> Result<()> {
routes_maker::get_health_check
],
)
.register("/api", rocket::catchers![routes_maker::unauthorized])
.mount(
"/",
rocket::routes![routes_maker::dist, routes_maker::index],
)
.register("/", rocket::catchers![routes_maker::unauthorized])
.launch()
.await?;

120
daemon/src/routes_maker.rs

@ -1,10 +1,11 @@
use crate::auth::Authenticated;
use crate::maker_cfd_actor;
use crate::model::cfd::{Cfd, Order, Origin};
use crate::model::{Usd, WalletInfo};
use crate::routes::EmbeddedFileExt;
use crate::to_sse_event::ToSseEvent;
use anyhow::Result;
use rocket::http::{ContentType, Status};
use rocket::http::{ContentType, Header, Status};
use rocket::response::stream::EventStream;
use rocket::response::{status, Responder};
use rocket::serde::json::Json;
@ -21,6 +22,7 @@ pub async fn maker_feed(
rx_cfds: &State<watch::Receiver<Vec<Cfd>>>,
rx_order: &State<watch::Receiver<Option<Order>>>,
rx_wallet: &State<watch::Receiver<WalletInfo>>,
_auth: Authenticated,
) -> EventStream![] {
let mut rx_cfds = rx_cfds.inner().clone();
let mut rx_order = rx_order.inner().clone();
@ -70,6 +72,7 @@ pub struct CfdNewOrderRequest {
pub async fn post_sell_order(
order: Json<CfdNewOrderRequest>,
cfd_actor_inbox: &State<mpsc::UnboundedSender<maker_cfd_actor::Command>>,
_auth: Authenticated,
) -> Result<status::Accepted<()>, status::BadRequest<String>> {
let order = Order::from_default_with_price(order.price, Origin::Ours)
.map_err(|e| status::BadRequest(Some(e.to_string())))?
@ -83,6 +86,23 @@ pub async fn post_sell_order(
Ok(status::Accepted(None))
}
/// A "catcher" for all 401 responses, triggers the browser's basic auth implementation.
#[rocket::catch(401)]
pub fn unauthorized() -> PromptAuthentication {
PromptAuthentication {
inner: (),
www_authenticate: Header::new("WWW-Authenticate", r#"Basic charset="UTF-8"#),
}
}
/// A rocket responder that prompts the user to sign in to access the API.
#[derive(rocket::Responder)]
#[response(status = 401)]
pub struct PromptAuthentication {
inner: (),
www_authenticate: Header<'static>,
}
// // TODO: Shall we use a simpler struct for verification? AFAICT quantity is not
// // needed, no need to send the whole CFD either as the other fields can be generated from the
// order #[rocket::post("/order/confirm", data = "<cfd_confirm_order_request>")]
@ -108,21 +128,109 @@ pub async fn post_sell_order(
#[rocket::get("/alive")]
pub fn get_health_check() {}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct RetrieveCurrentOrder;
#[derive(RustEmbed)]
#[folder = "../frontend/dist/maker"]
struct Asset;
#[rocket::get("/assets/<file..>")]
pub fn dist<'r>(file: PathBuf) -> impl Responder<'r, 'static> {
pub fn dist<'r>(file: PathBuf, _auth: Authenticated) -> impl Responder<'r, 'static> {
let filename = format!("assets/{}", file.display().to_string());
Asset::get(&filename).into_response(file)
}
#[rocket::get("/<_paths..>", format = "text/html")]
pub fn index<'r>(_paths: PathBuf) -> impl Responder<'r, 'static> {
pub fn index<'r>(_paths: PathBuf, _auth: Authenticated) -> impl Responder<'r, 'static> {
let asset = Asset::get("index.html").ok_or(Status::NotFound)?;
Ok::<(ContentType, Cow<[u8]>), Status>((ContentType::HTML, asset.data))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::Password;
use bdk::bitcoin::{Address, Amount, Network, PublicKey};
use rocket::http::{Header, Status};
use rocket::local::blocking::Client;
use rocket::{Build, Rocket};
use std::time::SystemTime;
use tokio::sync::mpsc;
#[test]
fn routes_are_password_protected() {
let client = Client::tracked(rocket()).unwrap();
let feed_response = client.get("/feed").dispatch();
let new_sell_order_response = client
.post("/order/sell")
.body(r#"{"price":"40000", "min_quantity":"100", "max_quantity":"10000"}"#)
.dispatch();
let index_response = client.get("/").header(ContentType::HTML).dispatch();
assert_eq!(feed_response.status(), Status::Unauthorized);
assert_eq!(new_sell_order_response.status(), Status::Unauthorized);
assert_eq!(index_response.status(), Status::Unauthorized);
}
#[test]
fn correct_password_grants_access() {
let client = Client::tracked(rocket()).unwrap();
let feed_response = client.get("/feed").header(auth_header()).dispatch();
let new_sell_order_response = client
.post("/order/sell")
.body(r#"{"price":"40000", "min_quantity":"100", "max_quantity":"10000"}"#)
.header(auth_header())
.dispatch();
let index_response = client
.get("/")
.header(ContentType::HTML)
.header(auth_header())
.dispatch();
assert_eq!(feed_response.status(), Status::Ok);
assert_eq!(new_sell_order_response.status(), Status::Accepted);
assert_eq!(index_response.status(), Status::NotFound); // we don't embed the files in the
// tests
}
/// Constructs a Rocket instance for testing.
fn rocket() -> Rocket<Build> {
let (_, state1) = watch::channel::<Vec<Cfd>>(vec![]);
let (_, state2) = watch::channel::<Option<Order>>(None);
let (_, state3) = watch::channel::<WalletInfo>(WalletInfo {
balance: Amount::ZERO,
address: Address::p2wpkh(
&PublicKey::new(
"0286cd889349ebc06b3165505b9c083df0a4147f554614ff207c10f16ff509578c"
.parse()
.unwrap(),
),
Network::Regtest,
)
.unwrap(),
last_updated_at: SystemTime::now(),
});
let (state4, actor) = mpsc::unbounded_channel::<maker_cfd_actor::Command>();
std::mem::forget(actor); // pretend the actor is running so we can don't panic in the route handler
rocket::build()
.manage(state1)
.manage(state2)
.manage(state3)
.manage(state4)
.manage(Password::from(*b"Now I'm feelin' so fly like a G6"))
.mount("/", rocket::routes![maker_feed, post_sell_order, index])
}
/// Creates an "Authorization" header that matches the password above,
/// in particular it has been created through:
/// ```
/// base64(maker:hex("Now I'm feelin' so fly like a G6"))
/// ```
fn auth_header() -> Header<'static> {
Header::new(
"Authorization",
"Basic bWFrZXI6NGU2Zjc3MjA0OTI3NmQyMDY2NjU2NTZjNjk2ZTI3MjA3MzZmMjA2NjZjNzkyMDZjNjk2YjY1MjA2MTIwNDczNg==",
)
}
}

20
daemon/src/seed.rs

@ -52,15 +52,27 @@ impl Seed {
}
pub fn derive_extended_priv_key(&self, network: Network) -> Result<ExtendedPrivKey> {
let h = Hkdf::<Sha256>::new(None, &self.0);
let mut okm = [0u8; 64];
h.expand(b"BITCOIN_WALLET_SEED", &mut okm)
let mut ext_priv_key_seed = [0u8; 64];
Hkdf::<Sha256>::new(None, &self.0)
.expand(b"BITCOIN_WALLET_SEED", &mut ext_priv_key_seed)
.expect("okm array is of correct length");
let ext_priv_key = ExtendedPrivKey::new_master(network, &okm)?;
let ext_priv_key = ExtendedPrivKey::new_master(network, &ext_priv_key_seed)?;
Ok(ext_priv_key)
}
#[allow(dead_code)] // Not used by all binaries.
pub fn derive_auth_password<P: From<[u8; 32]>>(&self) -> P {
let mut password = [0u8; 32];
Hkdf::<Sha256>::new(None, &self.0)
.expand(b"HTTP_AUTH_PASSWORD", &mut password)
.expect("okm array is of correct length");
P::from(password)
}
}
impl Default for Seed {

1
frontend/package.json

@ -24,7 +24,6 @@
"@types/react-table": "^7.7.2",
"@typescript-eslint/eslint-plugin": "^4.30.0",
"@typescript-eslint/parser": "^4.30.0",
"axios": "^0.21.1",
"babel-eslint": "^10.1.0",
"eslint": "^7.32.0",
"eslint-config-react-app": "^6.0.0",

5
frontend/src/Maker.tsx

@ -11,7 +11,6 @@ import {
useToast,
VStack,
} from "@chakra-ui/react";
import axios from "axios";
import React, { useState } from "react";
import { useAsync } from "react-async";
import { Route, Routes } from "react-router-dom";
@ -32,7 +31,7 @@ interface CfdSellOrderPayload {
}
async function postCfdSellOrderRequest(payload: CfdSellOrderPayload) {
let res = await axios.post(`/api/order/sell`, JSON.stringify(payload));
let res = await fetch(`/api/order/sell`, { method: "POST", body: JSON.stringify(payload), credentials: "include" });
if (!res.status.toString().startsWith("2")) {
console.log("Status: " + res.status + ", " + res.statusText);
@ -41,7 +40,7 @@ async function postCfdSellOrderRequest(payload: CfdSellOrderPayload) {
}
export default function App() {
let source = useEventSource({ source: "/api/feed" });
let source = useEventSource({ source: "/api/feed", options: { withCredentials: true } });
const cfds = useLatestEvent<Cfd[]>(source, "cfds");
const order = useLatestEvent<Order>(source, "order");

7
frontend/src/Taker.tsx

@ -1,5 +1,4 @@
import { Box, Button, Center, Flex, HStack, SimpleGrid, StackDivider, Text, useToast, VStack } from "@chakra-ui/react";
import axios from "axios";
import React, { useState } from "react";
import { useAsync } from "react-async";
import { Route, Routes } from "react-router-dom";
@ -28,7 +27,7 @@ interface MarginResponse {
}
async function postCfdTakeRequest(payload: CfdTakeRequestPayload) {
let res = await axios.post(`/api/cfd`, JSON.stringify(payload));
let res = await fetch(`/api/cfd`, { method: "POST", body: JSON.stringify(payload) });
if (!res.status.toString().startsWith("2")) {
throw new Error("failed to create new CFD take request: " + res.status + ", " + res.statusText);
@ -36,13 +35,13 @@ async function postCfdTakeRequest(payload: CfdTakeRequestPayload) {
}
async function getMargin(payload: MarginRequestPayload): Promise<MarginResponse> {
let res = await axios.post(`/api/calculate/margin`, JSON.stringify(payload));
let res = await fetch(`/api/calculate/margin`, { method: "POST", body: JSON.stringify(payload) });
if (!res.status.toString().startsWith("2")) {
throw new Error("failed to create new CFD take request: " + res.status + ", " + res.statusText);
}
return res.data;
return res.json();
}
export default function App() {

9
frontend/yarn.lock

@ -3562,13 +3562,6 @@ axe-core@^4.0.2:
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.3.3.tgz#b55cd8e8ddf659fe89b064680e1c6a4dceab0325"
integrity sha512-/lqqLAmuIPi79WYfRpy2i8z+x+vxU3zX2uAm0gs1q52qTuKwolOj1P8XbufpXcsydrpKx2yGn2wzAnxCMV86QA==
axios@^0.21.1:
version "0.21.4"
resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.4.tgz#c67b90dc0568e5c1cf2b0b858c43ba28e2eda575"
integrity sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==
dependencies:
follow-redirects "^1.14.0"
axobject-query@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be"
@ -6214,7 +6207,7 @@ focus-lock@^0.8.1:
dependencies:
tslib "^1.9.3"
follow-redirects@^1.0.0, follow-redirects@^1.14.0:
follow-redirects@^1.0.0:
version "1.14.3"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.3.tgz#6ada78118d8d24caee595595accdc0ac6abd022e"
integrity sha512-3MkHxknWMUtb23apkgz/83fDoe+y+qr0TdgacGIA7bew+QLBo3vdgEN2xEsuXNivpFy4CyDhBBZnNZOtalmenw==

Loading…
Cancel
Save