diff --git a/Cargo.lock b/Cargo.lock index 1c2f50b8d..4355f5eba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -987,6 +987,7 @@ name = "frontend" version = "3.1.5" dependencies = [ "anyhow", + "base64", "bytes", "chrono", "futures", @@ -997,7 +998,6 @@ dependencies = [ "implicit-clone", "js-sys", "log", - "paste", "prost", "regex", "reqwasm", diff --git a/backend/src/api/api_utils.rs b/backend/src/api/api_utils.rs index 0314002db..74f6db50e 100644 --- a/backend/src/api/api_utils.rs +++ b/backend/src/api/api_utils.rs @@ -9,7 +9,6 @@ use crate::api::model::{ ProviderStreamFactoryOptions, ProviderStreamInfo, ProviderStreamResponse, SharedStreamManager, StreamError, ThrottledStream, UserApiRequest, }; -use crate::auth::Claims; use crate::model::ConfigInput; use crate::model::{ConfigTarget, ProxyUserCredentials}; use crate::tools::atomic_once_flag::AtomicOnceFlag; @@ -25,10 +24,7 @@ use chrono::{DateTime, Utc}; use futures::{StreamExt, TryStreamExt}; use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; use log::{debug, error, log_enabled, trace}; -use shared::model::{ - InputFetchMethod, PlaylistEntry, PlaylistItemType, TargetType, UserConnectionPermission, - XtreamCluster, -}; +use shared::model::{Claims, InputFetchMethod, PlaylistEntry, PlaylistItemType, TargetType, UserConnectionPermission, XtreamCluster}; use shared::utils::{default_grace_period_millis, human_readable_byte_size, trim_slash}; use shared::utils::{ extract_extension_from_url, replace_url_extension, sanitize_sensitive_info, DASH_EXT, HLS_EXT, diff --git a/backend/src/api/endpoints/web_index.rs b/backend/src/api/endpoints/web_index.rs index a86a6ba5e..a855537b6 100644 --- a/backend/src/api/endpoints/web_index.rs +++ b/backend/src/api/endpoints/web_index.rs @@ -5,7 +5,7 @@ use crate::auth::{create_jwt_admin, create_jwt_user, is_admin, verify_password, use axum::response::IntoResponse; use log::error; use serde_json::json; -use shared::model::{TokenResponse, UserCredential}; +use shared::model::{TokenResponse, UserCredential, TOKEN_NO_AUTH}; use shared::utils::{concat_path_leading_slash, CONSTANTS}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -21,7 +21,7 @@ use lol_html::{element, RewriteStrSettings}; fn no_web_auth_token() -> impl axum::response::IntoResponse + Send { axum::Json(TokenResponse { - token: "authorized".to_string(), + token: TOKEN_NO_AUTH.to_string(), username: "admin".to_string(), }).into_response() } diff --git a/backend/src/auth/authenticator.rs b/backend/src/auth/authenticator.rs index d710dff3b..f149d5878 100644 --- a/backend/src/auth/authenticator.rs +++ b/backend/src/auth/authenticator.rs @@ -6,18 +6,7 @@ use crate::model::WebAuthConfig; use crate::api::model::AppState; use crate::auth::AuthBearer; use shared::error::to_io_error; - -const ROLE_ADMIN: &str = "ADMIN"; -const ROLE_USER: &str = "USER"; - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct Claims { - pub(crate) username: String, - iss: String, - iat: i64, - exp: i64, - roles: Vec, -} +use shared::model::{Claims, ROLE_ADMIN, ROLE_USER}; pub fn create_jwt_admin(web_auth_config: &WebAuthConfig, username: &str) -> Result { create_jwt(web_auth_config, username, vec![ROLE_ADMIN.to_string()]) diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index 05911ca08..a25669113 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -28,7 +28,7 @@ prost = "0" wasm-bindgen-futures = "0" bytes = "1" regex = "1.11.1" -paste = "1.0.15" +base64 = "0.22.1" [dependencies.web-sys] version = "0.3" diff --git a/frontend/public/config.json b/frontend/public/config.json index 138d5b0b7..2a055c157 100644 --- a/frontend/public/config.json +++ b/frontend/public/config.json @@ -1,5 +1,6 @@ { - "tabTitle": "tuliprox", + "appTitle": "Tuliprox", + "tabTitle": "Tuliprox", "appLogo": "/assets/tuliprox-logo.svg", "api": { "apiUrl": "/api/v1/", diff --git a/frontend/scss/app/_component.scss b/frontend/scss/app/_component.scss index f6cedb9fc..8ef6a2b29 100644 --- a/frontend/scss/app/_component.scss +++ b/frontend/scss/app/_component.scss @@ -67,4 +67,5 @@ @forward "components/config/config_view"; @forward "components/tabset"; @forward "components/select"; -@forward "components/form"; \ No newline at end of file +@forward "components/form"; +@forward "components/api_user/api_user_view"; \ No newline at end of file diff --git a/frontend/scss/app/components/api_user/_api_user_view.scss b/frontend/scss/app/components/api_user/_api_user_view.scss new file mode 100644 index 000000000..f62e230b6 --- /dev/null +++ b/frontend/scss/app/components/api_user/_api_user_view.scss @@ -0,0 +1,3 @@ +div { + color: var(--text-color); +} \ No newline at end of file diff --git a/frontend/src/app/components/api_user/api_user_view.rs b/frontend/src/app/components/api_user/api_user_view.rs new file mode 100644 index 000000000..0d76a9866 --- /dev/null +++ b/frontend/src/app/components/api_user/api_user_view.rs @@ -0,0 +1,44 @@ +use yew::{function_component, html, Callback, Html}; +use crate::app::components::loading_indicator::BusyIndicator; +use crate::app::components::{IconButton, ToastrView}; +use crate::hooks::use_service_context; +use crate::provider::DialogProvider; + +#[function_component] +pub fn ApiUserView() -> Html { + let services = use_service_context(); + + let handle_logout = { + let services_ctx = services.clone(); + Callback::from(move |_| services_ctx.auth.logout()) + }; + + html! { + + +
+ + +
+
+
+ { + if let Some(ref title) = services.config.ui_config.app_title { + html! { title.as_str() } + } else { + html! { /* */ } + } + } +
+
+ +
+
+
+ {" TODO "} +
+
+
+
+ } +} \ No newline at end of file diff --git a/frontend/src/app/components/api_user/mod.rs b/frontend/src/app/components/api_user/mod.rs new file mode 100644 index 000000000..13e695953 --- /dev/null +++ b/frontend/src/app/components/api_user/mod.rs @@ -0,0 +1,3 @@ +mod api_user_view; + +pub use api_user_view::*; \ No newline at end of file diff --git a/frontend/src/app/components/login.rs b/frontend/src/app/components/login.rs index cd6a1820e..4b80eaf81 100644 --- a/frontend/src/app/components/login.rs +++ b/frontend/src/app/components/login.rs @@ -61,6 +61,7 @@ pub fn Login() -> Html { let login = do_login.clone(); Callback::from(move |e: KeyboardEvent| { if e.key() == "Enter" { + e.prevent_default(); login.emit(()); } }) diff --git a/frontend/src/app/components/mod.rs b/frontend/src/app/components/mod.rs index 88bdd4945..3a98b3a2b 100644 --- a/frontend/src/app/components/mod.rs +++ b/frontend/src/app/components/mod.rs @@ -38,6 +38,8 @@ mod tabset; mod select; mod number_input; mod date_input; +mod role_based_content; +mod api_user; // pub use self::input::*; // pub use self::menu_item::*; // pub use self::popup_menu::*; @@ -74,5 +76,6 @@ pub use self::accordion::*; pub use self::accordion_panel::*; pub use self::csv_table::*; pub use self::tabset::*; +pub use self::role_based_content::*; //pub use self::number_input::*; //pub use self::date_input::*; \ No newline at end of file diff --git a/frontend/src/app/components/role_based_content.rs b/frontend/src/app/components/role_based_content.rs new file mode 100644 index 000000000..2a3d85d08 --- /dev/null +++ b/frontend/src/app/components/role_based_content.rs @@ -0,0 +1,18 @@ +use yew::prelude::*; +use yew_router::Switch; +use crate::app::{switch, AppRoute}; +use crate::app::components::api_user::ApiUserView; +use crate::hooks::use_service_context; + +#[function_component] +pub fn RoleBasedContent() -> Html { + let services = use_service_context(); + + if services.auth.is_admin() { + html! { render={switch} /> } + } else if services.auth.is_user() { + html! { } + } else { + html! { "Not authorized" } + } +} \ No newline at end of file diff --git a/frontend/src/app/mod.rs b/frontend/src/app/mod.rs index 0cfd02cf1..45873e680 100644 --- a/frontend/src/app/mod.rs +++ b/frontend/src/app/mod.rs @@ -6,13 +6,14 @@ use std::rc::Rc; use futures::future::join_all; use log::error; use serde_json::Value; +use web_sys::window; use crate::provider::IconContextProvider; use crate::provider::ServiceContextProvider; use yew_i18n::I18nProvider; use yew::prelude::*; use yew_hooks::{use_async_with_options, UseAsyncOptions}; use yew_router::prelude::*; -use crate::app::components::{Authentication, Home, Login}; +use crate::app::components::{Authentication, Home, Login, RoleBasedContent}; use crate::error::Error; use crate::hooks::{IconDefinition}; use crate::model::WebConfig; @@ -101,8 +102,17 @@ pub fn App() -> Html { { let config_state = configuration_state.clone(); use_async_with_options::<_, (), Error>(async move { - match request_get("config.json", None, None).await { - Ok(cfg) => config_state.set(Some(cfg)), + match request_get::("config.json", None, None).await { + Ok(cfg) => { + if let Some(tab_title) = cfg.tab_title.as_deref() { + if let Some(win) = window() { + if let Some(doc) = win.document() { + doc.set_title(tab_title); + } + } + } + config_state.set(Some(cfg)); + }, Err(err) => error!("Failed to load config {err}"), } Ok(()) @@ -134,7 +144,7 @@ pub fn App() -> Html { - render={switch} /> + diff --git a/frontend/src/hooks/use_service_context.rs b/frontend/src/hooks/use_service_context.rs index 45098b4a4..17ffa6ea3 100644 --- a/frontend/src/hooks/use_service_context.rs +++ b/frontend/src/hooks/use_service_context.rs @@ -1,7 +1,8 @@ use std::rc::Rc; use yew::prelude::*; use crate::model::WebConfig; -use crate::services::{AuthService, ConfigService, EventService, PlaylistService, StatusService, ToastrService, UserService, WebSocketService}; +use crate::services::{AuthService, ConfigService, EventService, PlaylistService, StatusService, ToastrService, + UserService, WebSocketService}; pub struct Services { pub auth: Rc, @@ -15,9 +16,9 @@ pub struct Services { } impl Services { - pub fn new(config: &WebConfig) -> Self { + pub fn new(web_config: &WebConfig) -> Self { + let config = Rc::new(ConfigService::new(web_config)); let auth = Rc::new(AuthService::new()); - let config = Rc::new(ConfigService::new(config)); let status = Rc::new(StatusService::new()); let event = Rc::new(EventService::new()); let playlist = Rc::new(PlaylistService::new()); diff --git a/frontend/src/services/auth_service.rs b/frontend/src/services/auth_service.rs index 1da5815cd..025cf1a4d 100644 --- a/frontend/src/services/auth_service.rs +++ b/frontend/src/services/auth_service.rs @@ -1,17 +1,25 @@ use std::cell::RefCell; -use super::{get_base_href, request_post}; +use super::{get_base_href, request_post, ConfigService}; use crate::error::Error; use crate::services::requests::set_token; use futures_signals::signal::Mutable; use futures_signals::signal::SignalExt; -use shared::model::{TokenResponse, UserCredential}; +use shared::model::{Claims, TokenResponse, UserCredential, ROLE_ADMIN, ROLE_USER, TOKEN_NO_AUTH}; use std::future::Future; use shared::utils::{concat_path, concat_path_leading_slash}; +use base64::{engine::general_purpose, Engine as _}; +use log::warn; + +fn decode_jwt_payload(token: &str) -> Option { + let payload_enc = token.split('.').nth(1)?; + let payload_bytes = general_purpose::URL_SAFE_NO_PAD.decode(payload_enc).ok()?; + serde_json::from_slice::(&payload_bytes).ok() +} -#[derive(Debug)] pub struct AuthService { auth_path: String, username: RefCell, + roles: RefCell>, auth_channel: Mutable, } @@ -22,12 +30,24 @@ impl AuthService { auth_path: concat_path_leading_slash(&base_href, "auth"), username: RefCell::new(String::new()), auth_channel: Mutable::new(false), + roles: RefCell::new(vec![]), } } pub fn get_username(&self) -> String { self.username.borrow().to_string() } + pub fn is_admin(&self) -> bool { + self.roles.borrow().iter().any(|r| r == ROLE_ADMIN) + } + + pub fn is_user(&self) -> bool { + self.roles.borrow().iter().any(|r| r == ROLE_USER) + } + + pub fn is_authenticated(&self) -> bool { + self.auth_channel.get() + } pub async fn auth_subscribe(&self, callback: &mut F) where @@ -54,6 +74,7 @@ impl AuthService { self.username.replace(token.username.to_string()); self.auth_channel.set(true); set_token(Some(&token.token)); + self.handle_token(&token.token); Ok(token) } Err(e) => { @@ -71,6 +92,7 @@ impl AuthService { self.username.replace(token.username.to_string()); self.auth_channel.set(true); set_token(Some(&token.token)); + self.handle_token(&token.token); Ok(token) } Err(e) => { @@ -81,10 +103,21 @@ impl AuthService { } } } -} -impl Default for AuthService { - fn default() -> Self { - Self::new() + fn handle_token(&self, token: &str) { + let mut roles = self.roles.borrow_mut(); + roles.clear(); + + if token == TOKEN_NO_AUTH { + roles.push(ROLE_ADMIN.to_string()); + } + + if let Some(claims) = decode_jwt_payload(token) { + for role in claims.roles.iter() { + roles.push(role.clone()); + } + } else { + warn!("no claims"); + } } } diff --git a/shared/src/model/auth/user.rs b/shared/src/model/auth/user.rs index b5e12d717..a9340480f 100644 --- a/shared/src/model/auth/user.rs +++ b/shared/src/model/auth/user.rs @@ -1,5 +1,19 @@ use zeroize::Zeroize; +pub const TOKEN_NO_AUTH: &str = "authorized"; + +pub const ROLE_ADMIN: &str = "ADMIN"; +pub const ROLE_USER: &str = "USER"; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct Claims { + pub username: String, + pub iss: String, + pub iat: i64, + pub exp: i64, + pub roles: Vec, +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct UserCredential { pub username: String,