diff --git a/CHANGELOG.md b/CHANGELOG.md index 152637ef9..bfe2c1c92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,12 @@ * Mapper can now set `epg_channel_id`. * Added environment variables for User Credentials `username`, `password` and `token` in format `${env:}` where `` should be replaced. * Added `web_ui_enabled` to `config.yml`. Default is `true`. Set to `false` to disable webui. -* Added `web_auth_enabled` to `config.yml`. Default is `false`. Set to `true` to enable webui authentication. +* Added `web_auth` to `config.yml` struct for web-ui-authentication is optional. + - `enabled`: default true + - `issuer` issuer for jwt token + - `secret` secret for jwt token + - `userfile` userfile with generated userfile in format "username: password" per file +* Password generation argument --genpwd to generate passwords for userfile. # v1.1.8(2024-03-06) * Fixed WebUI Option-Select diff --git a/Cargo.lock b/Cargo.lock index 0099dad37..c68c99133 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1466,8 +1466,10 @@ dependencies = [ "pest_derive", "petgraph", "quick-xml", + "rand", "regex", "reqwest", + "rpassword", "rust-argon2", "rustelebot", "serde", @@ -2021,6 +2023,27 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rpassword" +version = "7.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80472be3c897911d0137b2d2b9055faf6eeac5b14e324073d83bc17b191d7e3f" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.48.0", +] + +[[package]] +name = "rtoolbox" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c247d24e63230cdb56463ae328478bd5eac8b8faa8c69461a77e8e323afac90e" +dependencies = [ + "libc", + "windows-sys 0.48.0", +] + [[package]] name = "rust-argon2" version = "2.1.0" diff --git a/Cargo.toml b/Cargo.toml index 09cd88596..5386e81e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,3 +47,5 @@ bincode = "1.3" uuid = { version = "1.8", features = ["v4", "fast-rng", "macro-diagnostics"] } lzma-rs = "0.3" linereader = "0" +rand = "0.8" +rpassword = "7.3" diff --git a/frontend/public/index.html b/frontend/public/index.html index 8673846a2..d10572e34 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -3,44 +3,10 @@ - - - - - - - - - - - - - - - - - - - - - + m3u-filter diff --git a/frontend/src/api/api-service.ts b/frontend/src/api/api-service.ts index aa78c5e65..c8f0ef6fc 100644 --- a/frontend/src/api/api-service.ts +++ b/frontend/src/api/api-service.ts @@ -1,10 +1,12 @@ import {Observable} from "rxjs"; import axios from "axios"; import config from "../config"; +import ServiceContext from "../service/service-context"; const HEADER_CONTENT_TYPE = 'Content-Type'; const HEADER_LANGUAGE = 'X-Language'; const HEADER_ACCEPT = 'Accept'; +const HEADER_AUTHORIZATION = 'Authorization'; export default interface ApiService { get(query: string, url?: string): Observable; @@ -53,6 +55,10 @@ export class DefaultApiService implements ApiService { headers[HEADER_CONTENT_TYPE] = value; } headers[HEADER_ACCEPT] = 'application/json'; + const token = ServiceContext.auth().getToken(); + if (token) { + headers[HEADER_AUTHORIZATION] = 'Bearer ' + token; + } return headers; } diff --git a/frontend/src/api/auth-api-service.ts b/frontend/src/api/auth-api-service.ts new file mode 100644 index 000000000..71ef1d946 --- /dev/null +++ b/frontend/src/api/auth-api-service.ts @@ -0,0 +1,22 @@ +import ApiService, {DefaultApiService} from "./api-service"; +import {Observable} from "rxjs"; + +const AUTH_API_PATH = window.location + 'auth'; + +type TokenResponse = { token: string }; + +export default interface AuthApiService extends ApiService { + authenticate(username: string, password: string): Observable; + + refresh(): Observable +} + +export class DefaultAuthApiService extends DefaultApiService implements AuthApiService { + authenticate(username: string, password: string): Observable<{ token: string }> { + return this.post('/token', {username, password}, AUTH_API_PATH); + } + + refresh(): Observable { + return this.post('/refresh', {}, AUTH_API_PATH); + } +} diff --git a/frontend/src/app/app.tsx b/frontend/src/app/app.tsx index 1c458c3fb..fe03a0d24 100755 --- a/frontend/src/app/app.tsx +++ b/frontend/src/app/app.tsx @@ -96,7 +96,7 @@ export default function App(props: AppProps) { } else { enqueueSnackbar("Invalid filetype!", {variant: 'error'}) } - }, [clipboardChannel, serverConfig]); + }, [serverConfig, enqueueSnackbar, services]); const handleOnWebSearch = useCallback((playlistItem: PlaylistItem): void => { if (playlistItem) { diff --git a/frontend/src/component/authentication/authentication.tsx b/frontend/src/component/authentication/authentication.tsx new file mode 100644 index 000000000..1c13988ac --- /dev/null +++ b/frontend/src/component/authentication/authentication.tsx @@ -0,0 +1,21 @@ +import React, {useEffect, useState} from 'react'; +import App from "../../app/app"; +import Login from "../login/login"; +import {useServices} from "../../provider/service-provider"; + +export default function Authentication(): JSX.Element { + + const services = useServices(); + const [authenticated, setAuthenticated] = useState(false); + + useEffect(() => { + const sub = services.auth().authChannel().subscribe({ + next: (auth) => setAuthenticated(auth), + error: () => setAuthenticated(false), + }) + return () => sub.unsubscribe(); + }, [services]); + + return <>{authenticated ? : } + +} \ No newline at end of file diff --git a/frontend/src/component/login/login.scss b/frontend/src/component/login/login.scss new file mode 100644 index 000000000..17cae1ada --- /dev/null +++ b/frontend/src/component/login/login.scss @@ -0,0 +1,43 @@ +@use '../../scss/theme'; + +.login { + display: flex; + flex-flow: column; + gap: 8px; + margin: 20% auto auto auto; + border: 1px solid var(--border-color); + border-top-left-radius: 6px; + border-top-right-radius: 6px; + overflow: hidden; + + .title { + background-color: var(--app-header-color); + padding: 8px; + } + + &__form { + display: flex; + flex-flow: column; + gap: 20px; + padding: 8px; + width: 300px; + + input { + height: 2rem; + border: none; + font-size: 1.4rem; + background-color: var(--card-background-color); + padding: 4px 8px; + } + + button { + height: 2rem; + border: none; + font-size: 1.4rem; + background-color: var(--text-button-background-color); + color: var(--text-button-color); + padding: 4px 8px; + border-radius: 6px; + } + } +} \ No newline at end of file diff --git a/frontend/src/component/login/login.tsx b/frontend/src/component/login/login.tsx new file mode 100644 index 000000000..f6ff8f0bd --- /dev/null +++ b/frontend/src/component/login/login.tsx @@ -0,0 +1,50 @@ +import React, {useCallback, useRef, useState} from 'react'; +import './login.scss'; +import {useServices} from "../../provider/service-provider"; +import {first} from "rxjs/operators"; + +const checkUserPwd = (username: string, password: string) => username.trim().length > 0 && password.trim().length > 8; + +export default function Login(): JSX.Element { + + const usernameRef = useRef(); + const passwordRef = useRef(); + const services = useServices(); + const [authorized, setAuthorized] = useState(false); + + const handleLogin = useCallback(() => { + const username = usernameRef.current.value; + const password = passwordRef.current.value; + services.auth().authenticate(username, password).pipe(first()).subscribe({ + next: (auth) => { + setAuthorized(auth); + }, + error: () => { + setAuthorized(false); + } + }); + }, [services]); + + const handleKeyDown = useCallback((event: any) => { + if (event.key === 'Enter') { + if (checkUserPwd(usernameRef.current.value, passwordRef.current.value)) { + handleLogin(); + } + } + }, [handleLogin]); + + + return
+
Login to m3u-filter
+
+
+ + + + Failed to login +
+
+
+ +} \ No newline at end of file diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 9fd194b8d..542cd1cfb 100755 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,15 +1,16 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import './index.scss'; -import App from './app/app'; import {SnackbarProvider} from 'notistack'; import {ServiceProvider} from "./provider/service-provider"; +import Authentication from "./component/authentication/authentication"; const container = document.getElementById('root'); const root = createRoot(container); -root.render( +root.render( + - + ); diff --git a/frontend/src/service/auth-service.ts b/frontend/src/service/auth-service.ts new file mode 100644 index 000000000..cc64cd602 --- /dev/null +++ b/frontend/src/service/auth-service.ts @@ -0,0 +1,36 @@ +import {catchError, map, Observable, ReplaySubject, tap, throwError} from "rxjs"; +import AuthApiService, {DefaultAuthApiService} from "../api/auth-api-service"; + +export default class AuthService { + + private token: string; + private subject = new ReplaySubject(1); + + constructor(private authApiService: AuthApiService = new DefaultAuthApiService()) { + this.subject.next(false); + } + + authChannel(): Observable { + return this.subject; + } + + authenticate(username: string, password: string): Observable { + return this.authApiService.authenticate(username, password).pipe(map(auth => { + this.token = auth.token; + return auth.token != null + }), tap(data => { + this.subject.next(data); + }), catchError((error:any) => { + this.subject.next(false); + return throwError(() => error) + })); + } + + isAuthenticated(): boolean { + return this.token != null; + } + + getToken(): string { + return this.token; + } +} \ No newline at end of file diff --git a/frontend/src/service/service-context.ts b/frontend/src/service/service-context.ts index 407994a79..646478c6f 100644 --- a/frontend/src/service/service-context.ts +++ b/frontend/src/service/service-context.ts @@ -1,6 +1,7 @@ import ConfigService from "./config-service"; import PlaylistService from "./playlist-service"; import FileService from "./file-service"; +import AuthService from "./auth-service"; export interface Services { config(): ConfigService; @@ -8,6 +9,8 @@ export interface Services { playlist(): PlaylistService; file(): FileService; + + auth(): AuthService; } class ServiceContextImpl implements Services { @@ -15,6 +18,7 @@ class ServiceContextImpl implements Services { private readonly _configService: ConfigService = new ConfigService(); private readonly _playlistService: PlaylistService = new PlaylistService(); private readonly _fileService: FileService = new FileService(); + private readonly _authService: AuthService = new AuthService(); config() { return this._configService; @@ -27,6 +31,10 @@ class ServiceContextImpl implements Services { file() { return this._fileService; } + + auth() { + return this._authService; + } } const ServiceContext = new ServiceContextImpl(); diff --git a/src/api/api_utils.rs b/src/api/api_utils.rs index ed89f3214..986e367bf 100644 --- a/src/api/api_utils.rs +++ b/src/api/api_utils.rs @@ -5,7 +5,7 @@ use actix_web::{HttpRequest, HttpResponse, web}; use log::{debug, error}; use url::Url; use crate::api::api_model::{AppState, UserApiRequest}; -use crate::model::api_proxy::{ApiProxyServerInfo, UserCredentials}; +use crate::model::api_proxy::{ApiProxyServerInfo, ProxyUserCredentials}; use crate::model::config::{Config, ConfigTarget, ConfigInput}; use crate::utils::request_utils; @@ -23,7 +23,7 @@ pub(crate) async fn serve_file(file_path: &Path, req: &HttpRequest, mime_type: m } pub(crate) fn get_user_target_by_credentials<'a>(username: &str, password: &str, api_req: &'a UserApiRequest, - app_state: &'a web::Data) -> Option<(UserCredentials, &'a ConfigTarget)> { + app_state: &'a web::Data) -> Option<(ProxyUserCredentials, &'a ConfigTarget)> { if !username.is_empty() && !password.is_empty() { app_state.config.get_target_for_user(username, password) } else { @@ -36,13 +36,13 @@ pub(crate) fn get_user_target_by_credentials<'a>(username: &str, password: &str, } } -pub(crate) fn get_user_target<'a>(api_req: &'a UserApiRequest, app_state: &'a web::Data) -> Option<(UserCredentials, &'a ConfigTarget)> { +pub(crate) fn get_user_target<'a>(api_req: &'a UserApiRequest, app_state: &'a web::Data) -> Option<(ProxyUserCredentials, &'a ConfigTarget)> { let username = api_req.username.as_str().trim(); let password = api_req.password.as_str().trim(); get_user_target_by_credentials(username, password, api_req, app_state) } -pub(crate) fn get_user_server_info(cfg: &Config, user: &UserCredentials) -> ApiProxyServerInfo { +pub(crate) fn get_user_server_info(cfg: &Config, user: &ProxyUserCredentials) -> ApiProxyServerInfo { let server_info_list = cfg._api_proxy.read().unwrap().as_ref().unwrap().server.clone(); let server_info_name = match &user.server { Some(server_name) => server_name.as_str(), diff --git a/src/api/main_api.rs b/src/api/main_api.rs index df5aa1c19..ea28c5a1b 100644 --- a/src/api/main_api.rs +++ b/src/api/main_api.rs @@ -5,8 +5,7 @@ use std::sync::{Arc, Mutex, RwLock}; use actix_cors::Cors; use actix_web::{App, HttpServer, web}; -use actix_web::http::StatusCode; -use actix_web::middleware::{ErrorHandlers, Logger}; +use actix_web::middleware::{Logger}; use log::info; use crate::api::api_model::{AppState, DownloadQueue, SharedLocks}; @@ -16,7 +15,6 @@ use crate::api::v1_api::v1_api_register; use crate::api::web_index::index_register; use crate::api::xmltv_api::xmltv_api_register; use crate::api::xtream_api::xtream_api_register; -use crate::auth::authenticator::handle_unauthorized; use crate::model::config::{Config, ProcessTargets}; use crate::processing::playlist_processor; @@ -74,7 +72,6 @@ pub(crate) async fn start_server(cfg: Arc, targets: Arc) if web_ui_enabled { info!("Web root: {:?}", &web_dir_path); } - let web_auth_enabled = cfg.web_auth_enabled; // Web Server HttpServer::new(move || { @@ -87,19 +84,19 @@ pub(crate) async fn start_server(cfg: Arc, targets: Arc) .allow_any_header() .max_age(3600)) .app_data(shared_data.clone()) - .wrap(ErrorHandlers::new().handler(StatusCode::UNAUTHORIZED, handle_unauthorized)) - .configure(|cfg| { + // .wrap(Condition::new(web_auth_enabled, ErrorHandlers::new().handler(StatusCode::UNAUTHORIZED, handle_unauthorized))) + .configure(|srvcfg| { if web_ui_enabled { - cfg.service(actix_files::Files::new("/static", web_dir_path.join("static"))); - cfg.configure(v1_api_register(web_auth_enabled)); + srvcfg.service(actix_files::Files::new("/static", web_dir_path.join("static"))); + srvcfg.configure(v1_api_register(cfg.web_auth.as_ref().unwrap().clone())); } }) .configure(xtream_api_register) .configure(m3u_api_register) .configure(xmltv_api_register) - .configure(|cfg| { + .configure(|srvcfg| { if web_ui_enabled { - cfg.configure(index_register(&web_dir_path, web_auth_enabled)); + srvcfg.configure(index_register(&web_dir_path, cfg.web_auth.as_ref().unwrap().clone())); } }) }).bind(format!("{}:{}", host, port))?.run().await diff --git a/src/api/v1_api.rs b/src/api/v1_api.rs index a950024ac..2bd533e78 100644 --- a/src/api/v1_api.rs +++ b/src/api/v1_api.rs @@ -11,7 +11,7 @@ use crate::api::download_api; use crate::auth::authenticator::validator; use crate::m3u_filter_error::M3uFilterError; use crate::model::api_proxy::{ApiProxyConfig, ApiProxyServerInfo, TargetUser}; -use crate::model::config::{Config, ConfigDto, ConfigInput, ConfigInputOptions, ConfigSource, ConfigTarget, InputType, validate_targets}; +use crate::model::config::{Config, ConfigDto, ConfigInput, ConfigInputOptions, ConfigSource, ConfigTarget, InputType, validate_targets, WebAuthConfig}; use crate::processing::playlist_processor; use crate::utils::{config_reader, download}; @@ -212,7 +212,7 @@ pub(crate) async fn config( app_state.config._config_file_path.as_str(), app_state.config._sources_file_path.as_str()) { Ok(mut cfg) => { - let _ = cfg.prepare(); + let _ = cfg.prepare(true); map_config(&cfg) } Err(_) => map_config(&app_state.config) @@ -226,7 +226,8 @@ pub(crate) async fn config( HttpResponse::Ok().json(result) } -pub(crate) fn v1_api_register(web_auth_enabled: bool) -> impl Fn(&mut web::ServiceConfig) -> () { +pub(crate) fn v1_api_register(web_auth_config: WebAuthConfig) -> impl Fn(&mut web::ServiceConfig) -> () { + let web_auth_enabled = web_auth_config.enabled; return move |cfg: &mut web::ServiceConfig| { cfg.service(web::scope("/api/v1") .wrap(Condition::new(web_auth_enabled, HttpAuthentication::with_fn(validator))) diff --git a/src/api/web_index.rs b/src/api/web_index.rs index fc19864de..f838ca969 100644 --- a/src/api/web_index.rs +++ b/src/api/web_index.rs @@ -1,27 +1,71 @@ +use std::collections::HashMap; use std::path::PathBuf; use actix_files::NamedFile; -use actix_web::{HttpRequest, web}; -use actix_web::middleware::Condition; -use actix_web_httpauth::middleware::HttpAuthentication; +use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web_httpauth::extractors::bearer::BearerAuth; use crate::api::api_model::AppState; -use crate::auth::authenticator::validator; +use crate::auth::authenticator::{create_jwt, verify_token}; +use crate::auth::password::verify_password; +use crate::auth::user::UserCredential; +use crate::model::config::WebAuthConfig; -async fn index( +async fn token( + mut req: web::Json, + app_state: web::Data, +) -> HttpResponse { + let username = req.username.as_str(); + let password = req.password.as_str(); + + if username.len() > 0 && password.len() > 0 { + let web_auth = app_state.config.web_auth.as_ref().unwrap(); + if let Some(hash) = web_auth.get_user_password(username) { + if verify_password(hash, &password.as_bytes()) { + req.zeroize(); + if let Ok(token) = create_jwt(web_auth) { + return HttpResponse::Ok().json(HashMap::from([("token", token)])); + } + }; + } + } + req.zeroize(); + HttpResponse::BadRequest().finish() +} + +async fn token_refresh( _req: HttpRequest, - _app_state: web::Data, -) -> std::io::Result { - let path: PathBuf = [&_app_state.config.api.web_root, "index.html"].iter().collect(); - NamedFile::open(path) + credentials: Option, + app_state: web::Data, +) -> HttpResponse { + let secret_key = app_state.config.web_auth.as_ref().unwrap().secret.as_ref(); + if verify_token(credentials, secret_key) { + if let Ok(token) = create_jwt(app_state.config.web_auth.as_ref().unwrap()) { + return HttpResponse::Ok().json(HashMap::from([("token", token)])); + } + } + HttpResponse::BadRequest().finish() } -pub(crate) fn index_register(web_dir_path: &PathBuf, web_auth_enabled: bool) -> impl Fn(&mut web::ServiceConfig) -> () { +async fn index( + _req: HttpRequest, + app_state: web::Data, +) -> std::io::Result { + let path: PathBuf = [&app_state.config.api.web_root, "index.html"].iter().collect(); + NamedFile::open(path) +} + +pub(crate) fn index_register(web_dir_path: &PathBuf, web_auth_config: WebAuthConfig) -> impl Fn(&mut web::ServiceConfig) -> () { let wdp = web_dir_path.clone(); + let web_auth_enabled = web_auth_config.enabled; return move |cfg: &mut web::ServiceConfig| { + if web_auth_enabled { + cfg.service(web::scope("/auth") + .route("/token", web::post().to(token)) + .route("/refresh", web::post().to(token_refresh))); + } cfg.service(web::scope("/") - .wrap(Condition::new(web_auth_enabled, HttpAuthentication::with_fn(validator))) .route("", web::get().to(index)) .service(actix_files::Files::new("/", &wdp))); }; diff --git a/src/api/xtream_api.rs b/src/api/xtream_api.rs index 0bdcc0bd0..89b626835 100644 --- a/src/api/xtream_api.rs +++ b/src/api/xtream_api.rs @@ -12,7 +12,7 @@ use url::Url; use crate::api::api_model::{AppState, UserApiRequest, XtreamAuthorizationResponse, XtreamServerInfo, XtreamUserInfo}; use crate::api::api_utils::{get_user_server_info, get_user_target, get_user_target_by_credentials, serve_file, stream_response}; -use crate::model::api_proxy::{ProxyType, UserCredentials}; +use crate::model::api_proxy::{ProxyType, ProxyUserCredentials}; use crate::model::config::{Config, ConfigInput, InputType}; use crate::model::config::TargetType; use crate::model::playlist::XtreamCluster; @@ -112,7 +112,7 @@ fn get_xtream_player_api_stream_url(input: &ConfigInput, context: &str, action_p } -fn get_user_info(user: &UserCredentials, cfg: &Config) -> XtreamAuthorizationResponse { +fn get_user_info(user: &ProxyUserCredentials, cfg: &Config) -> XtreamAuthorizationResponse { let server_info = get_user_server_info(cfg, user); let now = Local::now(); @@ -262,7 +262,7 @@ async fn xtream_get_stream_info(app_state: &AppState, target_name: &str, stream_ Err(Error::new(std::io::ErrorKind::Other, format!("Cant find stream with id: {}/{}/{}", target_name, &cluster, stream_id))) } -async fn xtream_get_stream_info_response(app_state: &AppState, user: &UserCredentials, +async fn xtream_get_stream_info_response(app_state: &AppState, user: &ProxyUserCredentials, target_name: &str, stream_id: &str, cluster: &XtreamCluster) -> HttpResponse { match FromStr::from_str(stream_id) { @@ -284,7 +284,7 @@ async fn xtream_get_stream_info_response(app_state: &AppState, user: &UserCreden } } -async fn xtream_get_short_epg(app_state: &AppState, user: &UserCredentials, target_name: &str, stream_id: &str, limit: &str) -> HttpResponse { +async fn xtream_get_short_epg(app_state: &AppState, user: &ProxyUserCredentials, target_name: &str, stream_id: &str, limit: &str) -> HttpResponse { if !stream_id.is_empty() { if let Some(target_input) = app_state.config.get_input_for_target(target_name, &InputType::Xtream) { if let Some(action_url) = get_xtream_player_api_action_url(target_input, "get_short_epg") { diff --git a/src/auth/authenticator.rs b/src/auth/authenticator.rs index 51054de81..707aa1956 100644 --- a/src/auth/authenticator.rs +++ b/src/auth/authenticator.rs @@ -1,23 +1,63 @@ -use actix_web::{dev::ServiceRequest, Error, HttpResponse}; -use actix_web::dev::ServiceResponse; -use actix_web::middleware::ErrorHandlerResponse; +use actix_web::{dev::ServiceRequest, Error, web}; use actix_web_httpauth::extractors::bearer::BearerAuth; -use log::info; +use chrono::{Local, Duration}; +use jsonwebtoken::{Algorithm, DecodingKey, encode, decode, EncodingKey, Header, Validation}; +use crate::api::api_model::AppState; +use crate::model::config::WebAuthConfig; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct Claims { + iss: String, + iat: i64, + exp: i64, +} + +pub(crate) fn create_jwt(web_auth_config: &WebAuthConfig) -> Result { + let mut header = Header::new(Algorithm::HS256); + header.typ = Some("JWT".to_string()); + let now = Local::now(); + let iat = now.timestamp(); + let exp = (now + Duration::minutes(30)).timestamp(); + let claims = Claims { + iss: web_auth_config.issuer.clone(), + iat, + exp, + }; + match encode(&header, &claims, &EncodingKey::from_secret(web_auth_config.secret.as_bytes())) { + Ok(jwt) => Ok(jwt), + Err(err) => Err(std::io::Error::new(std::io::ErrorKind::Other, err.to_string())) + } +} + +pub(crate) fn verify_token(bearer: Option, secret_key: &[u8]) -> bool { + if let Some(auth) = bearer { + let token = auth.token(); + let token_message = decode::(&token, &DecodingKey::from_secret(secret_key), &Validation::new(Algorithm::HS256)); + if let Ok(_) = token_message { + return true; + } + } + false +} pub(crate) async fn validator( req: ServiceRequest, credentials: Option, ) -> Result { - info!("{:?}", credentials); - // Ok(req) - Err((actix_web::error::ErrorUnauthorized("Unauthorized"), req)) + let app_state: &web::Data = req.app_data::>().unwrap(); + let secret_key = app_state.config.web_auth.as_ref().unwrap().secret.as_ref(); + if verify_token(credentials, secret_key) { + Ok(req) + } else { + Err((actix_web::error::ErrorUnauthorized("Unauthorized"), req)) + } } -pub(crate) fn handle_unauthorized(srvres: ServiceResponse) -> actix_web::Result> { - let (req, _) = srvres.into_parts(); - let resp = HttpResponse::TemporaryRedirect().insert_header(("Location", "login")).finish(); - let result = ServiceResponse::new(req, resp) - .map_into_boxed_body() - .map_into_right_body(); - Ok(ErrorHandlerResponse::Response(result)) -} +// pub(crate) fn handle_unauthorized(srvres: ServiceResponse) -> actix_web::Result> { +// let (req, _) = srvres.into_parts(); +// let resp = HttpResponse::TemporaryRedirect().insert_header(("Location", "/auth/login")).finish(); +// let result = ServiceResponse::new(req, resp) +// .map_into_boxed_body() +// .map_into_right_body(); +// Ok(ErrorHandlerResponse::Response(result)) +// } diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 65b088d1a..c07a2228f 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,2 +1,3 @@ pub(crate) mod authenticator; -mod password; \ No newline at end of file +pub(crate) mod password; +pub(crate) mod user; \ No newline at end of file diff --git a/src/auth/password.rs b/src/auth/password.rs index 074fc4471..35dbecafe 100644 --- a/src/auth/password.rs +++ b/src/auth/password.rs @@ -1,24 +1,44 @@ -// use argon2::{ -// password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, -// Argon2, -// }; -// use log::error; -// -// pub async fn hash(password: &[u8]) -> Option { -// let salt = SaltString::generate(&mut OsRng); -// match Argon2::default().hash_password(password, &salt) { -// Ok(pwd) => Some(pwd.to_string()), -// Err(err) => { -// error!("Failed to hash password {}", err); -// None -// } -// } -// } -// -// pub fn verify_password(hash: &str, password: &[u8]) -> bool { -// let parsed_hash = PasswordHash::new(hash)?; -// match Argon2::default().verify_password(password, &parsed_hash) { -// Ok(_) => true, -// Err(_) => false -// } -// } \ No newline at end of file +use std::io::ErrorKind; +use rand::{Rng, distributions::Alphanumeric, rngs::OsRng}; + +fn generate_salt(length: usize) -> String { + let rng = OsRng; + let salt: String = rng + .sample_iter(&Alphanumeric) + .take(length) + .map(char::from) + .collect(); + salt +} + +pub(crate) fn hash(password: &[u8]) -> Option { + let salt = generate_salt(64); + if password.len() > 0 { + let config = argon2::Config::default(); + if let Ok(hash) = argon2::hash_encoded(password, salt.as_bytes(), &config) { + return Some(hash); + } + } + None +} + +pub(crate) fn verify_password(hash: &str, password: &[u8]) -> bool { + if let Ok(valid) = argon2::verify_encoded(hash, password) { + return valid; + } + false +} + + +pub(crate) fn generate_password() -> std::io::Result { + match rpassword::prompt_password("password> ") { + Ok(pwd) => { + match hash(pwd.as_bytes()) { + None => Err(std::io::Error::new(ErrorKind::Other, "Failed to generate hash")), + Some(hash) => Ok(hash), + } + }, + Err(err) => Err(err) + } +} + diff --git a/src/auth/user.rs b/src/auth/user.rs new file mode 100644 index 000000000..0c802e63d --- /dev/null +++ b/src/auth/user.rs @@ -0,0 +1,18 @@ +use std::ptr; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub(crate) struct UserCredential { + pub username: String, + pub password: String, +} + + +impl UserCredential { + pub(crate) fn zeroize(&mut self) { + unsafe { + let password_ptr = self.password.as_mut_ptr(); + let password_len = self.password.len(); + ptr::write_bytes(password_ptr, 0, password_len); + } + } +} diff --git a/src/main.rs b/src/main.rs index b75b60ea8..370717871 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ extern crate env_logger; extern crate pest; #[macro_use] extern crate pest_derive; +extern crate core; use std::sync::Arc; use actix_rt::System; @@ -9,6 +10,7 @@ use actix_rt::System; use clap::Parser; use env_logger::Builder; use log::{error, info, LevelFilter}; +use crate::auth::password::generate_password; use crate::model::config::{Config, ProcessTargets, validate_targets}; use crate::processing::playlist_processor; @@ -31,7 +33,6 @@ mod auth; #[command(version)] #[command(about = "Extended M3U playlist filter", long_about = None)] struct Args { - /// The config directory #[arg(short = 'p', long = "config-path")] config_path: Option, @@ -48,7 +49,6 @@ struct Args { #[arg(short = 'm', long = "mapping")] mapping_file: Option, - /// The target to process #[arg(short = 't', long)] target: Option>, @@ -64,6 +64,11 @@ struct Args { /// log level #[arg(short = 'l', long = "log-level", default_missing_value = "info")] log_level: Option, + + /// log level + #[arg(short = None, long = "genpwd", default_value_t = false, default_missing_value = "true")] + genpwd: bool, + } const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -76,9 +81,20 @@ fn main() { let config_file: String = args.config_file.unwrap_or(file_utils::get_default_config_file_path(&config_path)); let sources_file: String = args.source_file.unwrap_or(file_utils::get_default_sources_file_path(&config_path)); - let mut cfg = config_reader::read_config(config_path.as_str(), config_file.as_str(), sources_file.as_str()).unwrap_or_else(|err| exit!("{}", err)); + if args.genpwd { + match generate_password() { + Ok(pwd) => { + println!("{}", pwd); + } + Err(err) => { + error!("{}", err); + } + } + return; + } + // this does not work // if args.log_level.is_none() { // if let Some(log_level) = &cfg.log_level { @@ -115,8 +131,7 @@ fn start_in_cli_mode(cfg: Arc, targets: Arc) { fn start_in_server_mode(cfg: Arc, targets: Arc) { info!("Server running: http://{}:{}", &cfg.api.host, &cfg.api.port); match api::main_api::start_server(cfg, targets) { - Ok(_) => { - } + Ok(_) => {} Err(e) => { exit!("Can't start server: {}", e); } diff --git a/src/model/api_proxy.rs b/src/model/api_proxy.rs index 13ff303e2..dd96c0f2b 100644 --- a/src/model/api_proxy.rs +++ b/src/model/api_proxy.rs @@ -47,7 +47,7 @@ impl FromStr for ProxyType { } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub(crate) struct UserCredentials { +pub(crate) struct ProxyUserCredentials { pub username: String, pub password: String, pub token: Option, @@ -56,7 +56,7 @@ pub(crate) struct UserCredentials { pub server: Option, } -impl UserCredentials { +impl ProxyUserCredentials { pub fn prepare(&mut self, resolve_var: bool) { if resolve_var { @@ -95,15 +95,15 @@ impl UserCredentials { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub(crate) struct TargetUser { pub target: String, - pub credentials: Vec, + pub credentials: Vec, } impl TargetUser { - pub fn get_target_name(&self, username: &str, password: &str) -> Option<(&UserCredentials, &str)> { + pub fn get_target_name(&self, username: &str, password: &str) -> Option<(&ProxyUserCredentials, &str)> { self.credentials.iter().find(|c| c.matches(username, password)) .map(|credentials| (credentials, self.target.as_str())) } - pub fn get_target_name_by_token(&self, token: &str) -> Option<(&UserCredentials, &str)> { + pub fn get_target_name_by_token(&self, token: &str) -> Option<(&ProxyUserCredentials, &str)> { self.credentials.iter().find(|c| c.matches_token(token)) .map(|credentials| (credentials, self.target.as_str())) } @@ -232,7 +232,7 @@ impl ApiProxyConfig { } } - pub fn get_target_name(&self, username: &str, password: &str) -> Option<(UserCredentials, String)> { + pub fn get_target_name(&self, username: &str, password: &str) -> Option<(ProxyUserCredentials, String)> { for target_user in &self.user { if let Some((credentials, target_name)) = target_user.get_target_name(username, password) { return Some((credentials.clone(), target_name.to_string())); @@ -241,7 +241,7 @@ impl ApiProxyConfig { None } - pub fn get_target_name_by_token(&self, token: &str) -> Option<(UserCredentials, String)> { + pub fn get_target_name_by_token(&self, token: &str) -> Option<(ProxyUserCredentials, String)> { for target_user in &self.user { if let Some((credentials, target_name)) = target_user.get_target_name_by_token(token) { return Some((credentials.clone(), target_name.to_string())); diff --git a/src/model/config.rs b/src/model/config.rs index b3428668e..6ee9cf7df 100644 --- a/src/model/config.rs +++ b/src/model/config.rs @@ -2,20 +2,23 @@ use std::rc::Rc; use enum_iterator::Sequence; use std::borrow::BorrowMut; use std::collections::{HashMap, HashSet}; +use std::fs::File; +use std::io::BufRead; use std::path::PathBuf; use std::str::FromStr; use std::sync::{Arc, RwLock}; use log::{debug, error, warn}; use path_absolutize::*; +use crate::auth::user::UserCredential; use crate::filter::{Filter, get_filter, MockValueProcessor, PatternTemplate, prepare_templates, ValueProvider}; use crate::m3u_filter_error::{M3uFilterError, M3uFilterErrorKind}; use crate::messaging::MsgKind; -use crate::model::api_proxy::{ApiProxyConfig, UserCredentials}; +use crate::model::api_proxy::{ApiProxyConfig, ProxyUserCredentials}; use crate::model::mapping::Mapping; use crate::model::mapping::Mappings; -use crate::utils::file_utils; +use crate::utils::{config_reader, file_utils}; pub(crate) const MAPPER_ATTRIBUTE_FIELDS: &[&str] = &[ "name", "title", "group", "id", "logo", @@ -728,6 +731,67 @@ impl ConfigDto { } } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub(crate) struct WebAuthConfig { + #[serde(default = "default_as_true")] + pub enabled: bool, + pub issuer: String, + pub secret: String, + pub userfile: String, + pub _users: Option>, +} + +impl WebAuthConfig { + pub fn prepare(&mut self, config_path: &str, resolve_var: bool) -> Result<(), M3uFilterError> { + if resolve_var { + self.issuer = config_reader::resolve_env_var(&self.issuer); + self.secret = config_reader::resolve_env_var(&self.secret); + self.userfile = config_reader::resolve_env_var(&self.userfile); + } + + let userfile_path = PathBuf::from(file_utils::get_default_file_path(config_path, &self.userfile)); + if !file_utils::path_exists(&userfile_path) { + return create_m3u_filter_error_result!(M3uFilterErrorKind::Info, "Could not find userfile {:?}", &userfile_path); + } + + if let Ok(file) = File::open(&userfile_path) { + let mut users = vec![]; + let reader = std::io::BufReader::new(file); + for line in reader.lines() { + match line { + Ok(credential) => { + let mut parts = credential.split(':'); + if let (Some(username), Some(password)) = (parts.next(), parts.next()) { + users.push(UserCredential { + username: username.trim().to_string(), + password: password.trim().to_string(), + }); + debug!("Read ui user {}", username); + } + } + Err(_) => {} + } + } + + self._users = Some(users); + } else { + return create_m3u_filter_error_result!(M3uFilterErrorKind::Info, "Could not read userfile {:?}", &userfile_path); + } + Ok(()) + } + + pub fn get_user_password(&self, username: &str) -> Option<&str> { + if let Some(users) = &self._users { + for credential in users { + if credential.username.eq_ignore_ascii_case(username) { + return Some(credential.password.as_str()); + } + } + } + None + } +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub(crate) struct Config { #[serde(default = "default_as_zero")] @@ -743,8 +807,7 @@ pub(crate) struct Config { pub update_on_boot: bool, #[serde(default = "default_as_true")] pub web_ui_enabled: bool, - #[serde(default = "default_as_false")] - pub web_auth_enabled: bool, + pub web_auth: Option, pub messaging: Option, #[serde(skip_serializing, skip_deserializing)] pub _api_proxy: Arc>>, @@ -764,7 +827,7 @@ impl Config { self._api_proxy = Arc::new(RwLock::new(api_proxy)); } - fn _get_target_for_user(&self, user_target: Option<(UserCredentials, String)>) -> Option<(UserCredentials, &ConfigTarget)> { + fn _get_target_for_user(&self, user_target: Option<(ProxyUserCredentials, String)>) -> Option<(ProxyUserCredentials, &ConfigTarget)> { match user_target { Some((user, target_name)) => { for source in &self.sources { @@ -787,7 +850,7 @@ impl Config { None } - pub fn get_target_for_user(&self, username: &str, password: &str) -> Option<(UserCredentials, &ConfigTarget)> { + pub fn get_target_for_user(&self, username: &str, password: &str) -> Option<(ProxyUserCredentials, &ConfigTarget)> { match self._api_proxy.read().unwrap().as_ref() { Some(api_proxy) => { self._get_target_for_user(api_proxy.get_target_name(username, password)) @@ -796,7 +859,7 @@ impl Config { } } - pub fn get_target_for_user_by_token(&self, token: &str) -> Option<(UserCredentials, &ConfigTarget)> { + pub fn get_target_for_user_by_token(&self, token: &str) -> Option<(ProxyUserCredentials, &ConfigTarget)> { match self._api_proxy.read().unwrap().as_ref() { Some(api_proxy) => { self._get_target_for_user(api_proxy.get_target_name_by_token(token)) @@ -836,7 +899,7 @@ impl Config { Ok(()) } - pub fn prepare(&mut self) -> Result<(), M3uFilterError> { + pub fn prepare(&mut self, resolve_var: bool) -> Result<(), M3uFilterError> { self.working_dir = file_utils::get_working_path(&self.working_dir); if self.backup_dir.is_none() { self.backup_dir = Some(PathBuf::from(&self.working_dir).join(".backup").into_os_string().to_string_lossy().to_string()); @@ -902,6 +965,13 @@ impl Config { } } }; + + if let Some(web_auth) = &mut self.web_auth { + if let Err(err) = web_auth.prepare(&self._config_path, resolve_var) { + return Err(err); + } + } + Ok(()) } diff --git a/src/repository/m3u_repository.rs b/src/repository/m3u_repository.rs index 41df264ba..938c6ff4f 100644 --- a/src/repository/m3u_repository.rs +++ b/src/repository/m3u_repository.rs @@ -9,7 +9,7 @@ use log::error; use crate::{create_m3u_filter_error_result}; use crate::api::api_utils::get_user_server_info; use crate::m3u_filter_error::{M3uFilterError, M3uFilterErrorKind}; -use crate::model::api_proxy::UserCredentials; +use crate::model::api_proxy::ProxyUserCredentials; use crate::model::config::{Config, ConfigTarget}; use crate::model::playlist::{PlaylistGroup, PlaylistItemType}; use crate::processing::m3u_parser::consume_m3u; @@ -240,7 +240,7 @@ pub(crate) fn write_strm_playlist(target: &ConfigTarget, cfg: &Config, new_playl Ok(()) } -pub(crate) fn rewrite_m3u_playlist(cfg: &Config, target: &ConfigTarget, user: &UserCredentials) -> Option { +pub(crate) fn rewrite_m3u_playlist(cfg: &Config, target: &ConfigTarget, user: &ProxyUserCredentials) -> Option { let filename = target.get_m3u_filename(); if filename.is_some() { if let Some((m3u_path, url_path, idx_path)) = get_m3u_file_paths(cfg, &filename) { diff --git a/src/utils/config_reader.rs b/src/utils/config_reader.rs index 5109636fc..3498d3279 100644 --- a/src/utils/config_reader.rs +++ b/src/utils/config_reader.rs @@ -52,7 +52,7 @@ pub(crate) fn read_config(config_path: &str, config_file: &str, sources_file: &s result._config_path = config_path.to_string(); result._config_file_path = config_file.to_string(); result._sources_file_path = sources_file.to_string(); - match result.prepare() { + match result.prepare(true) { Ok(_) => Ok(result), Err(err) => Err(err) } diff --git a/src/utils/file_utils.rs b/src/utils/file_utils.rs index 414daf6af..a83defc73 100644 --- a/src/utils/file_utils.rs +++ b/src/utils/file_utils.rs @@ -37,7 +37,7 @@ fn get_default_path(file: &str) -> String { }) } -fn get_default_file_path(config_path: &str, file: &str) -> String { +pub(crate) fn get_default_file_path(config_path: &str, file: &str) -> String { let path: PathBuf = PathBuf::from(config_path); let default_path = path.join(file); String::from(if default_path.exists() {