Web API, Web UI #86

Merged
projectmoon merged 37 commits from web-api into master 2021-07-15 15:04:54 +00:00
6 changed files with 132 additions and 24 deletions
Showing only changes of commit 5986a3611a - Show all commits

1
Cargo.lock generated
View File

@ -3518,6 +3518,7 @@ dependencies = [
"rocket", "rocket",
"rocket_cors", "rocket_cors",
"serde", "serde",
"serde_json",
"substring", "substring",
"tenebrous-rpc", "tenebrous-rpc",
"thiserror", "thiserror",

View File

@ -14,6 +14,7 @@ substring = "1.4"
jsonwebtoken = "7.2" jsonwebtoken = "7.2"
chrono = "0.4" chrono = "0.4"
serde = {version = "1.0", features = ["derive"] } serde = {version = "1.0", features = ["derive"] }
serde_json = {version = "1.0" }
tenebrous-rpc = { path = "../rpc" } tenebrous-rpc = { path = "../rpc" }
juniper = { git = "https://github.com/graphql-rust/juniper", branch = "master" } juniper = { git = "https://github.com/graphql-rust/juniper", branch = "master" }
juniper_rocket_async = { git = "https://github.com/graphql-rust/juniper", branch = "master" } juniper_rocket_async = { git = "https://github.com/graphql-rust/juniper", branch = "master" }

View File

@ -2,8 +2,8 @@ use crate::config::Config;
use crate::errors::ApiError; use crate::errors::ApiError;
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use rocket::request::{self, FromRequest, Request};
use rocket::response::status::Custom; use rocket::response::status::Custom;
use rocket::{http::SameSite, request::local_cache};
use rocket::{ use rocket::{
http::Status, http::Status,
serde::{json::Json, Deserialize, Serialize}, serde::{json::Json, Deserialize, Serialize},
@ -12,6 +12,10 @@ use rocket::{
http::{Cookie, CookieJar}, http::{Cookie, CookieJar},
outcome::Outcome, outcome::Outcome,
}; };
use rocket::{
outcome::IntoOutcome,
request::{self, FromRequest, Request},
};
use rocket::{routes, Route, State}; use rocket::{routes, Route, State};
use substring::Substring; use substring::Substring;
@ -56,7 +60,7 @@ impl<'r> FromRequest<'r> for User {
} }
pub(crate) fn routes() -> Vec<Route> { pub(crate) fn routes() -> Vec<Route> {
routes![login] routes![login, refresh_token]
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@ -75,7 +79,7 @@ fn create_token<'a>(
username: &str, username: &str,
expiration: Duration, expiration: Duration,
secret: &str, secret: &str,
) -> Result<String, Custom<String>> { ) -> Result<String, ApiError> {
let expiration = Utc::now() let expiration = Utc::now()
.checked_add_signed(expiration) .checked_add_signed(expiration)
.expect("clock went awry") .expect("clock went awry")
@ -90,8 +94,7 @@ fn create_token<'a>(
&Header::default(), &Header::default(),
&claims, &claims,
&EncodingKey::from_secret(secret.as_bytes()), &EncodingKey::from_secret(secret.as_bytes()),
) )?;
.map_err(|e| Custom(Status::InternalServerError, e.to_string()))?;
Ok(token) Ok(token)
} }
@ -101,15 +104,51 @@ struct LoginResponse {
jwt_token: String, jwt_token: String,
} }
/// A strongly-typed representation of the refresh token, used with a
/// FromRequest trait to decode it from the cookie.
struct RefreshToken(String);
#[rocket::async_trait]
impl<'r> FromRequest<'r> for RefreshToken {
type Error = ();
async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
let token: Option<RefreshToken> = request
.cookies()
.get_private("refresh_token")
.and_then(|cookie| cookie.value().parse::<String>().ok())
.map(|t| RefreshToken(t));
token.or_forward(())
}
}
#[rocket::post("/login", data = "<request>")] #[rocket::post("/login", data = "<request>")]
async fn login( async fn login(
request: Json<LoginRequest<'_>>, request: Json<LoginRequest<'_>>,
config: &State<Config>, config: &State<Config>,
cookies: &CookieJar<'_>, cookies: &CookieJar<'_>,
) -> Result<Json<LoginResponse>, Custom<String>> { ) -> Result<Json<LoginResponse>, ApiError> {
let token = create_token(request.username, Duration::minutes(1), &config.jwt_secret)?; let token = create_token(request.username, Duration::minutes(1), &config.jwt_secret)?;
let refresh_token = create_token(request.username, Duration::weeks(1), &config.jwt_secret)?; let refresh_token = create_token(request.username, Duration::weeks(1), &config.jwt_secret)?;
cookies.add_private(Cookie::new("refresh_token", refresh_token)); let mut cookie = Cookie::new("refresh_token", refresh_token);
cookie.set_same_site(SameSite::None);
cookies.add_private(cookie);
Ok(Json(LoginResponse { jwt_token: token }))
}
#[rocket::post("/refresh")]
async fn refresh_token(
config: &State<Config>,
refresh_token: Option<RefreshToken>,
) -> Result<Json<LoginResponse>, ApiError> {
let refresh_token = refresh_token.ok_or(ApiError::RefreshTokenMissing)?;
let refresh_token = decode_token(&refresh_token.0, config)?;
//TODO check if token is valid? maybe decode takes care of it.
let token = create_token(&refresh_token.sub, Duration::minutes(1), &config.jwt_secret)?;
Ok(Json(LoginResponse { jwt_token: token })) Ok(Json(LoginResponse { jwt_token: token }))
} }

View File

@ -1,3 +1,7 @@
use rocket::http::ContentType;
use rocket::response::{self, Responder, Response};
use rocket::{http::Status, request::Request};
use std::io::Cursor;
use thiserror::Error; use thiserror::Error;
#[derive(Error, Debug)] #[derive(Error, Debug)]
@ -11,6 +15,34 @@ pub enum ApiError {
#[error("authentication token missing from request")] #[error("authentication token missing from request")]
AuthenticationRequired, AuthenticationRequired,
#[error("refresh token missing from request")]
RefreshTokenMissing,
#[error("error decoding token: {0}")] #[error("error decoding token: {0}")]
TokenDecodingError(#[from] jsonwebtoken::errors::Error), TokenDecodingError(#[from] jsonwebtoken::errors::Error),
} }
#[rocket::async_trait]
impl<'r> Responder<'r, 'static> for ApiError {
fn respond_to(self, _: &'r Request<'_>) -> response::Result<'static> {
let status = match self {
Self::UserDoesNotExist(_) => Status::Forbidden,
Self::AuthenticationRequired => Status::Forbidden,
Self::RefreshTokenMissing => Status::Forbidden,
Self::AuthenticationDenied(_) => Status::Forbidden,
Self::TokenDecodingError(_) => Status::InternalServerError,
};
//TODO certain errors might be too sensitive; need to filter them here.
let body = serde_json::json!({
"message": self.to_string()
})
.to_string();
Response::build()
.header(ContentType::JsonApi)
.status(status)
.sized_body(body.len(), Cursor::new(body))
.ok()
}
}

View File

@ -3,17 +3,29 @@ use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture; use wasm_bindgen_futures::JsFuture;
use web_sys::{console, Request, RequestInit, RequestMode, Response}; use web_sys::{console, Request, RequestCredentials, RequestInit, RequestMode, Response};
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
struct LoginResponse { struct LoginResponse {
jwt_token: String, jwt_token: String,
} }
async fn make_request(request: Request) -> Result<JsValue, UiError> {
let window = web_sys::window().unwrap();
let resp_value = JsFuture::from(window.fetch_with_request(&request)).await?;
let resp: Response = resp_value.dyn_into().unwrap();
let json = JsFuture::from(resp.json()?).await?;
Ok(json)
}
pub async fn fetch_jwt() -> Result<String, UiError> { pub async fn fetch_jwt() -> Result<String, UiError> {
let mut opts = RequestInit::new(); let mut opts = RequestInit::new();
opts.method("POST"); opts.method("POST");
opts.mode(RequestMode::Cors); opts.mode(RequestMode::Cors);
opts.credentials(RequestCredentials::Include);
opts.body(Some(&JsValue::from_str( opts.body(Some(&JsValue::from_str(
r#" r#"
{ "username": "@projectmoon:agnos.is", "password": "lolol" } { "username": "@projectmoon:agnos.is", "password": "lolol" }
@ -26,21 +38,29 @@ pub async fn fetch_jwt() -> Result<String, UiError> {
request.headers().set("Content-Type", "application/json")?; request.headers().set("Content-Type", "application/json")?;
request.headers().set("Accept", "application/json")?; request.headers().set("Accept", "application/json")?;
let window = web_sys::window().unwrap(); //TODO don't unwrap the response. OR... change it so we have a standard response.
let resp_value = JsFuture::from(window.fetch_with_request(&request)).await?; let response = make_request(request).await?;
let response: LoginResponse = response.into_serde().unwrap();
// `resp_value` is a `Response` object. Ok(response.jwt_token)
assert!(resp_value.is_instance_of::<Response>()); }
let resp: Response = resp_value.dyn_into().unwrap();
pub async fn refresh_jwt() -> Result<String, UiError> {
// Convert this other `Promise` into a rust `Future`. let mut opts = RequestInit::new();
let json = JsFuture::from(resp.json()?).await?; opts.method("POST");
opts.mode(RequestMode::Cors);
console::log_1(&json); opts.credentials(RequestCredentials::Include);
// Use serde to parse the JSON into a struct. let url = format!("http://localhost:10000/refresh");
let login_response: LoginResponse = json.into_serde().unwrap();
let request = Request::new_with_str_and_init(&url, &opts)?;
// Send the `Branch` struct back to JS as an `Object`. request.headers().set("Content-Type", "application/json")?;
Ok(login_response.jwt_token) request.headers().set("Accept", "application/json")?;
//TODO don't unwrap the response. OR... change it so we have a standard response.
let response = make_request(request).await?;
console::log_1(&response);
let response: LoginResponse = response.into_serde().unwrap();
Ok(response.jwt_token)
} }

View File

@ -50,6 +50,12 @@ async fn do_jwt_stuff(dispatch: &WebUiDispatcher) -> Result<(), UiError> {
Ok(()) Ok(())
} }
async fn do_refresh_jwt(dispatch: &WebUiDispatcher) -> Result<(), UiError> {
let jwt = api::auth::refresh_jwt().await?;
dispatch.send(Action::UpdateJwt(jwt));
Ok(())
}
impl Component for YewduxRoomList { impl Component for YewduxRoomList {
type Message = (); type Message = ();
type Properties = WebUiDispatcher; type Properties = WebUiDispatcher;
@ -84,6 +90,7 @@ impl Component for YewduxRoomList {
fn view(&self) -> Html { fn view(&self) -> Html {
let dispatch = Arc::new(self.dispatch.clone()); let dispatch = Arc::new(self.dispatch.clone());
let dispatch2 = dispatch.clone(); let dispatch2 = dispatch.clone();
let dispatch3 = dispatch.clone();
let the_future = self.link.callback(move |_| { let the_future = self.link.callback(move |_| {
let dispatch = dispatch.clone(); let dispatch = dispatch.clone();
@ -105,10 +112,18 @@ impl Component for YewduxRoomList {
}); });
}); });
let refresh_jwt = self.link.callback(move |_| {
let dispatch = dispatch3.clone();
spawn_local(async move {
do_refresh_jwt(&*dispatch).await;
});
});
html! { html! {
<div> <div>
<button onclick=the_future>{ "Add Room" }</button> <button onclick=the_future>{ "Add Room" }</button>
<button onclick=jwt_update>{ "Fetch JWT" }</button> <button onclick=jwt_update>{ "Fetch JWT" }</button>
<button onclick=refresh_jwt>{ "Refresh JWT" }</button>
<div> { "Current JWT: " } { self.dispatch.state().jwt_token.as_ref().unwrap_or(&"[not set]".to_string()) }</div> <div> { "Current JWT: " } { self.dispatch.state().jwt_token.as_ref().unwrap_or(&"[not set]".to_string()) }</div>
<ul> <ul>
{ {