Web API, Web UI #86
|
@ -3518,6 +3518,7 @@ dependencies = [
|
||||||
"rocket",
|
"rocket",
|
||||||
"rocket_cors",
|
"rocket_cors",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"substring",
|
"substring",
|
||||||
"tenebrous-rpc",
|
"tenebrous-rpc",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
|
|
|
@ -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" }
|
||||||
|
|
|
@ -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 }))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
{
|
{
|
||||||
|
|
Loading…
Reference in New Issue